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
30 changes: 25 additions & 5 deletions docs/0-requirements.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,13 +32,23 @@
- **複数の channel サーバを同時に有効化できる。** 部屋のサイドカーと `github-webhook-mcp` の双方が同一セッションで機能した。#90 の premise は `--channels` 併記の失敗のみを確定させており、この点は扱っていなかった。
- 上の「単独指定」は `--channels` を併記しないという意味であって、`server:` エントリを一つに限る意味ではない。

### 枠の消費構造(2026-08-21)

利用枠を削るのは常駐そのものではなく反応である。PTY に座っているだけのセッションはモデルを呼ばない。

```
消費 ≒ 部屋の発言数 × その発言に反応したエージェント数
```

常駐数が効くのは「反応しうる者が増える」経路を通じてのみ。したがって抑えるべき第一の対象は常駐数ではなく反応の規律であり、その規律を成立させる最小の材料が宛先である。

現況は下の「実装状況」節を参照。

## constraints

- **アプリは部屋であってランタイムではない。** Li+ の各層を UI 側のコードとして実装しない。所有した瞬間に MCP 管理・OAuth・設定 UI をすべて自前で抱えることになり、ニッチ化と保守費はその代償として必ず発生する。
- **単一ベンダー構成とする。** 二ベンダーによる独立性確保とサブスク枠の分散は取り下げる。構造を単純化し、他ベンダー側 channel 相当の有無という未検証前提を消すための決定。
- **代償**: 常駐する全エージェントが同一の利用枠(5 時間枠)を共有する。同時発話数と常駐エージェント数を設計側で抑えること
- **代償**: 常駐する全エージェントが同一の利用枠(5 時間枠)を共有する。抑える対象は常駐数ではなく反応の数である(premise の「枠の消費構造」)。第一の機構は発言の宛先であり、宛先を配送の絞り込みには使わない
- **AI 同士の暴走や対立を意図的に誘発する機構は設けない。** 現在の Li+ を自然に動かした結果として予期しない相互作用が生じた場合は、発言者と会話の流れを追える形で観測できることを目指す。
- **エージェント間の対話を GitHub へ流さない。** `rules/task/task.md` が両者を別物として定義している(`Issue body = judgment record` / `Dialogue message = history`)。加えて書き込み量には実測済みの制約がある。
- **プレビュー結合を受容する。** channel は experimental capability であり、仕様変更がアプリに波及する。早期採用の代償として明示的に受容する。
Expand Down Expand Up @@ -89,7 +99,9 @@ push の形(参照実装 `Liplus-Project/github-webhook-mcp` `local-mcp/src/in

- method = `notifications/claude/channel`
- `params.content` = 発言本文
- `params.meta` = `chat_id` / `message_id` / `user` / `ts` ほか、発言者と会話を追跡するための属性
- `params.meta` = `chat_id` / `message_id` / `user` / `to`(任意) / `ts` ほか、発言者と会話を追跡するための属性

宛先は `content` ではなく `meta` に載せる。`content` は言われたものと等しく保つ。発言者を本文へ混ぜたときは画面に名前が二度出た(#28)。

返信路は通常の MCP tool であり、channel 固有の機構を必要としない。返信ツールは `say_to_room` 1 本に限定する。増やすと「どちらで喋るか」という判断をエージェント側に持たせることになる。

Expand All @@ -103,12 +115,14 @@ push の形(参照実装 `Liplus-Project/github-webhook-mcp` `local-mcp/src/in

| 向き | type | フィールド |
|---|---|---|
| 部屋 → サイドカー | `say` | `message_id` / `user` / `content` / `ts` |
| 部屋 → サイドカー | `say` | `message_id` / `user` / `content` / `to`(任意) / `ts` |
| サイドカー → 部屋 | `hello` | `protocol` / `agent` |
| サイドカー → 部屋 | `reply` | `message_id` / `agent` / `content` / `to`(任意) / `ts` |

未知の `type` は拒否せず無視する。部屋側がフレーム種別を増やしても、旧サイドカーが壊れないため。`protocol` はフレームの形が変わり、サイドカー側が気づく必要があるときに上げる。

`to` は両方向で同じ語彙であり、宛先となる参加者の表示名を入れる。任意であり、無いときは `null` ではなくキーごと省く。`to` の追加で `protocol` は上げていない。サイドカーはアプリが `.mcp.json` へ書く同梱パスから起動するため、部屋とサイドカーの版が食い違う形が構造上無い。

人間の発言も、エージェントの返信も、フロントエンドへは同一の `room-message` イベントとして届く。並び順の権威を 1 箇所に保つためであり、送信時にフロント側でローカルに追記しない。

### サイドカーの起動形
Expand All @@ -124,7 +138,12 @@ push の形(参照実装 `Liplus-Project/github-webhook-mcp` `local-mcp/src/in

部屋のルールは MCP サーバが initialize 時に返す `instructions` 文字列により全エージェントへ一元的に配布する。実測では、当該ターンで返信を指示していないにもかかわらずエージェントが返信 tool を呼んだ。駆動源はこの `instructions` である。

同時発話数の抑制、発言の宛先、沈黙してよい条件などはここで与える。
判定材料(`meta.to`)と判定規律(`instructions`)は同時に変える。片方だけ動かすと、宛先を知らないまま宛先を判断させる状態になる。

- サイドカーは自分の部屋での名前(`LIPLUS_AGENT_NAME`)を `instructions` へ埋め込む。埋めなければエージェントは `meta.to` を自分と照合できない。
- `meta.to` が自分の名前なら自分宛、他の参加者の名前なら自分宛ではない、無ければ部屋全体宛。全体宛に誰が答えるかはエージェントの判断に委ねる。
- 宛先による配送の絞り込みはアプリ側で行わない。部屋は全員へ配り、答えるかどうかをエージェントが決める。絞れば部屋が「誰に届いたか」を持つことになり、アプリが会話の中身へ関与し始める。
- 宛先は任意である。必須にすると人間の入力費用が上がる。

### MCP サーバの実装方式

Expand Down Expand Up @@ -152,6 +171,7 @@ liplus-desktop の `stream_parser.rs` および `spawn_stream_pty` / `spawn_stre
- 部屋ソケット(Rust、`127.0.0.1` 任意ポート、Bearer トークン必須)
- サーバの `.mcp.json` 登録(既存内容はマージして保持)と、成立条件を満たす CLI 起動フラグの適用
- 部屋の作法(`instructions`)の初版
- 発言の宛先(部屋 → `say.to` → channel の `meta.to`、返信は `say_to_room` の `to`)と、それを判定材料として名指しする `instructions`
- チャットルーム UI(メッセージ一覧、発言者表示、入力欄、参加者表示)
- 診断面(部屋ソケットの待受状態、セッションの生死と終了コード、CLI の端末表示と入力)
- Windows CI(`npm ci` と Rust コンパイル確認)、Release 公開時の CD
Expand Down Expand Up @@ -255,7 +275,7 @@ CI が実行するもの:

- 会話面はメッセージ単位を保つ。端末は診断面側に置き、会話面には置かない。
- 参加者パネルを持つ。セッションの接続方法・起動コマンド・作業ディレクトリ・開始時刻と、操作(端末 / ミュート / 終了)を出す。裏の値はすべて既にあり、出すだけで済む。
- 発言に宛先を持たせる。
- 発言に宛先を持たせる(実装済み。入力欄の左で参加者から選ぶ。既定は全体宛)

### 決まっていないこと

Expand Down
10 changes: 10 additions & 0 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,16 @@
</aside>

<footer id="composer">
<!-- The addressee is optional. Leaving it on 全体 is a room-wide
utterance; who answers one of those is the agents' judgment, not
the room's. The room delivers every utterance to everyone either
way. -->
<label class="to">
宛先
<select id="to-select">
<option value="">全体</option>
</select>
</label>
<textarea
id="input"
rows="2"
Expand Down
25 changes: 22 additions & 3 deletions sidecar/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,11 +44,16 @@ function log(line: string): void {
// ── Room frames ──────────────────────────────────────────────────────────────
//
// Room -> sidecar:
// { type: "say", message_id, user, content, ts }
// { type: "say", message_id, user, content, to?, ts }
// Sidecar -> room:
// { type: "hello", protocol, agent }
// { type: "reply", message_id, agent, content, to?, ts }
//
// `to` is optional in both directions and means the same thing on each: the
// display name of the participant addressed. The room fans every frame out to
// everyone regardless — whether an utterance is yours to answer is decided
// here, by the agent, not by the room narrowing its delivery.
//
// Frames whose `type` is unknown are ignored rather than rejected, so the room
// can add frame kinds without breaking a sidecar built against this revision.

Expand All @@ -57,21 +62,30 @@ interface SayFrame {
message_id?: string;
user?: string;
content?: string;
to?: string;
ts?: string;
}

// ── MCP server ───────────────────────────────────────────────────────────────

const INSTRUCTIONS = [
"あなたは liplus-chat の部屋に参加しています。",
`この部屋でのあなたの名前は「${AGENT_NAME}」です。`,
"",
'部屋の発言は <channel source="liplus-chat" ...> として届きます。',
"返信するときは say_to_room ツールを呼んでください。ターミナルへの出力は",
"部屋には届きません。",
"",
"宛先:",
"- 発言には宛先が付くことがあります。宛先は meta.to に入っています。",
`- meta.to が「${AGENT_NAME}」なら、あなた宛です。答えてください。`,
"- meta.to が他の参加者の名前なら、あなた宛ではありません。黙ってください。",
" 補足したくなっても割り込まないでください。",
"- meta.to が無い発言は部屋全体宛です。自分が答えるべきときだけ答えてください。",
"- say_to_room の to 引数で、こちらからも宛先を指定できます。",
"",
"部屋の作法:",
"- 自分に向けられた発言、または自分が答えるべき発言にだけ返信してください。",
"- 他の参加者への発言に割り込まないでください。返信しない判断は正当です。",
"- 返信しない判断は正当です。全員が答えると部屋は読めなくなります。",
"- 一度の発言は簡潔に。長い説明が必要なときは、まず要点だけ返してください。",
"- 他の参加者の発言を、自分の文脈として取り込まないでください。それぞれが",
" 自分の文脈から同じ会話に参加しています。",
Expand Down Expand Up @@ -193,6 +207,10 @@ function pushToChannel(frame: SayFrame): void {
if (!content) return;

const user = frame.user ?? "someone";
// The addressee rides in meta for the same reason the speaker does: the body
// must stay equal to what was said. It is judgment material, not text — the
// instructions tell the agent to read it and decide whether to answer.
const to = typeof frame.to === "string" && frame.to ? frame.to : undefined;
void mcp.notification({
method: "notifications/claude/channel",
params: {
Expand All @@ -204,6 +222,7 @@ function pushToChannel(frame: SayFrame): void {
chat_id: CHAT_ID,
message_id: frame.message_id ?? randomUUID(),
user,
...(to ? { to } : {}),
ts: frame.ts ?? new Date().toISOString(),
},
},
Expand Down
69 changes: 63 additions & 6 deletions sidecar/test/round-trip.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -121,18 +121,20 @@ test("room say reaches the channel, and say_to_room reaches the room", async (t)
function notify(method, params) {
child.stdin.write(`${JSON.stringify({ jsonrpc: "2.0", method, params })}\n`);
}
function nextNotification(method) {
const found = notifications.find((n) => n.method === method);
if (found) return Promise.resolve(found);
/** The `index`-th notification of `method`, awaited if it has not arrived. */
function nextNotification(method, index = 0) {
const matching = () => notifications.filter((n) => n.method === method);
if (matching().length > index) return Promise.resolve(matching()[index]);
return withTimeout(
new Promise((resolve) => {
const waiter = (msg) => {
if (msg.method === method) resolve(msg);
const waiter = () => {
const seen = matching();
if (seen.length > index) resolve(seen[index]);
else notificationWaiters.push(waiter);
};
notificationWaiters.push(waiter);
}),
method,
`${method} #${index}`,
);
}

Expand All @@ -152,6 +154,20 @@ test("room say reaches the channel, and say_to_room reaches the room", async (t)
/say_to_room/,
"instructions must name the reply tool",
);
// The manners and the material they are judged on ship together. Manners
// that say "answer what is addressed to you" without naming where the
// addressee is, or without naming what this agent is called, ask for a
// judgment the agent has nothing to make.
assert.match(
init.result.instructions ?? "",
/meta\.to/,
"instructions must name the addressee as judgment material",
);
assert.match(
init.result.instructions ?? "",
/test-agent/,
"instructions must tell the agent the name it answers to",
);

notify("notifications/initialized", {});

Expand Down Expand Up @@ -185,6 +201,47 @@ test("room say reaches the channel, and say_to_room reaches the room", async (t)
assert.equal(pushed.params.meta.chat_id, "test-room");
assert.equal(pushed.params.meta.message_id, "m-1");
assert.equal(pushed.params.meta.user, "Master");
// An unaddressed utterance is the room as a whole. No key, rather than an
// empty one: an agent testing `meta.to` must not read "" as a name.
assert.equal(
"to" in pushed.params.meta,
false,
"an utterance with no addressee must carry no `to`",
);

// ── the addressee rides through to the agent ───────────────────────────────
roomSocket.send(
JSON.stringify({
type: "say",
message_id: "m-2",
user: "Master",
content: "リンだけ答えて",
to: "test-agent",
ts: "2026-08-21T00:00:01.000Z",
}),
);

const addressed = await nextNotification("notifications/claude/channel", 1);
// In meta, next to the speaker, for the same reason: the body stays equal to
// what was said.
assert.equal(addressed.params.content, "リンだけ答えて");
assert.equal(addressed.params.meta.to, "test-agent");

// Addressed elsewhere still arrives — the room delivers to everyone and the
// agent decides. Filtering here would put "who heard it" in the room.
roomSocket.send(
JSON.stringify({
type: "say",
message_id: "m-3",
user: "Master",
content: "レイはどう思う",
to: "other-agent",
ts: "2026-08-21T00:00:02.000Z",
}),
);

const elsewhere = await nextNotification("notifications/claude/channel", 2);
assert.equal(elsewhere.params.meta.to, "other-agent");

// ── agent -> room ──────────────────────────────────────────────────────────
const call = await request("tools/call", {
Expand Down
23 changes: 20 additions & 3 deletions src-tauri/src/room.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,15 @@
//!
//! Frames on the wire are the room protocol:
//!
//! room -> sidecar : { type: "say", message_id, user, content, ts }
//! room -> sidecar : { type: "say", message_id, user, content, to?, ts }
//! sidecar -> room : { type: "hello", protocol, agent }
//! { type: "reply", message_id, agent, content, to?, ts }
//!
//! `to` is optional in both directions and carries the same vocabulary: the
//! display name of the participant addressed. The room still fans every frame
//! out to every sidecar — narrowing delivery here would make the room hold who
//! heard what, and answering is the agent's judgment, not the room's.
//!
//! Everything the frontend needs arrives as a `room-message` event. The room
//! never reads a CLI's terminal output; that is not a message source.

Expand Down Expand Up @@ -289,22 +294,34 @@ pub fn room_say(
state: tauri::State<RoomState>,
user: String,
content: String,
to: Option<String>,
) -> Result<String, String> {
let content = content.trim().to_string();
if content.is_empty() {
return Err("content is empty".to_string());
}

// Absent is the key omitted, never an empty one: an agent matching `to`
// against its own name must not have to rule "" out first. Normalising
// here keeps that shape a property of the room rather than of its callers.
let to = to
.map(|name| name.trim().to_string())
.filter(|name| !name.is_empty());

let message_id = Uuid::new_v4().to_string();
let ts = now_iso();

let frame = serde_json::json!({
let mut frame = serde_json::json!({
"type": "say",
"message_id": message_id,
"user": user,
"content": content,
"ts": ts,
});
// Omitted rather than null when absent, matching the `reply` direction.
if let Some(name) = &to {
frame["to"] = serde_json::Value::String(name.clone());
}

// No subscribers means no session has joined yet. That is not an error —
// the room accepts what is said in it; a later joiner simply missed it.
Expand All @@ -317,7 +334,7 @@ pub fn room_say(
kind: "human".to_string(),
speaker: user,
content,
to: None,
to,
ts,
},
);
Expand Down
Loading
Loading