diff --git a/crates/mcp-config/src/lib.rs b/crates/mcp-config/src/lib.rs index ffbbd95..09df099 100644 --- a/crates/mcp-config/src/lib.rs +++ b/crates/mcp-config/src/lib.rs @@ -124,6 +124,20 @@ pub struct RoomRegistration<'a> { /// for an undeclared participant, and a value written here would be a /// declaration the person never made. pub agent_hue: Option, + /// Whether this session is being seated in a topic that already holds posts + /// it does not have. + /// + /// True when both hold: the topic has been spoken in, and this launch did + /// not go in on the resume line. The boundary is the seat that did not + /// resume, not the seat whose resume record was dropped — a fresh session + /// entering a topic that has been talked in is as blind as one whose way + /// back went, and a flag that caught only the second would cover half of + /// one state (#133). + /// + /// Carried so the manners can say it. The room still pushes nothing: what + /// crosses is the fact that there is something to pull, never the posts + /// themselves, and whether to pull stays the session's call. + pub unseen_history: bool, /// Absolute path of the sidecar entry point. pub sidecar_entry: &'a Path, /// Absolute path of the TypeScript runner that executes the entry point. @@ -543,6 +557,12 @@ pub fn register_sidecar(dir: &Path, room: &RoomRegistration<'_>) -> Result String { } } +/// Whether anything has been said in `topic_id` yet, without reading it. +/// +/// The file's size and nothing else, which is the same question [`read_posts`] +/// answers and a different cost: a launch asks this to decide one sentence of +/// the manners it hands the session, and paying the length of the topic for +/// that sentence would put the cost #115 kept off every launch back on it. A +/// count would need the read; that is why no count is handed to the session +/// (#133). +/// +/// A missing file and an empty one are the same state here, as they are for the +/// first-post check the append makes. So is a file of nothing but torn lines: +/// it answers true, and the pull that follows says the topic is empty. Over- +/// answering costs one call nobody had to make; under-answering costs the +/// session the fact that it is missing something. +pub fn has_posts(dir: &Path, topic_id: &str) -> bool { + std::fs::metadata(topic_path(dir, topic_id)) + .map(|meta| meta.len() > 0) + .unwrap_or(false) +} + /// One topic's posts, oldest first. /// /// The tuple's second half is how many lines did not parse. A line that does @@ -412,6 +432,26 @@ mod tests { assert_eq!(topic.created_at, "2026-08-27T10:00:00+09:00"); } + #[test] + fn a_topic_with_posts_is_told_from_one_without_them() { + let scratch = Scratch::new(); + put_topic(scratch.path(), "spoken", "先に言われたこと", "2026-08-27T10:00:00+09:00"); + std::fs::write(topic_path(scratch.path(), "opened"), "").expect("write empty topic"); + + assert!( + has_posts(scratch.path(), "spoken"), + "a topic that was spoken in has posts" + ); + assert!( + !has_posts(scratch.path(), "opened"), + "a topic realised by a launch and never spoken in has none" + ); + assert!( + !has_posts(scratch.path(), "never-made"), + "a topic with no file at all has none" + ); + } + #[test] fn deleting_takes_the_file_and_the_entry_together() { let scratch = Scratch::new(); diff --git a/docs/0-requirements.md b/docs/0-requirements.md index a192f63..b7640c3 100644 --- a/docs/0-requirements.md +++ b/docs/0-requirements.md @@ -309,6 +309,16 @@ logs/{部屋名}/{トピック}.jsonl そのトピックの発言、追記の **返すものは「あなた宛ではない」と明示する。** 読み出しが返す発言は、その参加者へ配られたことが一度も無いものである。届いた発言として読めば答えるべきものになる。 +**文脈を持たないまま席に着いたことは、そのセッション自身へ伝える(#133)。** 道具が在っても、呼ぶべき場面を名指す文が作法に無ければ引かれない。欠けていたのは道具ではなく引き金である。再開先を外した起動は状態行で**人には**言うが(上記「外したことは状態行で述べる」)、席に着いた当人には何も言わない——文脈ゼロで座り、そうと知らないまま話し始める。 + +**伝える条件は「そのトピックに既に発言が在り、かつこの起動が再開の線に入らなかったこと」である(AI 判断1)。** 「再開先を外した席」ではない。外した席も、発言のあるトピックへ初めて入った席も、等しく来る前の発言を持たない——盲目の度合いが同じである。前者だけを拾う形は #131 の経路に貼った絆創膏になり、同じ状態の残り半分を落とす。判定は解決後の行に対して行う(`resumed_from` が無いこと)。`dropped_resume` はその一例に過ぎない。 + +**伝えるのは「在る」ことだけで、件数は載せない(AI 判断2)。** 引くかどうかの判断に件数は要らない。件数を出すにはトピックの全読みを起動のたびに払うことになり、対価が釣り合わない。見るのはファイルの大きさだけである(`crates/topic-index` の `has_posts`)。壊れた行しか無いファイルは「在る」と答え、続く読み出しが「何も言われていない」と返す——多く答えれば要らない呼び出しが一回、少なく答えれば欠けていること自体が伝わらない。 + +**アプリが `read_room_history` を代理で呼ばない(AI 判断3)。** 引く判断はセッションのものである。アプリが結果を先回りして注入すれば、上の力業と同じ場所に着く。作法は「在る」と述べるだけであり、「読め」とは言わない。渡すのは「在る」という事実であって、発言そのものではない——上記「部屋は過去の発言を channel へ push しない」の側に立つ。 + +**伝える口は起動ごとの env である。** `PULLCEPT_UNSEEN_HISTORY` を `.mcp.json` の登録へ書き(`crates/mcp-config/`)、サイドカーが `instructions` の「前を見る」の節をそれで差し替える。二つの形は並置ではなく置換である。登録は起動のたびに丸ごと書き直されるため、状態が成り立たない起動へ前回の鍵が残ることはない。 + **トピックは一覧から消せる(#119)。** 消すとは、そのトピックのファイルと索引の項目の両方を捨てることである(決定1)。索引の項目だけを消す形は取らない——上記「部屋のログ」の照合が次の読み出しで建て直すためであり、黙って復活する削除になる。 **消えたものは戻らない。** ログは git に載っておらず、他所に写しも無い。`rules/model/subtractive-structural-beauty.md` の削除判断でいう「ローカルの非 git な意味のある状態」であり、復旧コストは高い側に立つ。したがって**確認の一段は任意ではなく必須**である(決定2)。形は明示のダイアログであり、二度押しや猶予ではない。 @@ -364,6 +374,7 @@ liplus-desktop の `stream_parser.rs` および `spawn_stream_pty` / `spawn_stre - トピックの削除(行の `❌`、確認のダイアログ、ファイルと索引の項目の両方の削除、そのトピックで走っているセッションの終了、開いているトピックを消したときの新しいトピックへの移動、題の無い行も消せること) - トピックの再開(アカウントの再開コマンド、`{session_id}` の置換、トピックへのセッション id の記録、再開の前に会話が実在するかを見ること、実在しない記録のその場での取り消し、再開できない席の新規着席、名簿に現れないまま終えた再開の記録の取り消し、いずれも状態行での通知) - 参加者からの読み出し(サイドカーの `read_room_history`、`history` / `history_result` フレーム、pull のみ、ページと `before` の巻き戻し) +- 文脈を持たないまま着いた席への通知(起動時の `PULLCEPT_UNSEEN_HISTORY`、サイドカーの「前を見る」の分岐、再開しなかった席という境界、件数を持たないこと、代理で呼ばないこと) - トピックの一覧(会話面の左の列、新しい順、先頭に固定の「新規」と索引に無い現在のトピックでのその選択状態、選択による部屋への読み戻し、参加者パネルと対の寸法、日付を含む時刻、名前から導く色) - 参加者モデル(統一 `post` フレーム、発言者以外の全参加者への配送、接続同一性による自分の発言の抑止と名簿の同一性、人間を含む名簿) - アカウント(作成・編集・削除、種別、名前と色と作業ディレクトリと起動オプション、リストでのオフライン表示、一つのアカウントは一つの部屋に一席まで、`config.json` からの移行) @@ -498,7 +509,7 @@ liplus-desktop の `stream_parser.rs` および `spawn_stream_pty` / `spawn_stre - トピックの再開の実機確認。`--session-id` で配った id が実際に `claude --resume` で戻るところは未計測である。CLI の選択肢の存在は 2026-08-27 に確認しているが(#115 の前提)、往復そのものは辿っていない。同じ id を二度 `--session-id` で渡した場合の CLI の挙動も未計測であり、この実装はその経路へ入らない——記録が在るときは、新規起動側でも新しい id を配り直す。 - 戻れなかった再開先を外すところの実機確認(#127、上記「トピック」)。再開の線で起動して名簿に現れないまま終えた回で記録が外れること、状態行がそう述べること、次に押すと通常起動で立つことは実装済みで、CI の型検査・Rust のコンパイル・`crates/topic-index` のテストは通っているが、実機での操作は未確認である。この経路を実機で作るには、確認プロンプトで止まった起動を id が記録された状態で終える必要がある——#123 の実機確認がそれを起こした回であり、狙って再現する手順としては未確認である。 - 会話が実在するかを起動の前に見るところの実機確認(#131、上記「トピック」)。スラッグの規則は 2026-08-29 に実機の `~/.claude/projects/` から確定し、`crates/mcp-config/` のテストが持っているが、記録の在るトピックを開き直して実際に再開が選ばれること、記録だけが残った組で通常起動へ落ちて状態行がそう述べることは、実機では未確認である。前者は上記「トピックの再開の実機確認」と同じ往復であり、そちらが未計測である以上こちらも未計測である。 -- `read_room_history` を実際のセッションが呼ぶところの実機確認。サイドカーのラウンドトリップテストは通っているが、エージェントが必要な場面で自分から引くかどうかは未計測である。押し付けないという判断がそのまま「引かれない」に落ちる可能性は残っており、そこは `instructions` の書き方の問題として観測してから判断する。 +- `read_room_history` を実際のセッションが呼ぶところの実機確認。サイドカーのラウンドトリップテストは通っているが、エージェントが必要な場面で自分から引くかどうかは未計測である。押し付けないという判断がそのまま「引かれない」に落ちる可能性は残っており、そこは `instructions` の書き方の問題として観測してから判断する。#133 の引き金はその観測に対する最初の手当てであり(上記「文脈を持たないまま席に着いたことは…」)、CI は通っているが、着いた席がそれを読んで実際に引くかどうかは同じく未計測である。 - トピックを切り替えたときに、席を持っている参加者がそれをどう受け取るかの実機確認。床は空になり席は入口へ戻るが(上記「トピック」)、走っているセッション自身は自分の文脈をそのまま持ったままである。 - 複数の部屋。ログのパスは部屋名を位置として持つが(`logs/{部屋名}/`、現在は `main` に固定)、部屋そのものは一つである。 - トピックからの引用操作。読み戻した行は部屋の本文に在るが、行を選んで入力欄へ持っていく経路は持たない。 diff --git a/sidecar/src/index.ts b/sidecar/src/index.ts index 0fc6f26..709a779 100644 --- a/sidecar/src/index.ts +++ b/sidecar/src/index.ts @@ -75,6 +75,24 @@ function readHue(raw: string | undefined): number | null { */ const ACCOUNT_ID = process.env.PULLCEPT_ACCOUNT_ID?.trim() || null; +/** + * Whether this session was seated in a topic that already holds posts it does + * not have. + * + * The trigger the pull was missing. `read_room_history` has been reachable + * since #115, and the manners named it without ever naming a moment to call it + * — a session that joined a topic mid-conversation was told, in general terms, + * that a tool exists, and had nothing to notice its own blindness by. What the + * launch knows and the session does not is exactly that: the topic had been + * spoken in before this seat was taken (#133). + * + * The fact only. No count and no posts: the room does not push its past + * (#31 / #39), and whether to look is the session's decision, which a number + * does not inform. Presence of the key is the whole value — the launch rewrites + * this registration whole every time, so a stale key cannot arrive. + */ +const UNSEEN_HISTORY = process.env.PULLCEPT_UNSEEN_HISTORY === "1"; + const PROTOCOL_VERSION = 6; /** @@ -193,6 +211,42 @@ interface HistoryResultFrame { // ── MCP server ─────────────────────────────────────────────────────────────── +/** + * Looking back, said one of two ways. + * + * The tool is the same either way and so is the decision; what differs is + * whether this session is standing in front of something. The general form + * describes a possibility, which is what a session with nothing behind it is + * in. The seated-late form states a fact about this seat, because that is what + * the launch established — and a session cannot notice, from inside, that the + * conversation started before it arrived. + * + * Neither form tells the session to call. Saying "there is something" and + * saying "go and read it" are different acts, and the second is the push this + * whole path exists to avoid (#133, 決定3). + */ +const LOOKING_BACK = [ + "前を見る:", + ...(UNSEEN_HISTORY + ? [ + "- 今のトピックには、あなたが来る前の発言が既にあります。あなたは", + " それを持っていません。部屋は過去を配らないからです。", + "- 何が言われたかが要るときは read_room_history を呼んでください。", + " 今のトピックでそれまでに言われたことが、古い順で返ります。", + "- 引くかどうかはあなたが決めます。要らないと判断したなら", + " 呼ばないでください。", + ] + : [ + "- あなたが来る前の発言は届きません。部屋は過去を配らないからです。", + "- 必要になったら read_room_history を呼んでください。今のトピックで", + " それまでに言われたことが、古い順で返ります。", + "- 押し付けられないので、要らないときは呼ばないでください。話の流れが", + " 分からないまま答えそうなときにだけ引けば足ります。", + ]), + "- 返り切らなかったときは、いちばん古い発言の message_id を before に", + " 入れてもう一度呼ぶと、その手前が返ります。", +]; + const INSTRUCTIONS = [ "あなたは Pullcept の部屋に参加しています。", `この部屋でのあなたの名前は「${AGENT_NAME}」です。`, @@ -205,14 +259,7 @@ const INSTRUCTIONS = [ "発言するときは say_to_room ツールを呼んでください。ターミナルへの出力は", "部屋には届きません。", "", - "前を見る:", - "- あなたが来る前の発言は届きません。部屋は過去を配らないからです。", - "- 必要になったら read_room_history を呼んでください。今のトピックで", - " それまでに言われたことが、古い順で返ります。", - "- 押し付けられないので、要らないときは呼ばないでください。話の流れが", - " 分からないまま答えそうなときにだけ引けば足ります。", - "- 返り切らなかったときは、いちばん古い発言の message_id を before に", - " 入れてもう一度呼ぶと、その手前が返ります。", + ...LOOKING_BACK, "", "宛先:", "- 発言には宛先が付くことがあります。宛先は meta.to に入っています。", diff --git a/sidecar/test/round-trip.test.mjs b/sidecar/test/round-trip.test.mjs index 1e03626..a05b0e1 100644 --- a/sidecar/test/round-trip.test.mjs +++ b/sidecar/test/round-trip.test.mjs @@ -61,6 +61,24 @@ const LOOKING_BACK = [ " 入れてもう一度呼ぶと、その手前が返ります。", ].join("\n"); +// Looking back, as it is said to a seat taken in front of posts it does not +// have. The tool and the decision are the same as above; what changes is that +// the manners state a fact about this seat instead of describing a possibility +// — a session cannot notice from inside that the conversation started before it +// arrived, and the launch is the only party that knows (#133). +// +// The last bullet is not repeated here: it is the same sentence in both forms +// and is asserted once, by the general literal above. +const SEATED_LATE = [ + "前を見る:", + "- 今のトピックには、あなたが来る前の発言が既にあります。あなたは", + " それを持っていません。部屋は過去を配らないからです。", + "- 何が言われたかが要るときは read_room_history を呼んでください。", + " 今のトピックでそれまでに言われたことが、古い順で返ります。", + "- 引くかどうかはあなたが決めます。要らないと判断したなら", + " 呼ばないでください。", +].join("\n"); + const SEE_THE_FLOOR = [ "床を見てから送る:", "- say_to_room には last_seen を付けてください。値は、あなたが実際に見た", @@ -387,6 +405,14 @@ test("a room post reaches the channel, and say_to_room reaches the room", async LOOKING_BACK, "instructions must carry the looking-back manners in full, tail included", ); + // This launch declared no unseen history, so the manners must not assert any. + // Telling every session that the topic already holds posts would make the + // sentence worthless in the one case it exists for, and would be false in + // every other (#133). + assert.ok( + !instructions.includes("あなたが来る前の発言が既にあります"), + "a seat with nothing behind it must not be told the topic already holds posts", + ); notify("notifications/initialized", {}); @@ -758,3 +784,97 @@ test("a session launched without a hue or an account says so by omission", async "a connection with no account behind it must carry no account key", ); }); + +test("a session seated in a topic that already holds posts is told so", async (t) => { + // The trigger, which is the whole of what #133 adds. The pull has been + // reachable since #115 and the manners named it, but they named no moment to + // call it: a session that joined mid-conversation was handed a general + // description of a tool and nothing to notice its own blindness by. + // + // No room here. The manners ride on `initialize`, which the sidecar answers + // over stdio whether or not a room is attached — and what is under test is + // what the launch put in the env, not anything on the wire. + const child = spawn( + process.execPath, + [join(REPO, "node_modules", "tsx", "dist", "cli.mjs"), ENTRY], + { + cwd: REPO, + env: { + ...process.env, + // Unset on purpose: no room to connect to, and the sidecar stays + // offline and serving rather than exiting. + PULLCEPT_ROOM_URL: "", + PULLCEPT_AGENT_NAME: "late-arrival", + PULLCEPT_ROOM_ID: "test-room", + PULLCEPT_UNSEEN_HISTORY: "1", + }, + stdio: ["pipe", "pipe", "pipe"], + }, + ); + child.stderr.resume(); + t.after(() => child.kill()); + + const pending = new Map(); + let buffer = ""; + child.stdout.on("data", (chunk) => { + buffer += chunk.toString(); + let nl; + while ((nl = buffer.indexOf("\n")) >= 0) { + const line = buffer.slice(0, nl).trim(); + buffer = buffer.slice(nl + 1); + if (!line) continue; + const msg = JSON.parse(line); + if (msg.id !== undefined && pending.has(msg.id)) { + pending.get(msg.id).resolve(msg); + pending.delete(msg.id); + } + } + }); + + const d = deferred(); + pending.set(1, d); + child.stdin.write( + `${JSON.stringify({ + jsonrpc: "2.0", + id: 1, + method: "initialize", + params: { + protocolVersion: "2024-11-05", + capabilities: {}, + clientInfo: { name: "seated-late-test", version: "0" }, + }, + })}\n`, + ); + const init = await withTimeout(d.promise, "response to initialize"); + const instructions = init.result.instructions ?? ""; + + // In full, for the reason every other manners literal here is: a head-only + // check passes on a paragraph whose tail was deleted, and the tail is where + // the decision is left with the session. + assertContains( + instructions, + SEATED_LATE, + "a seat taken in front of posts it does not have must be told so, tail included", + ); + // The general form is replaced, not stacked on top of. Both at once would + // say the topic holds posts and describe the possibility of it in the same + // breath. + assert.ok( + !instructions.includes("- あなたが来る前の発言は届きません。"), + "the seated-late form replaces the general one rather than joining it", + ); + // The paging sentence is shared and must survive the branch: a first page + // that does not return the whole topic is the normal case, and a session + // with no cursor cannot keep reading. + assertContains( + instructions, + "- 返り切らなかったときは、いちばん古い発言の message_id を before に", + "the way to keep reading backwards is said in both forms", + ); + // Said, not told to. Naming the fact is what the room may do; instructing the + // session to read is the push this path exists to avoid (#133, 決定3). + assert.ok( + !instructions.includes("まず read_room_history を呼んで"), + "the manners must state that there is something to pull, not order the pull", + ); +}); diff --git a/src-tauri/src/room_log.rs b/src-tauri/src/room_log.rs index 2b42092..542f6e9 100644 --- a/src-tauri/src/room_log.rs +++ b/src-tauri/src/room_log.rs @@ -362,6 +362,23 @@ pub fn topic_posts(app: &AppHandle, topic_id: &str) -> Result, S Ok(posts) } +/// Whether anything has been said in a topic, without reading what. +/// +/// What a launch asks to decide whether the session it is seating is being put +/// in front of posts it does not have (`session.rs`, #133). Not +/// [`topic_posts`]: the answer needed is whether, and reading the topic to get +/// it would charge every launch the length of the conversation. +/// +/// Unreadable is answered as no history, the way [`topic_posts`] answers a +/// missing file with no posts. Nothing is reported on the error surface: this +/// is a question asked to word one sentence of the manners, and a failure to +/// answer it costs the session a sentence it can still reach the pull without. +pub fn topic_has_posts(app: &AppHandle, topic_id: &str) -> bool { + room_dir(app) + .map(|dir| topic_index::has_posts(&dir, topic_id)) + .unwrap_or(false) +} + /// The session `account_id` was in while `topic_id` was open, if one is on /// record. pub fn session_of(app: &AppHandle, topic_id: &str, account_id: &str) -> Option { diff --git a/src-tauri/src/session.rs b/src-tauri/src/session.rs index 734858a..4cf0333 100644 --- a/src-tauri/src/session.rs +++ b/src-tauri/src/session.rs @@ -631,6 +631,23 @@ pub fn start_session( // then spawning the resume line would be checking the wrong line. let launch_line = resolve_launch(&app, &account, &topic, &cwd)?; + // Whether this seat is being taken in front of posts it does not have. + // + // Read off the line that resolved, which is why it is here and not beside + // the topic: the question is not whether a record was dropped but whether + // this launch went in on the resume line at all. A seat entering a topic + // that has been spoken in for the first time is as blind as one whose way + // back went, and `dropped_resume` is one case of the state rather than the + // state (#133). + // + // Whether, not how much. A count would mean reading the topic on every + // launch, and the session does not need one to decide whether to look — + // which is the decision, and stays the session's (#115, decision 4C). + // Nothing about the posts crosses here: the room pushes no past, and what + // is handed over is that there is some. + let unseen_history = launch_line.resumed_from.is_none() + && room_log::topic_has_posts(&app, &topic.topic_id); + if let Err(flag) = reject_incompatible_flags(&launch_line.args) { return Err(format!( "Account \"{name}\" passes {flag}, which stops channel pushes from arriving. \ @@ -704,6 +721,7 @@ pub fn start_session( &room_url, &cwd, &topic.topic_id, + unseen_history, cols, rows, ) { @@ -786,6 +804,9 @@ fn launch( // The topic this launch is going into, carried through so the answer names // the topic a failed resume would have to be undone on (#127). topic_id: &str, + // Whether the topic already holds posts this session was not seated with, + // decided by the caller against the line that resolved (#133). + unseen_history: bool, cols: u16, rows: u16, ) -> Result { @@ -798,6 +819,7 @@ fn launch( account_id: &account.id, agent_name: name, agent_hue: account.hue, + unseen_history, sidecar_entry: &sidecar_entry, sidecar_runner: &sidecar_runner, },