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
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ Pullcept は、人間と複数の独立した AI / Li+ セッションが、一
- 部屋ソケット(`127.0.0.1` の任意ポート、Bearer トークン必須)
- `.mcp.json` への登録と、channel が成立する条件を満たした CLI 起動
- メッセージ一覧・発言者表示・入力欄を備えたチャットルーム UI
- 部屋のログ(`logs/main.jsonl` への追記)と、それを会話面の左の列に並べる履歴
- Windows 上で `npm ci` と Rust のコンパイル確認を行う CI
- GitHub Release 公開時に Tauri バンドルを作成する CD

Expand All @@ -43,7 +44,8 @@ Pullcept は、人間と複数の独立した AI / Li+ セッションが、一
### 未実装

- 複数の AI セッションを同一の部屋へ参加させる運用(同時発話の抑制を含む)。経路は通っていますが、実機での往復は未確認です
- 会話ログの永続化と観測 UI
- 部屋のログと履歴の実機確認。追記と読み出しは実装済みで CI は通っていますが、実機での操作は未確認です
- 複数の部屋。ログのパスは部屋名を位置として持ちますが、部屋そのものは一つです
- plugin としての allowlist 掲載(配布の第二段階)

## 部屋を動かす
Expand Down
44 changes: 43 additions & 1 deletion docs/0-requirements.md
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,20 @@ push の形(参照実装 `Liplus-Project/github-webhook-mcp` `local-mcp/src/in

他システムの調査(2026-08-23): 主要フレームワーク(AutoGen / AG2 GroupChat、Microsoft Agent Framework Group Chat)は orchestrator が次の話者を選ぶ turn-based であり、同時発話が構造上存在しない。並行が要る場合は共有ターンを持たない actor-model へ案内される。**この問題を解いたのではなく、起こらない形にしている。** 最も近い前例は Bounded Autonomy(ライブマルチプレイヤーの LLM キャラクター、2026-04)で、Talk state の原子的ロックにより発話中の割り当てを拒否する。調査は 2 回の検索に基づき、「他に例が無い」ことは主張しない。

### 部屋のログ

部屋で言われたことを追記のみの jsonl として残す。部屋の発言はウィンドウの DOM にしか無く、閉じれば消えていた——引用できないことは症状のひとつであり、実際に何が起きたかを根拠にする設計判断が参加者の記憶だけを頼りにしていたことが、これを持たせた理由である(#48)。

- **ログは部屋のものであってタブのものではない。** `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()` が床を判定した一回の取得の中で追記する。ロックを離してから書く形では、床が既に順序を付けた二人が逆順でディスクへ届きうるため、一つの会話に二つの順序が生まれる。並びの権威は部屋であるという既存の形(上記「床」)を、ファイルの側でも崩さない。
- **追記の失敗で発言は失われない。失敗は画面に出す。** 発言はディスクに触れる前に部屋へ入っており、書き込みの失敗が取り消せるものではない。失われうるのは記録の側だけである。したがって配送も戻り値も追記の成否に依存させず、代わりに `room-log-error` イベントで画面の状況行に出す——黙って記録を止めたログは依然としてログに見え、後から見に行った者はその空白を「何も言われなかった」と読む。
- **読み出しは画面のためのものであり、再配信ではない。** `room_log` コマンドは画面だけが呼ぶ。途中から参加したセッションへ過去の発言を流し込むことはしない(`room.rs` の現在の扱いを変えない)。流し込めば、部屋が「誰が何を聞いたか」を持ち始める——#31 と #39 が退けた形である。
- **ローテーションも上限も持たない。** `design/Vision.dc.html` が日付を持たない `main.jsonl` を単数形で置いていることに従う。古い方から捨てるログは、「実際に何が言われたか」という、これが在る理由そのものの問いに、問う価値が出るちょうどその距離で沈黙する。
- **ファイル名は `logs/{部屋名}.jsonl` であり、部屋名は `main` に固定する。** Vision の `main` を部屋名の位置と読む。複数部屋は実装しない——実装するときに増えるのはここの値であって、パスの階層ではない。
- **読めない行は飛ばす。読み全体は失敗させない。** 途中で終わった実行が残す千切れた末尾がこの経路に当たり、その最後の一行のために全履歴を拒むのは、何も守らずにすべてを失う。飛ばした件数は追記の失敗と同じイベントで画面へ返す。

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

参照実装は TypeScript / Node、本リポジトリは Rust + TypeScript であるため二択となる。
Expand Down Expand Up @@ -254,6 +268,8 @@ 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` を持ち込まない写像、追記の失敗の画面への通知、読み出しコマンド、参加者への再配信をしないこと)
- 履歴(会話面の左の列、起動時の一回の読み出し、部屋の本文へ復元しないこと、参加者パネルと対の寸法、日付を含む時刻、名前から導く色)
- 参加者モデル(統一 `post` フレーム、発言者以外の全参加者への配送、接続同一性による自分の発言の抑止と名簿の同一性、人間を含む名簿)
- アカウント(作成・編集・削除、種別、名前と色と作業ディレクトリと起動オプション、リストでのオフライン表示、一つのアカウントは一つの部屋に一席まで、`config.json` からの移行)
- アカウントのキャラクター(output style の `name:` を持つ欄、起動時の `--settings` による選択、ファイルを書き出さない形、起動オプションの `--settings` との併記の拒否、宣言しないアカウントは既定のまま)
Expand Down Expand Up @@ -364,7 +380,9 @@ liplus-desktop の `stream_parser.rs` および `spawn_stream_pty` / `spawn_stre
- クラッシュした場合の後始末(#85 の範囲外)。job object へ入れれば OS が落とすが、確認を挟む形とは別の軸であり、実装していない。正常終了の経路を閉じた後も、この経路からは孤児が生まれうる。
- 孤児の引き取り(#85 の範囲外、#84 関連)。起動時に前回の孤児を見つけて繋ぎ直す形。pty id が Rust のメモリにしか無いため持続化の話になる。
- CLI の出力から状態を読む形(「ツール使用中」の粒度)。#82 は段階 1 として出力を読まない二つの合図だけを採り、この形は実測してから別 issue で判断すると決めた。
- 会話ログの永続化と観測 UI
- 部屋のログと履歴の実機確認。追記されること、再起動後に左の列へ並ぶこと、部屋の本文が空から始まること、二つの面の寸法が揃うことはいずれも実装済みで CI の型検査と Rust のコンパイルは通っているが、実機での操作は未確認である。追記の失敗の経路(`room-log-error`)はそもそも失敗を作り出す必要があるため、実機でも未観測である。
- 複数の部屋。ログのパスは部屋名を位置として持つが(`logs/{部屋名}.jsonl`、現在は `main` に固定)、部屋そのものは一つである。
- 履歴からの引用操作。左の列は読む面であり、行を選んで入力欄へ持っていく経路は持たない。
- plugin としての allowlist 掲載(配布の第二段階)

### 二つの地色
Expand All @@ -379,6 +397,28 @@ liplus-desktop の `stream_parser.rs` および `spawn_stream_pty` / `spawn_stre

