diff --git a/README.md b/README.md index 429eace..3464c11 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,9 @@ Pullcept は、人間と複数の独立した AI / Li+ セッションが、一 - 部屋ソケット(`127.0.0.1` の任意ポート、Bearer トークン必須) - `.mcp.json` への登録と、channel が成立する条件を満たした CLI 起動 - メッセージ一覧・発言者表示・入力欄を備えたチャットルーム UI -- 部屋のログ(`logs/main.jsonl` への追記)と、それを会話面の左の列に並べる履歴 +- 部屋のログ(`logs/main/{トピック}.jsonl` への追記と `logs/main/index.json` の索引)と、会話面の左の列に並ぶトピックの一覧 +- トピックの再開。一覧から選ぶとそのトピックが部屋へ戻り、アカウントに `再開コマンド` があればそこに居た CLI セッションも戻ります +- 参加者が自分でトピックの過去発言を引く道具(サイドカーの `read_room_history`) - Windows 上で `npm ci` と Rust のコンパイル確認を行う CI - GitHub Release 公開時に Tauri バンドルを作成する CD @@ -44,7 +46,8 @@ Pullcept は、人間と複数の独立した AI / Li+ セッションが、一 ### 未実装 - 複数の AI セッションを同一の部屋へ参加させる運用(同時発話の抑制を含む)。経路は通っていますが、実機での往復は未確認です -- 部屋のログと履歴の実機確認。追記と読み出しは実装済みで CI は通っていますが、実機での操作は未確認です +- トピックの実機確認。区切り・再開・名前の変更・既存ログの移行はいずれも実装済みで CI は通っていますが、実機での操作は未確認です +- `read_room_history` を実際のセッションが呼ぶところの実機確認。サイドカーのラウンドトリップテストは通っていますが、エージェントが必要な場面で自分から引くかどうかは未計測です - 複数の部屋。ログのパスは部屋名を位置として持ちますが、部屋そのものは一つです - plugin としての allowlist 掲載(配布の第二段階) @@ -58,7 +61,7 @@ npm run tauri dev 上の行は**参加するアカウントを選んで参加するだけ**の行です。アカウントを作る・直す・消すのは、右の「参加者」の見出しの隣にある「+」と、各行の「編集」から開くウィンドウで行います。 -そのウィンドウには、名前・種別・色と、AI のアカウントなら作業ディレクトリ・起動オプションがあります。**決定を押すまで何も保存されません。** 「+」は空のフォームを開くだけで、取消せばアカウントは作られません。編集も同じで、取消せば元のままです。削除はそのウィンドウの中にあり、二度押しです。 +そのウィンドウには、名前・種別・色と、AI のアカウントなら作業ディレクトリ・キャラクター・起動オプション・再開コマンドがあります。**決定を押すまで何も保存されません。** 「+」は空のフォームを開くだけで、取消せばアカウントは作られません。編集も同じで、取消せば元のままです。削除はそのウィンドウの中にあり、二度押しです。 「名前」と「色」は、そのアカウントが部屋で名乗る名前と、発言に付く色です。**いつでも変えられます。** 同一性は名前ではなく内部の id にあるため、改名しても稼働中のセッションの登録は追随します。色を選ばずに参加することもでき、その場合は名前から色が決まります。 @@ -66,6 +69,13 @@ npm run tauri dev 「起動オプション」には `--dangerously-skip-permissions` のように、CLI へ渡したいオプションをそのまま書けます。アプリは部屋の channel エントリ(`server:pullcept-room--`)をここへ統合するので、別の channel サーバを指定しても部屋の入力路は残ります。実際に起動する行はその下に表示されます。 +「起動オプション」と「再開コマンド」には `{session_id}` を書けます。**トピックの再開に使う組です。** + +- 起動オプションに書くと、新しいセッションを始めるときにアプリが決めた UUID がそこへ入ります。`claude` なら `--session-id {session_id}` です。書いた場合だけ id が配られ、そのトピックに記録されます。 +- 再開コマンドは、そのトピックに記録された id へ戻る**一行まるごと**です。先頭の語が起動する command になります。`claude` なら `claude --resume {session_id}` です。 + +どのフラグが id を運ぶかは CLI ごとに違うため、アプリはフラグを持たず、書かれた場所へ差し込みます。**どちらも空のままで構いません。** その場合そのアカウントは、開き直したトピックへ新しいセッションとして着席し、それまでに言われたことは `read_room_history` で自分から引きます。 + 作業ディレクトリは初回だけホームディレクトリが入っているので、セッションを動かしたいディレクトリへ変更してください。 右の参加者パネルは**一つのリスト**です。部屋にいる参加者と、まだ起動していないアカウント(「未起動」)が、種別ごとのグループに分かれて並びます(`user — 1` / `AI — 2` のように、件数も出ます)。名前はその参加者の色で出ます。居ない相手は色を保ったまま薄くなり、点が塗りから輪郭に変わります。自分の行には「(あなた)」が付きます。 @@ -100,6 +110,18 @@ npm run tauri dev 入力欄から発言すると channel notification としてセッションへ届き、セッションが `say_to_room` を呼び返すとメッセージ一覧へ並びます。往復が成立しないときの切り分け手順は [`docs/0-requirements.md`](docs/0-requirements.md) を参照してください。 +### トピック + +会話面の左は**トピックの一覧**です。トピックは会話の記録ではなく、会話が行われる器です。一覧から選ぶとそのトピックが部屋へ戻り、続きを話せます。 + +区切るのは「新規」だけです。アプリを起動したときも新しいトピックが開きますが、二つは独立しています——一回の起動で複数のトピックを持つことも、起動をまたいで一つのトピックを続けることもできます。何も話さずに閉じた回は一覧に残りません(最初の発言が着いた時点で記録されます)。 + +トピック名は最初の発言の冒頭から自動で付きます。行をダブルクリックすると、その場で直せます(Enter で決定、Escape で取消)。 + +トピックはアカウントごとに、そこに居た CLI セッションの id を持ちます。開き直して「開始」を押すと、そのアカウントに再開コマンドがあれば同じセッションが戻ります。**戻せない席があってもトピックは開きます。** 戻せなかった席は新しいセッションとして着席し、必要になったときに `read_room_history` で過去の発言を自分で引きます。部屋の側から過去を流し込むことはしません。 + +ログは `%APPDATA%\org.liplus-project.pullcept\logs\main\` の下に、トピックごとの `.jsonl` と `index.json` として置かれます。以前の `logs\main.jsonl` は、初回の読み出しで一つのトピックとして引き継がれます(移動であり、捨てません)。 + ### サイドカー単体の確認 ```powershell @@ -170,6 +192,7 @@ sidecar/ 部屋の MCP channel サーバ(Node) src/ チャットルーム UI(TypeScript) src-tauri/src/ Tauri、部屋ソケット、PTY、設定・セッション保存の Rust 実装 crates/mcp-config/ .mcp.json 登録と起動フラグ検査(tauri 非依存、テスト対象) +crates/room-floor/ 同時発話の順序付け(tauri 非依存、テスト対象) portable-pty-patch/ Windows 対応を含む portable-pty のローカルパッチ .github/workflows/ Windows CI とリリース用 CD ``` diff --git a/crates/mcp-config/src/lib.rs b/crates/mcp-config/src/lib.rs index 3f72399..f118b11 100644 --- a/crates/mcp-config/src/lib.rs +++ b/crates/mcp-config/src/lib.rs @@ -185,6 +185,39 @@ pub fn declares_settings(args: &[String]) -> bool { .any(|arg| arg.split('=').next().unwrap_or(arg) == SETTINGS_FLAG) } +/// What an account writes where the id of a CLI session goes. +/// +/// The one thing this app knows about resuming a session is that the id is +/// decided here rather than read back out of the CLI's output. Which flag +/// carries it is the CLI's business, and the CLI is per-account +/// (`Account::command`) — so the app substitutes into a line the person wrote +/// instead of holding a flag of its own. `claude` spells the two halves +/// `--session-id ` and `--resume `; another CLI spells them +/// otherwise, or not at all, and an account that writes the placeholder nowhere +/// simply has no session id (#115, decision 4B). +pub const SESSION_ID_PLACEHOLDER: &str = "{session_id}"; + +/// Whether these arguments have somewhere to put a session id. +/// +/// What decides whether one is minted at all. Minting unconditionally would +/// hand out an id no launch passes on, and the topic would then record a +/// session that never existed under that name. +pub fn declares_session_id(args: &[String]) -> bool { + args.iter().any(|arg| arg.contains(SESSION_ID_PLACEHOLDER)) +} + +/// Put the session id where the account said it goes. +/// +/// Every occurrence in every argument, and inside a larger argument as well as +/// alone: `--session-id={session_id}` is one argument, and so is +/// `--resume={session_id}`. Arguments naming no placeholder come through +/// untouched. +pub fn substitute_session_id(args: &[String], session_id: &str) -> Vec { + args.iter() + .map(|arg| arg.replace(SESSION_ID_PLACEHOLDER, session_id)) + .collect() +} + /// The character an account speaks as, or `None` when it declares none. /// /// Blank is the same state as absent. The field is a text input on the screen, @@ -1055,4 +1088,32 @@ mod tests { ] ); } + #[test] + fn a_session_id_is_substituted_wherever_the_account_wrote_it() { + let args = split_launch_options("--resume {session_id} --verbose"); + let filled = substitute_session_id(&args, "0f5a-uuid"); + assert_eq!(filled, vec!["--resume", "0f5a-uuid", "--verbose"]); + } + + #[test] + fn a_session_id_is_substituted_inside_one_argument_too() { + let args = split_launch_options("--session-id={session_id}"); + let filled = substitute_session_id(&args, "0f5a-uuid"); + assert_eq!(filled, vec!["--session-id=0f5a-uuid"]); + } + + #[test] + fn options_with_no_placeholder_declare_no_session_id() { + let args = split_launch_options("--dangerously-skip-permissions"); + assert!(!declares_session_id(&args)); + assert_eq!(substitute_session_id(&args, "0f5a-uuid"), args); + } + + #[test] + fn options_naming_the_placeholder_declare_a_session_id() { + assert!(declares_session_id(&split_launch_options( + "--session-id {session_id}" + ))); + } + } diff --git a/crates/room-floor/src/lib.rs b/crates/room-floor/src/lib.rs index ee2792d..ad359b8 100644 --- a/crates/room-floor/src/lib.rs +++ b/crates/room-floor/src/lib.rs @@ -140,6 +140,23 @@ impl Floor { self.seq } + /// Empty the floor and start its numbering over. + /// + /// For a room changing topic: the posts of the topic being left are not the + /// floor of the one being entered, and leaving them would refuse the first + /// thing said in the new one and hand back the old one's contents as + /// "missed" (`room.rs`). + /// + /// The caller has seats holding `since` positions taken against the old + /// numbering. Every one of them is now past the end of this floor, which + /// reads as having seen everything rather than nothing — so the caller puts + /// them back to [`Floor::seq`] itself. That is the room's to do: this type + /// holds no seats. + pub fn reset(&mut self) { + self.seq = 0; + self.log.clear(); + } + /// Check the speaker against the floor and, if they are clear, put their /// post on it. /// @@ -248,6 +265,22 @@ mod tests { } } + #[test] + fn a_reset_floor_admits_a_speaker_carrying_a_watermark_from_before_it() { + let mut floor = Floor::new(); + floor.admit("master", 0, None, post("m-1", "Master", "前のトピック")); + floor.admit("claude", 0, Some("m-1"), post("c-1", "Claude", "はい")); + + floor.reset(); + assert_eq!(floor.seq(), 0); + + // The watermark names a post the floor no longer holds, which resolves + // to position 0. An empty floor has nothing past 0, so the speaker is + // admitted rather than handed back a topic they have already read. + let admission = floor.admit("master", 0, Some("c-1"), post("m-2", "Master", "続き")); + assert_eq!(admitted_seq(&admission), 1); + } + #[test] fn an_empty_floor_admits_the_first_speaker() { let mut floor = Floor::new(); diff --git a/docs/0-requirements.md b/docs/0-requirements.md index d5f212b..14eadc5 100644 --- a/docs/0-requirements.md +++ b/docs/0-requirements.md @@ -68,6 +68,7 @@ - **構想上の機能と実装済みの機能を明確に区別する。** 未実装を完成済みとして記載しない。 - ライセンスは Apache-2.0。`portable-pty-patch/` は MIT License のコードを含む。 - Windows を第一対象とする(ConPTY 依存)。 +- **部屋から参加者へ過去を push しない。** 席を取る前の発言は配られない(#31 / #39)。トピックの読み出しは参加者が自分から引く pull であり、この一線を動かさない。 - 実装変更と docs 更新は同一 PR とする。 ## architecture @@ -134,6 +135,8 @@ push の形(参照実装 `Liplus-Project/github-webhook-mcp` `local-mcp/src/in | サイドカー → 部屋 | `post` | `message_id` / `content` / `to`(任意) / `ts` / `last_seen`(任意) | | 部屋 → サイドカー | `post` | `message_id` / `speaker` / `content` / `to`(任意) / `ts` | | 部屋 → サイドカー | `post_result` | `message_id` / `delivered` / `missed` | +| サイドカー → 部屋 | `history` | `request_id` / `limit`(任意) / `before`(任意) | +| 部屋 → サイドカー | `history_result` | `request_id` / `posts` / `has_more`、または `request_id` / `error` | 発言のフレームは `post` 一種類であり、向きによって名前が変わらない。誰が出した発言かはフレームの種別ではなく `speaker` の値で表す。 @@ -141,7 +144,9 @@ push の形(参照実装 `Liplus-Project/github-webhook-mcp` `local-mcp/src/in `post_result` は投稿への返答であり、投稿した接続にのみ返す。fan-out ではない。相関は `message_id` で取る。これにより `say_to_room` は送りっぱなしではなく往復になり、呼び出しそのものが境界になる(後述の「床」)。 -未知の `type` は拒否せず無視する。部屋側がフレーム種別を増やしても、旧サイドカーが壊れないため。`protocol` はフレームの形が変わり、サイドカー側が気づく必要があるときに上げる。`say` / `reply` から `post` への統合で `protocol` を 2 へ、`hello` への `hue` の追加で 3 へ、`post` の `last_seen` と `post_result` の追加で 4 へ、`hello` への `account_id` の追加で 5 へ上げた。サイドカーはアプリが `.mcp.json` へ書く同梱パスから起動するため、部屋とサイドカーの版が食い違う形が構造上無く、旧フレームとの互換は残していない。 +`history` は参加者からの**読み出しの要求**であり、`history_result` はその答えである。投稿した接続にのみ返す点も、相関を id(`request_id`)で取る点も `post_result` と同じ形である。**これは pull であって push ではない。** 部屋が自分から過去を配ることは無く、席を取る前の発言が届かないことも変わらない——変わるのは、参加者が自分から取りに行けることだけである(後述の「トピック」)。 + +未知の `type` は拒否せず無視する。部屋側がフレーム種別を増やしても、旧サイドカーが壊れないため。`protocol` はフレームの形が変わり、サイドカー側が気づく必要があるときに上げる。`say` / `reply` から `post` への統合で `protocol` を 2 へ、`hello` への `hue` の追加で 3 へ、`post` の `last_seen` と `post_result` の追加で 4 へ、`hello` への `account_id` の追加で 5 へ、`history` / `history_result` の追加で 6 へ上げた。サイドカーはアプリが `.mcp.json` へ書く同梱パスから起動するため、部屋とサイドカーの版が食い違う形が構造上無く、旧フレームとの互換は残していない。 `to` は両方向で同じ語彙であり、宛先となる参加者の表示名を入れる。任意であり、無いときは `null` ではなくキーごと省く。 @@ -228,15 +233,58 @@ push の形(参照実装 `Liplus-Project/github-webhook-mcp` `local-mcp/src/in 部屋で言われたことを追記のみの jsonl として残す。部屋の発言はウィンドウの DOM にしか無く、閉じれば消えていた——引用できないことは症状のひとつであり、実際に何が起きたかを根拠にする設計判断が参加者の記憶だけを頼りにしていたことが、これを持たせた理由である(#48)。 +**単位はトピックである(#115)。** 一本の流れに全部を積む形は #48 が残したものであり、この issue が置き換える。トピックは会話の一区画ではなく、会話が行われる器である——開き直せばその会話が部屋へ戻り、続きが話せる。 + +```text +logs/{部屋名}/index.json トピックごとの索引: 名前・作成時刻・{account_id: session_uuid} +logs/{部屋名}/{トピック}.jsonl そのトピックの発言、追記のみ +``` + - **ログは部屋のものであってタブのものではない。** `config.rs` の `SavedChatMessage` / `SessionData` / `TabSessions` は流用しない。`SavedChatMessage` は `role` と `content_type` を持つチャットアシスタントの語彙であり、部屋には `role` の当たる軸が無い——人もセッションも一種類の参加者であり、二つの行を分けるのは名前だけである(#39)。`TabSessions` の鍵はタブであり、タブは起動の形であって会話の器ではない。どちらを配線しても、#39 が外した非対称が保存層から戻る。 - **一行が一つの発言であり、載るのは五欄である。** `message_id` / `speaker` / `content` / `to` / `ts`。 - **`hue` と `own` は載せない。** `own` は見る側の属性であって話者の属性ではなく(`room.rs` の型が自らそう言っている)、記録すれば一人の視点を発話の一部として残すことになる。`hue` は参加した時点の宣言であり、読み戻す頃にはそれを持っていた席が無い——引き直せば、誰も宣言していない色を描くことになる。 -- **判定と同じロックの内側で書く。** `deliver()` が床を判定した一回の取得の中で追記する。ロックを離してから書く形では、床が既に順序を付けた二人が逆順でディスクへ届きうるため、一つの会話に二つの順序が生まれる。並びの権威は部屋であるという既存の形(上記「床」)を、ファイルの側でも崩さない。 +- **一トピック一ファイルとする。** 一ファイル+`topic_id` 欄の案は採らない。理由は二つあり、両方が要る。一つのトピックを読むのに一つのファイルだけを読めばよくなること(#114 はこれで解消する)と、トピックが発言には無いもの——そこに居たセッションの id——を持つことである。投稿に欄を増やす形にはその置き場が無い(#39、参加者クラスを値域に取る欄を作らない)。 +- **実在はディレクトリであり、索引はその注釈である。** 索引に無い `.jsonl` は読み出しのときに拾って項目を建てる。これが遅延生成を安全にしている——起動のたびに空の行が索引へ残らず、発言がファイルへ届いて索引へ届く前に落ちても、ファイルだけから項目を建て直せる。 +- **索引へ書くのは意図のある行為だけである。** 最初の発言、セッション id の記録、名前の変更。「アプリが開かれた」はそこに含まれない。 +- **判定と同じロックの内側で書く。** `deliver()` が床を判定した一回の取得の中で追記する。ロックを離してから書く形では、床が既に順序を付けた二人が逆順でディスクへ届きうるため、一つの会話に二つの順序が生まれる。並びの権威は部屋であるという既存の形(上記「床」)を、ファイルの側でも崩さない。書き込み先のトピックも同じ取得の中で読む——外で読めば、切り替えが二つの操作の間に挟まったとき、片方のトピックの順序で受理された発言がもう片方のファイルへ落ちる。 +- **索引はロックの外で書く。** 別のファイルの読み書きであり、部屋を止めて行う仕事ではない。発言はそのどれにも待たされない。 - **追記の失敗で発言は失われない。失敗は画面に出す。** 発言はディスクに触れる前に部屋へ入っており、書き込みの失敗が取り消せるものではない。失われうるのは記録の側だけである。したがって配送も戻り値も追記の成否に依存させず、代わりに `room-log-error` イベントで画面の状況行に出す——黙って記録を止めたログは依然としてログに見え、後から見に行った者はその空白を「何も言われなかった」と読む。 -- **読み出しは画面のためのものであり、再配信ではない。** `room_log` コマンドは画面だけが呼ぶ。途中から参加したセッションへ過去の発言を流し込むことはしない(`room.rs` の現在の扱いを変えない)。流し込めば、部屋が「誰が何を聞いたか」を持ち始める——#31 と #39 が退けた形である。 -- **ローテーションも上限も持たない。** `design/Vision.dc.html` が日付を持たない `main.jsonl` を単数形で置いていることに従う。古い方から捨てるログは、「実際に何が言われたか」という、これが在る理由そのものの問いに、問う価値が出るちょうどその距離で沈黙する。 -- **ファイル名は `logs/{部屋名}.jsonl` であり、部屋名は `main` に固定する。** Vision の `main` を部屋名の位置と読む。複数部屋は実装しない——実装するときに増えるのはここの値であって、パスの階層ではない。 +- **画面への読み出しはトピックを指定して行う。** `room_topic_log` は開くトピックを名指す。一覧から選ぶという操作がそもそも「現在ではないトピックを読む」ことであるため、現在のトピックを暗黙に読む形では成り立たない。 +- **参加者への読み出しは pull のみである。** 部屋から過去を配る経路は作らない(下記「トピック」)。 +- **ローテーションも上限も持たない。** トピックごとにも、全体としても。古い方から捨てるログは、「実際に何が言われたか」という、これが在る理由そのものの問いに、問う価値が出るちょうどその距離で沈黙する。 +- **部屋名はパスの位置として持ち、`main` に固定する。** Vision の `main` を部屋名の位置と読む。複数部屋は実装しない——実装するときに増えるのはここの値であって、パスの階層ではない。 - **読めない行は飛ばす。読み全体は失敗させない。** 途中で終わった実行が残す千切れた末尾がこの経路に当たり、その最後の一行のために全履歴を拒むのは、何も守らずにすべてを失う。飛ばした件数は追記の失敗と同じイベントで画面へ返す。 +- **以前の `logs/{部屋名}.jsonl` は一つのトピックとして引き継ぐ。** 索引を初めて建てるときに移動する。コピーではない——一つの会話が二つのファイルに在ることは、その会話に二つの順序が在ることであり、部屋が権威を持っている当のものである。捨てない(#115、決定8)。 + +### トピック + +トピックは会話の器であり、会話の記録ではない。開き直すと、そこに居た CLI セッションが**自分の文脈を持って**戻る(#115)。 + +**区切りは手動である。** 「新規」を押して切る。起動とは独立しており、一回の起動で複数トピックも、起動をまたいで一トピックの継続もありうる。 + +**起動時は新しいトピックを開く。** 最後のトピックの続きを自動では開かない。部屋は毎回空から始まり、過去は一覧から選んで開く。#48 の「起動時の部屋は新規である」は既定として残り、変わったのは「消えた先に読みに行ける場所があるか」ではなく「そこから戻せるか」になった点である。 + +**新規トピックは遅延生成する。** 起動のたびに索引へ空のトピックを書くと、何も話さずに閉じた回が一覧に残り続ける。起動時のトピックは「まだ索引に無い現在のトピック」として部屋が持ち、最初の投稿が着いた時点で索引とファイルができる。画面はそれを一覧に無くても現在のトピックとして描く——毎回の起動直後には、部屋に在って一覧に無いトピックがちょうど一つ在る。 + +**復帰の手段は二段構えである。** ネイティブ resume を持つ CLI はそれを使い、持たない CLI にはサイドカー経由の読み出しを与える。履歴をテキストで PTY へ流し込む力業は採らない。理由は三つある。トピックが伸びるほど毎回の起動が高くつく(参加者が要るかどうかに関わらず全部渡すため)。CLI は流し込まれた文をユーザ入力として読むため、「自分が言ったこと」と「言ったと聞かされたこと」を区別できない。そして端末ペインにその塊が見える——あのペインはセッションのものである、というのが `RESUMED_NOTICE` の設計思想である(#84)。読み出しは力業を push でやる代わりに pull にしたものであり、これが在れば力業は要らない。 + +**再開コマンドはアカウントの属性である。** `{session_id}` を含む一行であり、先頭の語が command になる(例: `claude --resume {session_id}`)。トピックが `{account_id: session_uuid}` を保持し、再開時にそこへ差し込む。 + +**新規セッションへ渡す id は、同じ置換子を起動オプションへ書くことで配る。** 欄を増やさない。`claude` では `--session-id {session_id}` に当たるが、どのフラグが id を運ぶかは CLI ごとの問いであり、再開コマンドが答えているのと同じ問いである。アプリはフラグを持たず、書かれた場所へ差し込む。**書かなければ id を配らない**——それは resume を持たない CLI が恒常的に置かれる状態であり、失敗ではない。 + +**判定は「このトピックがこのアカウントのセッションを持つか」で行う。** 「アカウントが resume を持つか」ではない。記録と再開コマンドの両方が揃ったときだけ resume であり、どちらかが欠ければ新規起動である。起動チェック(互換しないフラグ、`--settings` の二重宣言)は解決後の行に対して走る——アカウントの行を見て別の行を spawn する形にしない。 + +**再開できない席があってもトピックは開く。** 復帰できる席は復帰させ、できない席は新規起動として着席する。読み出しがあるため、復帰できなかった席も自分で辿れる。トピックが開かない形は取らない。resume だったかどうかは画面の状況行で言う——黙って同じ言葉で済ませると、戻ってきたのかどうかを人が判別できない。 + +**トピックへ入ると床は空になり、席は入口へ戻る。** 前のトピックの発言は、入るトピックの床ではない。残したままにすると、開き直したトピックで最初に発言した参加者が、前のトピックの保持窓ごと「見落とし」として返される——実際にはその全部を読んでいる参加者に対して、である。席そのものは残る。トピックの切り替えは誰かを部屋から出すことではなく、どこで話しているかが変わることである。 + +**部屋は過去の発言を channel へ push しない。** 席を取る前の発言が届かないことは変えない。#31 / #39 で退けた「部屋が誰の既読を持つ」形に触らずに済む。トピックによる復帰はこの制約を緩めるのではなく、不要にする。 + +**読み出しの道具は `say_to_room` の隣に置く。** `read_room_history` は現在のトピックのそれまでの発言を古い順で返す。既定 50 件、上限 200 件、`before` に `message_id` を渡すとその手前が返る。トピックに上限が無い以上、一回の tool 結果へ全部を載せる形は採らない。**この道具は発言しない。** 「返信ツールは `say_to_room` 1 本に限定する」は投稿の軸の制約であり、読み出しはその軸に載らない——二つめの喋り方があれば「どちらで答えるのか」が参加者の判断に戻るが、読み方が増えてもそれは起きない。 + +**返すものは「あなた宛ではない」と明示する。** 読み出しが返す発言は、その参加者へ配られたことが一度も無いものである。届いた発言として読めば答えるべきものになる。 + +**トピック名は最初の発言の冒頭から自動生成し、後から手で変更できる。** 日時のみの一覧は可読性が低い。生成は最初の発言が着いた時点で一度だけ行い、既に名前があるトピックには触れない——話す前に名前を付けたトピックの名前を、最初の発言が取り返す形にしない。 ### MCP サーバの実装方式 @@ -268,8 +316,11 @@ liplus-desktop の `stream_parser.rs` および `spawn_stream_pty` / `spawn_stre - 順番の作法(先に届いた答えを読んでから決める、送信直前の読み直し。`instructions` のみで、protocol もツールも増やしていない) - 床(`say_to_room` の `last_seen` と `post_result`、既存ロック内での判定と刻印、見落としがあるときの拒否と返却。ツールは 1 本のまま) - 拒否が返した発言の描画(画面。`message_id` による重複除去、返ってきた最新への透かしの前進、発言に載せて運ぶ宣言色、描いた分だけを述べる状況表示) -- 部屋のログ(`logs/main.jsonl` への追記、床の判定と同じロック内での書き込み、保存する五欄、`hue` と `own` を持ち込まない写像、追記の失敗の画面への通知、読み出しコマンド、参加者への再配信をしないこと) -- 履歴(会話面の左の列、起動時の一回の読み出し、部屋の本文へ復元しないこと、参加者パネルと対の寸法、日付を含む時刻、名前から導く色) +- 部屋のログ(`logs/main/{トピック}.jsonl` への追記、床の判定と同じロック内での書き込み、保存する五欄、`hue` と `own` を持ち込まない写像、追記の失敗の画面への通知、トピックを指定する読み出しコマンド、参加者への再配信をしないこと) +- トピック(手動の区切り、起動時の新規トピックと遅延生成、索引とディレクトリの照合、最初の発言からの命名とその場での変更、既存 `logs/main.jsonl` の移行、切り替え時の床の初期化) +- トピックの再開(アカウントの再開コマンド、`{session_id}` の置換、トピックへのセッション id の記録、再開できない席の新規着席) +- 参加者からの読み出し(サイドカーの `read_room_history`、`history` / `history_result` フレーム、pull のみ、ページと `before` の巻き戻し) +- トピックの一覧(会話面の左の列、新しい順、現在のトピックの描画、選択による部屋への読み戻し、参加者パネルと対の寸法、日付を含む時刻、名前から導く色) - 参加者モデル(統一 `post` フレーム、発言者以外の全参加者への配送、接続同一性による自分の発言の抑止と名簿の同一性、人間を含む名簿) - アカウント(作成・編集・削除、種別、名前と色と作業ディレクトリと起動オプション、リストでのオフライン表示、一つのアカウントは一つの部屋に一席まで、`config.json` からの移行) - アカウントのキャラクター(output style の `name:` を持つ欄、起動時の `--settings` による選択、ファイルを書き出さない形、起動オプションの `--settings` との併記の拒否、宣言しないアカウントは既定のまま) @@ -380,9 +431,14 @@ liplus-desktop の `stream_parser.rs` および `spawn_stream_pty` / `spawn_stre - クラッシュした場合の後始末(#85 の範囲外)。job object へ入れれば OS が落とすが、確認を挟む形とは別の軸であり、実装していない。正常終了の経路を閉じた後も、この経路からは孤児が生まれうる。 - 孤児の引き取り(#85 の範囲外、#84 関連)。起動時に前回の孤児を見つけて繋ぎ直す形。pty id が Rust のメモリにしか無いため持続化の話になる。 - CLI の出力から状態を読む形(「ツール使用中」の粒度)。#82 は段階 1 として出力を読まない二つの合図だけを採り、この形は実測してから別 issue で判断すると決めた。 -- 部屋のログと履歴の実機確認。追記されること、再起動後に左の列へ並ぶこと、部屋の本文が空から始まること、二つの面の寸法が揃うことはいずれも実装済みで CI の型検査と Rust のコンパイルは通っているが、実機での操作は未確認である。追記の失敗の経路(`room-log-error`)はそもそも失敗を作り出す必要があるため、実機でも未観測である。 -- 複数の部屋。ログのパスは部屋名を位置として持つが(`logs/{部屋名}.jsonl`、現在は `main` に固定)、部屋そのものは一つである。 -- 履歴からの引用操作。左の列は読む面であり、行を選んで入力欄へ持っていく経路は持たない。 +- 部屋のログとトピックの実機確認。追記されること、再起動後に左の列へトピックが並ぶこと、部屋の本文が空から始まること、選んだトピックが本文へ戻ること、名前の自動生成とその場での変更、二つの面の寸法が揃うことはいずれも実装済みで CI の型検査と Rust のコンパイルは通っているが、実機での操作は未確認である。追記の失敗の経路(`room-log-error`)はそもそも失敗を作り出す必要があるため、実機でも未観測である。 +- 既存 `logs/main.jsonl` の移行の実機確認。移動と索引への採録は実装済みで、対象は現時点で 2 行だが、実機では未実行である。 +- トピックの再開の実機確認。`--session-id` で配った id が実際に `claude --resume` で戻るところは未計測である。CLI の選択肢の存在は 2026-08-27 に確認しているが(#115 の前提)、往復そのものは辿っていない。同じ id を二度 `--session-id` で渡した場合の CLI の挙動も未計測であり、この実装はその経路へ入らない——記録が在るときは、新規起動側でも新しい id を配り直す。 +- `read_room_history` を実際のセッションが呼ぶところの実機確認。サイドカーのラウンドトリップテストは通っているが、エージェントが必要な場面で自分から引くかどうかは未計測である。押し付けないという判断がそのまま「引かれない」に落ちる可能性は残っており、そこは `instructions` の書き方の問題として観測してから判断する。 +- トピックを切り替えたときに、席を持っている参加者がそれをどう受け取るかの実機確認。床は空になり席は入口へ戻るが(上記「トピック」)、走っているセッション自身は自分の文脈をそのまま持ったままである。 +- 複数の部屋。ログのパスは部屋名を位置として持つが(`logs/{部屋名}/`、現在は `main` に固定)、部屋そのものは一つである。 +- トピックからの引用操作。読み戻した行は部屋の本文に在るが、行を選んで入力欄へ持っていく経路は持たない。 +- トピックの削除。一覧から消す経路は持たない。 - plugin としての allowlist 掲載(配布の第二段階) ### 二つの地色 @@ -397,27 +453,31 @@ liplus-desktop の `stream_parser.rs` および `spawn_stream_pty` / `spawn_stre この規則の下では、選択中の端末タブは地色で選択を言えなくなる。代わりに言うのは枠(未選択の `transparent` から `--line` へ)と文字色(`--muted` から参加者自身の色へ)である。参加者パネルの選択中の行と同じ形——枠のある窓の地の箱——になり、同じ選択を二つの面が同じ手で描く(#75)。それまでは逆を向いていた。パネルの地が `--surface` なので選択された行は `--bg` で浮き、端末ヘッダの地が `--bg` なので選択されたタブは `--surface` で沈んでいた。 -### 履歴 +### トピックの一覧 + +会話面の**左**に常時出る列を持つ。トピックの一覧であり、選べばそのトピックが部屋へ戻る(上記「トピック」)。 -会話面の**左**に常時出る列を持つ。部屋のログを読み出して、このウィンドウが開く前に言われたことを並べる(上記「部屋のログ」)。 +この列は過去の発言を一本の流れで並べる帯だった(#48)。求められていた形は一覧であり、埋めた粒度が外れていた——#48 で Master が判断したのは「左サイドバーに履歴として表示」「起動時は新規ルーム」の二点であって、一本の流れか、トピックの一覧か、ではなかった(#115)。 -**起動時の部屋は新規である。** 過去ログを `#room` の本文へ復元しない。閉じれば部屋の中身は消えるという現在の振る舞いを変えない——変えたのは、消えた先に読みに行ける場所があるかどうかだけである。 +**選んだトピックは部屋の本文へ戻る。** #48 の「過去ログを `#room` の本文へ復元しない」はここで巻き戻る。その一行は上書きされたものとして扱う。**起動時の部屋が新規であることは残る**——変わったのは、過去が読める場所ではなく、過去へ戻れるかどうかである。 -**二つの面を分けるのは起動である。** 左に在るのはこのウィンドウが開く前、右の部屋に在るのはこのウィンドウの中。だから履歴は発言が届くたびに伸びない——伸びる帯は隣の会話面をもう一度描いたものであり、二つを見分けている境界そのものが無くなる。読み出しは起動時の一回である。 +**新しい順に並べる。** 会話の一覧が読まれるのはこちらの端からである。ファイルは起きた順(追記の順)を保ち、逆順は読みの判断であって保存の判断ではない。 -**部屋の本文と履歴は混ぜない。** 別のサーフェスであることがこの機能の形であり、同じものを二か所に出すことではない。 +**現在のトピックは一覧に無くても描く。** 遅延生成のため、毎回の起動直後には部屋に在って索引に無いトピックがちょうど一つ在る(上記「トピック」)。空白にすると、自分の部屋のトピックが自分の一覧に無い状態として読まれる。 -**参加者パネルと対にする。** 幅(16.5rem)と地色を揃え、枠は会話面に向く側に置く(左は `border-right`、右は `border-left`)。部屋を挟む二つの面が別々の寸法を持てば、揃っていないこと自体が読まれる。中の部品は同じ語彙(`.panel-head` / `.panel-title` / `.empty`)を使う。 +**一件が持つのは名前と作成時刻である。** 名前は一行で切り、溢れは省略する——一覧は左端を縦に読むものであり、三行に折り返した題は次の行の位置を前の行の長さで決めることになる。まだ何も言われていないトピックは「未命名」と出す。空白にすると、読み出しに失敗した行と見分けが付かない。 -**一件の見た目は部屋の行と同じ形である。** 話者の色の罫、その上に発言者・宛先・時刻。ただし寸法はパネルのものであり、会話面の文字サイズ(`--room-font-size`)は載せない——あの寸法は会話面の本文を描く二つの要素にだけ宣言されるものであり(#81)、ここはパネルである。 +**名前はその場で直す。** 行をダブルクリックすると入力欄になり、Enter が決定、Escape とフォーカス喪失が取消である。生成された題は編集できる欄に置かれた出発点であり、アカウントの名前と同じものである。 -**古い方から並べ、末尾を開いた状態で出す。** 部屋の並びと同じ向きであり、二つは一続きに読める——この列の最後の行は、隣の部屋の最初の行の直前に言われたものである。だから下端が会話の連続する側であり、上端は何か月も前になる。上限を持たないログで一番古い行を毎回正面に出すのは、その距離が伸び続ける側を見せることになる。 +**「新規」は一覧の見出しの隣に置く。** 作る操作はそれが現れるリストの隣に置く——参加者パネルの「+」と同じ形である(#59)。 -**時刻には日付も出す。** 部屋の行は今この実行の中で言われたものなので時計だけで読めるが、履歴に在るものはすべてこのウィンドウより前であり、日付が置く側である。 +**参加者パネルと対にする。** 幅(16.5rem)と地色を揃え、枠は会話面に向く側に置く(左は `border-right`、右は `border-left`)。部屋を挟む二つの面が別々の寸法を持てば、揃っていないこと自体が読まれる。中の部品は同じ語彙(`.panel-head` / `.panel-title` / `.empty`)を使う。選択中の行は、参加者パネルの選択中の行と同じく枠のある窓の地の箱である(#75)。 -**色は名前から導く。** ログは宣言色を持たないため(上記「部屋のログ」)、`hue` の梯子の最後の段——名前からの導出——を使う。同じ名前を名乗った二人はここでは一色になる。これは、誰も宣言していない色を保存しないことの既知の代償であり、履歴は過去の面であって部屋の帰属の面ではない。 +**読み戻した行は部屋の行と同じ形で描く。** 別の面ではなくなったため、寸法も同じである。時刻には日付も出す——トピックは日をまたぐため、`14:32` だけではどの日か決まらない。**色は名前から導き、`own` はどの行にも立てない。** ログは宣言色も見る側の位置も持たないためであり(上記「部屋のログ」)、この画面自身の過去の発言もアクセントでは出ない。それ以外に過去であることの印は付けない——同じ会話であり、「起動のどちら側で言われたか」は発言の属性ではない。 -**空とエラーを分ける。** 記録が無いのは初回の実行であって失敗ではないため「記録なし」と出す。読み出しが失敗したときだけ理由を添えて出す。 +**読み戻しでは透かしを消す。** トピックへ入ると床は空になるため、読み戻した発言の `message_id` を申告しても位置 0 に落ちる。等価な値ではなく、本当のこと(この床では何も見ていない)を言う。描いた `message_id` は残す——拒否が保持窓の全件を返したときに、既に描いた行を二重に描かないためである(#108)。 + +**空とエラーを分ける。** トピックが無いのは初回の実行であって失敗ではないため「トピックなし」と出す。読み出しが失敗したときだけ理由を添えて出す。 ### 参加者パネル @@ -667,8 +727,18 @@ liplus-desktop の `stream_parser.rs` および `spawn_stream_pty` / `spawn_stre 表示は省略記号で切らずに折り返す。切れる末尾はアプリが統合した channel エントリであり、そこが「書いた行と走る行が違う」ことの中身である。一行のランチャ行には縦の余裕が無かったが、モーダルにはある(ランチャ行そのものは #62 で無くなった。下記「ランチャ行は無い」)。 +起動オプションには `{session_id}` を書ける。新しいセッションを始めるときに、このアプリが決めた UUID がそこへ入る(上記「トピック」)。`claude` では `--session-id {session_id}` に当たる。欄を増やさずここへ書く形にしたのは、どのフラグが id を運ぶかが CLI ごとの問いであり、再開コマンドが答えているのと同じ問いだからである。書かなければ id は配られない。 + **実測済み(2026-08-21)**: 複数の channel サーバを同時に有効化した状態で往復が成立した。`--print` での確認は無意味である(channel の読み込み自体が走らないため)ことも実測しており、確認は対話セッションでのみ可能。 +### 再開コマンド + +アカウントは起動コマンドの隣に**再開コマンド**を持つ。`{session_id}` を含む一行であり、先頭の語が command になる(例: `claude --resume {session_id}`)。トピックがアカウントごとのセッション id を持ち、再開時にそこへ差し込む(上記「トピック」)。置換は `crates/mcp-config/` に置き、テストの対象とする。 + +**一行まるごとである。** 起動オプションと別の欄なのは、戻り方が起動と同じ呼び出しとは限らないためであり、これは人がそのとき打つ行である。分割は起動オプションと同じ規則で行う。 + +**空は正当な状態であり、通常の状態である。** そのアカウントは開き直したトピックへ新規のセッションとして着席し、必要になったことは `read_room_history` で自分から引く。resume を持たない CLI も同じ状態に置かれる。 + ### 作業ディレクトリ セッションの作業ディレクトリはアカウントの属性であり、アカウントのモーダルで表示・変更する。未設定のままアプリのプロセス cwd へ暗黙にフォールバックしない。未設定のアカウントの起動は拒否し、モーダルを開く。 @@ -829,7 +899,7 @@ slug は読みやすさのためだけにあり、ASCII 英数字と `-` に落 したがって `src-tauri/src/` に残すのは、tauri の `State` / `AppHandle` に触れる部分だけとする。純粋ロジックをそちらへ書き足すと、書いた時点で検証不能になる。床の順序付けは読んでも確かめられない性質のもの(二人が同時に呼んだとき片方だけが通る)であり、この方針が最も効く箇所である。`src-tauri/src/room.rs` が持つのは、その判定を既存ロックの内側で呼ぶことと、結果をフレームとイベントへ載せることだけである。 -部屋のログ(`src-tauri/src/room_log.rs`)はこの方針の例外ではなく、境界の側である。パスの解決は `AppHandle` の `app_data_dir` に依存し、追記も読み出しもその解決の上に載るため、tauri 非依存の crate へは出せない。ただし純粋な部分は残っている——発言から五欄への写像と、一行の解析である。**これらは `cargo test` の対象になっていない。** 隠さずここに書く。写像は落とす欄を明示するために手で書いてあり(`LoggedPost::of`)、解析は `serde_json` そのものであるため、読んで確かめられる範囲に収めてある。床の順序付けのように読んでも確かめられない性質のものは、この面には無い——追記の順序は床の判定と同じロックの内側で決まっており、その順序付け自体は既に `crates/room-floor/` が持っている。 +部屋のログ(`src-tauri/src/room_log.rs`)はこの方針の例外ではなく、境界の側である。パスの解決は `AppHandle` の `app_data_dir` に依存し、追記も読み出しもその解決の上に載るため、tauri 非依存の crate へは出せない。ただし純粋な部分は残っている——発言から五欄への写像、一行の解析、題の生成、そして索引とディレクトリの照合である。**これらは `cargo test` の対象になっていない。** 隠さずここに書く。写像は落とす欄を明示するために手で書いてあり(`LoggedPost::of`)、解析は `serde_json` そのものであるため、読んで確かめられる範囲に収めてある。題の生成(`title_from`)と照合(`adopt_orphans`)は #115 で増えた分であり、前者は純粋関数、後者はディレクトリの読みに依存する。**照合のほうは読んで確かめられる範囲を出ている**——遅延生成の安全性がこれに乗っているためであり、`AppHandle` への依存を外して crate へ出すのが正しい形である。この issue の範囲では行っていない。床の順序付けのように読んでも確かめられない性質のものは、この面には無い——追記の順序は床の判定と同じロックの内側で決まっており、その順序付け自体は既に `crates/room-floor/` が持っている。 CI が実行するもの: @@ -909,6 +979,9 @@ CI が実行するもの: | 終了した端末をタブが閉じられるまで残す(#68) | 放置されたタブのぶん、スクロールバックがメモリに残る。#57 は「読んだら消える」でこれを避けていた | | 再読み込み後の端末を席から繋ぎ直す(#84) | 戻るのは出力・入力・終了だけであり、スクロールバックは戻らない。過去の出力は消えた xterm のインスタンスに在り、他のどこにも無い | | 「出力中」が端末の出力だけを言う(#82) | 「出力中」と出ても部屋へ発言が来ないことがある。語は部屋への到着を主張していないため受容する(Master、2026-08-24) | +| 読み戻した行が宣言色を持たない(#115) | ログは `hue` を持たないため、トピックを開き直すと過去の発言は名前から導いた色で出る。同じ名前を名乗った二人は一色になり、この画面自身の過去の発言もアクセントでは出ない。誰も宣言していない色を保存しないことの代償である | +| セッション id の置換子を起動オプションへ書く(#115) | 「新しいセッションを始める」側に専用の欄が無く、`{session_id}` を自分で書く必要がある。どのフラグが id を運ぶかは CLI ごとの問いであり、アプリがフラグを持てば `claude` 以外で外れる | +| 索引をディレクトリと照合して建て直す(#115) | 遅延生成の安全性がこの照合に乗るが、照合そのものは `AppHandle` に依存するため `cargo test` の外に在る(上記「テストの配置」) | ## 位置づけ diff --git a/index.html b/index.html index 16a3ddc..44391f3 100644 --- a/index.html +++ b/index.html @@ -42,25 +42,32 @@
- -