Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 69 additions & 0 deletions crates/mcp-config/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<f64>,
/// 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.
Expand Down Expand Up @@ -543,6 +557,12 @@ pub fn register_sidecar(dir: &Path, room: &RoomRegistration<'_>) -> Result<PathB
if let Some(hue) = room.agent_hue {
env.insert("PULLCEPT_AGENT_HUE".into(), json!(format!("{hue:.1}")));
}
// Only when true, and absence is the other half rather than a launch that
// lost it: every launch rewrites this entry whole, so a key left over from
// a run where it did hold cannot survive into one where it does not.
if room.unseen_history {
env.insert("PULLCEPT_UNSEEN_HISTORY".into(), json!("1"));
}

servers.insert(
server_name,
Expand Down Expand Up @@ -598,6 +618,7 @@ mod tests {
account_id: LIN,
agent_name: "Lin",
agent_hue: None,
unseen_history: false,
sidecar_entry: entry,
sidecar_runner: runner,
}
Expand Down Expand Up @@ -650,6 +671,51 @@ mod tests {
);
}

#[test]
fn the_unseen_history_flag_is_written_only_for_a_seat_that_has_one() {
// Both directions, because the sidecar reads presence: a key that stayed
// behind from a launch where it did hold would tell the next session it
// is missing something in a topic nobody has spoken in (#133).
let scratch = Scratch::new();
let entry = PathBuf::from(ENTRY);
let runner = PathBuf::from(RUNNER);

let path =
register_sidecar(scratch.path(), &registration(&entry, &runner)).expect("no history");
let json = read(&path);
assert!(
!json["mcpServers"][server_name_for(LIN)]["env"]
.as_object()
.expect("env")
.contains_key("PULLCEPT_UNSEEN_HISTORY"),
"a seat with nothing behind it must leave no key"
);

let seated_late = RoomRegistration {
unseen_history: true,
..registration(&entry, &runner)
};
let path = register_sidecar(scratch.path(), &seated_late).expect("history");
let json = read(&path);
assert_eq!(
json["mcpServers"][server_name_for(LIN)]["env"]["PULLCEPT_UNSEEN_HISTORY"],
"1"
);

// And back again, on the same file: the entry is rewritten whole, so
// the key goes when the state does.
let path =
register_sidecar(scratch.path(), &registration(&entry, &runner)).expect("no history");
let json = read(&path);
assert!(
!json["mcpServers"][server_name_for(LIN)]["env"]
.as_object()
.expect("env")
.contains_key("PULLCEPT_UNSEEN_HISTORY"),
"the key must not survive into a launch the state does not hold for"
);
}

#[test]
fn leaves_everything_else_in_the_config_alone() {
// This writes into the user's own project directory. Clobbering a
Expand Down Expand Up @@ -685,6 +751,7 @@ mod tests {
account_id: LIN,
agent_name: "Lin",
agent_hue: Some(145.0),
unseen_history: false,
sidecar_entry: &entry,
sidecar_runner: &runner,
};
Expand Down Expand Up @@ -719,6 +786,7 @@ mod tests {
account_id: LIN,
agent_name: "リン",
agent_hue: None,
unseen_history: false,
sidecar_entry: &entry,
sidecar_runner: &runner,
};
Expand Down Expand Up @@ -751,6 +819,7 @@ mod tests {
account_id: LAY,
agent_name: "Lay",
agent_hue: Some(25.0),
unseen_history: false,
sidecar_entry: &entry,
sidecar_runner: &runner,
};
Expand Down
40 changes: 40 additions & 0 deletions crates/topic-index/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,26 @@ pub fn title_from(content: &str) -> 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
Expand Down Expand Up @@ -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();
Expand Down
13 changes: 12 additions & 1 deletion docs/0-requirements.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)。形は明示のダイアログであり、二度押しや猶予ではない。
Expand Down Expand Up @@ -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` からの移行)
Expand Down Expand Up @@ -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` に固定)、部屋そのものは一つである。
- トピックからの引用操作。読み戻した行は部屋の本文に在るが、行を選んで入力欄へ持っていく経路は持たない。
Expand Down
Loading