この規則の下では、選択中の端末タブは地色で選択を言えなくなる。代わりに言うのは枠(未選択の `transparent` から `--line` へ)と文字色(`--muted` から参加者自身の色へ)である。参加者パネルの選択中の行と同じ形——枠のある窓の地の箱——になり、同じ選択を二つの面が同じ手で描く(#75)。それまでは逆を向いていた。パネルの地が `--surface` なので選択された行は `--bg` で浮き、端末ヘッダの地が `--bg` なので選択されたタブは `--surface` で沈んでいた。

### 履歴

会話面の**左**に常時出る列を持つ。部屋のログを読み出して、このウィンドウが開く前に言われたことを並べる(上記「部屋のログ」)。

**起動時の部屋は新規である。** 過去ログを `#room` の本文へ復元しない。閉じれば部屋の中身は消えるという現在の振る舞いを変えない——変えたのは、消えた先に読みに行ける場所があるかどうかだけである。

**二つの面を分けるのは起動である。** 左に在るのはこのウィンドウが開く前、右の部屋に在るのはこのウィンドウの中。だから履歴は発言が届くたびに伸びない——伸びる帯は隣の会話面をもう一度描いたものであり、二つを見分けている境界そのものが無くなる。読み出しは起動時の一回である。

**部屋の本文と履歴は混ぜない。** 別のサーフェスであることがこの機能の形であり、同じものを二か所に出すことではない。

**参加者パネルと対にする。** 幅(16.5rem)と地色を揃え、枠は会話面に向く側に置く(左は `border-right`、右は `border-left`)。部屋を挟む二つの面が別々の寸法を持てば、揃っていないこと自体が読まれる。中の部品は同じ語彙(`.panel-head` / `.panel-title` / `.empty`)を使う。

**一件の見た目は部屋の行と同じ形である。** 話者の色の罫、その上に発言者・宛先・時刻。ただし寸法はパネルのものであり、会話面の文字サイズ(`--room-font-size`)は載せない——あの寸法は会話面の本文を描く二つの要素にだけ宣言されるものであり(#81)、ここはパネルである。

**古い方から並べ、末尾を開いた状態で出す。** 部屋の並びと同じ向きであり、二つは一続きに読める——この列の最後の行は、隣の部屋の最初の行の直前に言われたものである。だから下端が会話の連続する側であり、上端は何か月も前になる。上限を持たないログで一番古い行を毎回正面に出すのは、その距離が伸び続ける側を見せることになる。

**時刻には日付も出す。** 部屋の行は今この実行の中で言われたものなので時計だけで読めるが、履歴に在るものはすべてこのウィンドウより前であり、日付が置く側である。

**色は名前から導く。** ログは宣言色を持たないため(上記「部屋のログ」)、`hue` の梯子の最後の段——名前からの導出——を使う。同じ名前を名乗った二人はここでは一色になる。これは、誰も宣言していない色を保存しないことの既知の代償であり、履歴は過去の面であって部屋の帰属の面ではない。

**空とエラーを分ける。** 記録が無いのは初回の実行であって失敗ではないため「記録なし」と出す。読み出しが失敗したときだけ理由を添えて出す。

### 参加者パネル

会話面の右に常時出る列を持つ。参加者のリストと、その下にセッションの裏の値(部屋ソケット / セッションの生死と終了コード / 接続方法 / 起動コマンド / 作業ディレクトリ / 開始時刻 / ウィンドウ)を並べる。
Expand Down Expand Up @@ -789,6 +829,8 @@ 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/` が持っている。

CI が実行するもの:

| コマンド | 対象 |
Expand Down
23 changes: 23 additions & 0 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,29 @@
</header>

<div id="body">
<!-- What was said before this run, read out of the room's log
(src-tauri/src/room_log.rs). A surface of its own, on the opposite
side from the account panel, because the room's body and the
history are two different things and mixing them would undo the
one that the room starts empty every time (#48).

The division between the two is the launch. Below is what was said
before this window opened; the room to the right is what is being
said in it. That is why nothing here follows along as posts
arrive — a strip that grew with the conversation would be a second
drawing of the conversation, which is the surface next to it. -->
<aside id="history" aria-label="履歴">
<div class="panel-head">
<h2 class="panel-title">履歴</h2>
</div>
<!-- Seeded, because the log is read after the frame is up: an empty
strip in that gap would say "nothing was ever said" at the one
moment nothing has been asked yet. -->
<ol id="history-list" class="history">
<li class="empty">読み込み中</li>
</ol>
</aside>

<div id="conversation">
<main id="room" aria-live="polite"></main>

Expand Down
2 changes: 2 additions & 0 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
mod config;
mod pty;
mod room;
mod room_log;
mod session;

use pty::PtyState;
Expand Down Expand Up @@ -46,6 +47,7 @@ pub fn run() {
room::room_participants,
room::room_join,
room::room_post,
room_log::room_log,
session::seated_accounts,
session::parse_launch_options,
session::preview_launch_args,
Expand Down
36 changes: 34 additions & 2 deletions src-tauri/src/room.rs
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,14 @@
//!
//! 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.
//!
//! What is admitted is also written to the room's log, inside the same
//! acquisition it was judged under, so the file's order is the floor's order
//! (`room_log`). A post is never held back on account of that write: it is in
//! the room before the disk is touched, and a failure there costs the record,
//! not the utterance (#48).

use crate::room_log;
use futures_util::{SinkExt, StreamExt};
use parking_lot::Mutex;
use room_floor::{Admission, Floor, Missed, Post};
Expand Down Expand Up @@ -462,7 +469,7 @@ fn deliver(

// One acquisition, both halves. Concurrent speakers serialise here, so the
// loser's check runs against a floor the winner has already changed.
let (admission, hue) = {
let (admission, hue, logged) = {
let mut inner = room.inner.lock();
let (since, hue) = match inner.participants.get(origin) {
Some(seat) => (seat.since, seat.hue),
Expand All @@ -476,7 +483,24 @@ fn deliver(
// refusal hands that copy back, and the screen draws it (#108).
post.hue = hue;
let admission = inner.floor.admit(origin, since, last_seen, post.clone());
(admission, hue)
// Written here, inside the acquisition the floor was judged under, so
// the file's order is the floor's order. Appending after the lock is
// dropped would let two speakers the floor has already ordered reach
// the disk the other way round, and the conversation would then have
// two orderings — which is the one thing the room is the authority on
// (#48).
//
// Only what was admitted. A refused post is not in the room, so there
// is nothing about it for the room to have recorded.
//
// The failure is carried out rather than reported here: the room is
// stopped while this lock is held, and telling the screen is not work
// to do with everyone waiting.
let logged = match &admission {
Admission::Admitted { .. } => room_log::append(app, &post),
Admission::Unseen(_) => Ok(()),
};
(admission, hue, logged)
};

if let Admission::Unseen(missed) = admission {
Expand All @@ -487,6 +511,14 @@ fn deliver(
};
}

// The post is in the room either way. A log that cannot be written loses
// the record, never the utterance — so nothing below is conditional on
// this, and the one thing that must not happen is it passing unnoticed
// (#48).
if let Err(err) = logged {
room_log::report(app, err);
}

let mut frame = serde_json::json!({
"type": "post",
"message_id": post.message_id,
Expand Down
Loading