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
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -29,4 +29,4 @@ src-tauri/gen/
# Machine-local cargo override (see src-tauri/.cargo/config.toml.example)
src-tauri/.cargo/config.toml
# Seeded design canvas — 2.2MB of editor payload, regenerated from design/*.dc.html
design/liplus-chat-ui.html
design/pullcept-ui.html
2 changes: 1 addition & 1 deletion NOTICE.txt
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
liplus-chat
Pullcept
Copyright 2026 Yoshiharu Uematsu
12 changes: 6 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
# liplus-chat
# Pullcept

> [!IMPORTANT]
> 部屋の一往復(人間の発言 → channel → セッションの返信 → 部屋への表示)は 2026-08-21 に実機で確認しました。配布は開発者環境向けの第一段階のみで、起動時に警告バナーが出ます。

## 概要

liplus-chat は、人間と複数の独立した AI / Li+ セッションが、一つのローカルな会話面を共有するためのデスクトップアプリを目指しています。
Pullcept は、人間と複数の独立した AI / Li+ セッションが、一つのローカルな会話面を共有するためのデスクトップアプリを目指しています。

各 AI セッションの文脈と人格は独立したまま保ち、発言者を識別できる共通の場で対話する構想です。特定の AI に他のセッションの内部文脈を統合するのではなく、それぞれが自分の文脈から同じ会話へ参加する形を取ります。

Expand Down Expand Up @@ -58,7 +58,7 @@ npm run tauri dev

「名前」と「色」は、そのアカウントが部屋で名乗る名前と、発言に付く色です。**いつでも変えられます。** 同一性は名前ではなく内部の id にあるため、改名しても稼働中のセッションの登録は追随します。色を選ばずに参加することもでき、その場合は名前から色が決まります。タイトルバーの「名前」「色」は同じものの人間側です(人間はアカウントではなく、この画面の話者として参加します)。

「起動オプション」には `--dangerously-skip-permissions` のように、CLI へ渡したいオプションをそのまま書けます。アプリは部屋の channel エントリ(`server:liplus-chat-room-<slug>-<hash>`)をここへ統合するので、別の channel サーバを指定しても部屋の入力路は残ります。実際に起動する行は入力欄の右に表示されます。
「起動オプション」には `--dangerously-skip-permissions` のように、CLI へ渡したいオプションをそのまま書けます。アプリは部屋の channel エントリ(`server:pullcept-room-<slug>-<hash>`)をここへ統合するので、別の channel サーバを指定しても部屋の入力路は残ります。実際に起動する行は入力欄の右に表示されます。

作業ディレクトリの入力欄があります。初回はホームディレクトリが入っているので、セッションを動かしたいディレクトリへ変更してください。

Expand All @@ -70,8 +70,8 @@ npm run tauri dev

アカウントを選び「セッション参加」を押すと、次の 2 つが行われます。

1. そのアカウントの作業ディレクトリの `.mcp.json` へ、部屋のサイドカーを `liplus-chat-room-<slug>-<hash>` という名前で登録します。**既存の内容はマージして保持します**が、あなたのリポジトリのファイルを書き換える操作です。登録鍵はアカウント id から決まります。アカウントごとに分けているのは同じ作業ディレクトリへ 2 つのセッションを立てたときに互いの登録を潰さないためで、名前ではなく id から導くのは改名で鍵が動かないようにするためです。前回の起動で書かれたエントリ(もう繋がらないポートを指しているもの)は、このとき掃除します。
2. `--dangerously-load-development-channels server:liplus-chat-room-<slug>-<hash>` を付けて CLI を PTY 上の対話セッションとして起動します。
1. そのアカウントの作業ディレクトリの `.mcp.json` へ、部屋のサイドカーを `pullcept-room-<slug>-<hash>` という名前で登録します。**既存の内容はマージして保持します**が、あなたのリポジトリのファイルを書き換える操作です。登録鍵はアカウント id から決まります。アカウントごとに分けているのは同じ作業ディレクトリへ 2 つのセッションを立てたときに互いの登録を潰さないためで、名前ではなく id から導くのは改名で鍵が動かないようにするためです。前回の起動で書かれたエントリ(もう繋がらないポートを指しているもの)は、このとき掃除します。
2. `--dangerously-load-development-channels server:pullcept-room-<slug>-<hash>` を付けて CLI を PTY 上の対話セッションとして起動します。

同じアカウントを二重に起動することはできません。一つのアカウントが持てる席は、一つの部屋につき一つです。

Expand Down Expand Up @@ -156,6 +156,6 @@ portable-pty-patch/ Windows 対応を含む portable-pty のローカルパッ

## ライセンス

liplus-chat は [Apache License 2.0](LICENSE) のもとで提供されます。著作権表示は [NOTICE.txt](NOTICE.txt) を参照してください。
Pullcept は [Apache License 2.0](LICENSE) のもとで提供されます。著作権表示は [NOTICE.txt](NOTICE.txt) を参照してください。

`portable-pty-patch/` は MIT License のコードを含みます。詳細は [`portable-pty-patch/LICENSE.md`](portable-pty-patch/LICENSE.md) を参照してください。
4 changes: 2 additions & 2 deletions crates/mcp-config/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
[package]
name = "mcp-config"
version = "0.1.0"
description = "Registration of the liplus-chat room sidecar in a project's .mcp.json"
description = "Registration of the Pullcept room sidecar in a project's .mcp.json"
authors = ["Liplus Project Contributors"]
license = "Apache-2.0"
edition = "2021"

# Deliberately free of tauri. This crate writes into the user's own project
# directory, which is the part of liplus-chat that most needs test coverage,
# directory, which is the part of Pullcept that most needs test coverage,
# and a test binary that links the tauri dependency tree does not load on the
# GNU target (see the CI note in docs/0-requirements.md).

Expand Down
62 changes: 31 additions & 31 deletions crates/mcp-config/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
//! registers the same server twice and takes the whole room down.
//!
//! This crate holds no tauri: it writes into the user's own project directory,
//! which is the part of liplus-chat that most needs test coverage, and a test
//! which is the part of Pullcept that most needs test coverage, and a test
//! binary linking the tauri tree does not load on the GNU target.

use serde_json::{json, Map, Value};
Expand All @@ -24,7 +24,7 @@ use std::path::{Path, PathBuf};
/// a single key means the second launch overwrites the first one's name, hue
/// and room address — the identity the first session was launched with is gone
/// while that session is still running (#40).
pub const SERVER_PREFIX: &str = "liplus-chat-room";
pub const SERVER_PREFIX: &str = "pullcept-room";

/// The `.mcp.json` key, and the `server:<name>` tag, for one account.
///
Expand All @@ -41,7 +41,7 @@ pub const SERVER_PREFIX: &str = "liplus-chat-room";
/// The slug half is legibility and the hash half is what makes the key total,
/// as it was under the name. An id is opaque, so the slug reads less well than
/// a name did; which account an entry belongs to is read from
/// `LIPLUS_AGENT_NAME` in its own env instead. Legibility loses to identity
/// `PULLCEPT_AGENT_NAME` in its own env instead. Legibility loses to identity
/// here — a key that reads oddly costs one lookup, and a key that moves is a
/// registration nobody can find.
pub fn server_name_for(account_id: &str) -> String {
Expand Down Expand Up @@ -204,7 +204,7 @@ pub fn split_launch_options(text: &str) -> Vec<String> {
///
/// Absolute paths and nothing to resolve. The CLI spawns this from the user's
/// own project directory, so anything looked up by name is looked up there:
/// `npx tsx` searched for a package that lives in liplus-chat and asked to
/// `npx tsx` searched for a package that lives in Pullcept and asked to
/// install it, from a process with no way to answer (#22). `node` is an
/// executable rather than a shell script, so no shell wrapper is needed either.
fn spawn_form(runner: &Path, entry: &Path) -> (&'static str, Vec<String>) {
Expand Down Expand Up @@ -261,26 +261,26 @@ pub fn register_sidecar(dir: &Path, room: &RoomRegistration<'_>) -> Result<PathB
// retries nothing forever, and the file would grow by one key per account
// ever launched here. Entries carrying the current address are live
// siblings — the other sessions of this run — and stay. Entries with no
// `LIPLUS_ROOM_URL` at all are not ours to judge, whatever they are named.
// `PULLCEPT_ROOM_URL` at all are not ours to judge, whatever they are named.
servers.retain(|name, entry| {
if !name.starts_with(SERVER_PREFIX) {
return true;
}
match entry.get("env").and_then(|env| env.get("LIPLUS_ROOM_URL")) {
match entry.get("env").and_then(|env| env.get("PULLCEPT_ROOM_URL")) {
Some(Value::String(url)) => url == room.room_url,
_ => true,
}
});

let mut env = Map::new();
env.insert("LIPLUS_ROOM_URL".into(), json!(room.room_url));
env.insert("LIPLUS_ROOM_TOKEN".into(), json!(room.token));
env.insert("LIPLUS_AGENT_NAME".into(), json!(room.agent_name));
env.insert("LIPLUS_ROOM_ID".into(), json!("liplus-chat"));
env.insert("PULLCEPT_ROOM_URL".into(), json!(room.room_url));
env.insert("PULLCEPT_ROOM_TOKEN".into(), json!(room.token));
env.insert("PULLCEPT_AGENT_NAME".into(), json!(room.agent_name));
env.insert("PULLCEPT_ROOM_ID".into(), json!("pullcept"));
// Only when declared. An undeclared participant is a participant the room
// derives a hue for, which is not the same state as one who chose that hue.
if let Some(hue) = room.agent_hue {
env.insert("LIPLUS_AGENT_HUE".into(), json!(format!("{hue:.1}")));
env.insert("PULLCEPT_AGENT_HUE".into(), json!(format!("{hue:.1}")));
}

servers.insert(
Expand Down Expand Up @@ -308,7 +308,7 @@ mod tests {

impl Scratch {
fn new() -> Self {
let dir = std::env::temp_dir().join(format!("liplus-chat-test-{}", Uuid::new_v4()));
let dir = std::env::temp_dir().join(format!("pullcept-test-{}", Uuid::new_v4()));
std::fs::create_dir_all(&dir).expect("scratch dir");
Scratch(dir)
}
Expand All @@ -323,8 +323,8 @@ mod tests {
}
}

const ENTRY: &str = "C:/liplus-chat/sidecar/src/index.ts";
const RUNNER: &str = "C:/liplus-chat/node_modules/tsx/dist/cli.mjs";
const ENTRY: &str = "C:/pullcept/sidecar/src/index.ts";
const RUNNER: &str = "C:/pullcept/node_modules/tsx/dist/cli.mjs";
/// The account `Lin` is launched from. Opaque here as it is in the app: a
/// test that read a name out of it would be testing the wrong key.
const LIN: &str = "8f14e45f-ceea-467a-b160-6f14e45fceea";
Expand Down Expand Up @@ -356,17 +356,17 @@ mod tests {

let json = read(&path);
let server = &json["mcpServers"][server_name_for(LIN)];
assert_eq!(server["env"]["LIPLUS_ROOM_URL"], "ws://127.0.0.1:1234");
assert_eq!(server["env"]["LIPLUS_ROOM_TOKEN"], "tok");
assert_eq!(server["env"]["LIPLUS_AGENT_NAME"], "Lin");
assert_eq!(server["env"]["LIPLUS_ROOM_ID"], "liplus-chat");
assert_eq!(server["env"]["PULLCEPT_ROOM_URL"], "ws://127.0.0.1:1234");
assert_eq!(server["env"]["PULLCEPT_ROOM_TOKEN"], "tok");
assert_eq!(server["env"]["PULLCEPT_AGENT_NAME"], "Lin");
assert_eq!(server["env"]["PULLCEPT_ROOM_ID"], "pullcept");
// Undeclared is the key absent, not a default value: a hue written here
// would be a declaration this participant never made.
assert!(
!server["env"]
.as_object()
.expect("env")
.contains_key("LIPLUS_AGENT_HUE"),
.contains_key("PULLCEPT_AGENT_HUE"),
"an undeclared hue must leave no key behind"
);

Expand Down Expand Up @@ -425,8 +425,8 @@ mod tests {

let json = read(&path);
let server = &json["mcpServers"][server_name_for(LIN)];
assert_eq!(server["env"]["LIPLUS_ROOM_TOKEN"], "tok2");
assert_eq!(server["env"]["LIPLUS_AGENT_HUE"], "145.0");
assert_eq!(server["env"]["PULLCEPT_ROOM_TOKEN"], "tok2");
assert_eq!(server["env"]["PULLCEPT_AGENT_HUE"], "145.0");
assert_eq!(
json["mcpServers"].as_object().expect("servers").len(),
1,
Expand Down Expand Up @@ -461,7 +461,7 @@ mod tests {
let servers = json["mcpServers"].as_object().expect("servers");
assert_eq!(servers.len(), 1, "a rename must not open a second entry");
assert_eq!(
json["mcpServers"][server_name_for(LIN)]["env"]["LIPLUS_AGENT_NAME"],
json["mcpServers"][server_name_for(LIN)]["env"]["PULLCEPT_AGENT_NAME"],
"リン",
"the new name belongs in the entry the id already had"
);
Expand Down Expand Up @@ -491,12 +491,12 @@ mod tests {

let json = read(&path);
assert_eq!(
json["mcpServers"][server_name_for(LIN)]["env"]["LIPLUS_AGENT_NAME"],
json["mcpServers"][server_name_for(LIN)]["env"]["PULLCEPT_AGENT_NAME"],
"Lin",
"the first session's identity must survive the second launch"
);
assert_eq!(
json["mcpServers"][server_name_for(LAY)]["env"]["LIPLUS_AGENT_NAME"],
json["mcpServers"][server_name_for(LAY)]["env"]["PULLCEPT_AGENT_NAME"],
"Lay"
);
assert_eq!(json["mcpServers"].as_object().expect("servers").len(), 2);
Expand All @@ -512,9 +512,9 @@ mod tests {
std::fs::write(
scratch.path().join(".mcp.json"),
r#"{"mcpServers":{
"liplus-chat-room": {"env":{"LIPLUS_ROOM_URL":"ws://127.0.0.1:1"}},
"liplus-chat-room-lay-00000000": {"env":{"LIPLUS_ROOM_URL":"ws://127.0.0.1:1234"}},
"liplus-chat-room-theirs": {"command":"not-ours"},
"pullcept-room": {"env":{"PULLCEPT_ROOM_URL":"ws://127.0.0.1:1"}},
"pullcept-room-lay-00000000": {"env":{"PULLCEPT_ROOM_URL":"ws://127.0.0.1:1234"}},
"pullcept-room-theirs": {"command":"not-ours"},
"theirs": {"command":"their-server"}
}}"#,
)
Expand All @@ -528,15 +528,15 @@ mod tests {
let json = read(&path);
let servers = json["mcpServers"].as_object().expect("servers");
assert!(
!servers.contains_key("liplus-chat-room"),
!servers.contains_key("pullcept-room"),
"an entry from a previous run must go"
);
assert!(
servers.contains_key("liplus-chat-room-lay-00000000"),
servers.contains_key("pullcept-room-lay-00000000"),
"a live sibling of this run must stay"
);
assert!(
servers.contains_key("liplus-chat-room-theirs"),
servers.contains_key("pullcept-room-theirs"),
"an entry with no room address of ours is not ours to remove"
);
assert!(servers.contains_key("theirs"));
Expand All @@ -552,7 +552,7 @@ mod tests {
// input that slugs to nothing still gets a key of its own, and two that
// slug alike still get two. The uniqueness lives in the hash, and the
// ids the app mints do not lean on the slug for it.
assert!(server_name_for("マスター").starts_with("liplus-chat-room-"));
assert!(server_name_for("マスター").starts_with("pullcept-room-"));
assert_ne!(server_name_for("マスター"), server_name_for("ますたー"));
assert_ne!(server_name_for("Lin"), server_name_for("lin!"));
// Nothing outside the set a console and a JSON key both leave alone.
Expand Down
2 changes: 1 addition & 1 deletion crates/room-floor/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
[package]
name = "room-floor"
version = "0.1.0"
description = "The floor of the liplus-chat room: what has been said, and who had seen it before speaking"
description = "The floor of the Pullcept room: what has been said, and who had seen it before speaking"
authors = ["Liplus Project Contributors"]
license = "Apache-2.0"
edition = "2021"
Expand Down
4 changes: 2 additions & 2 deletions design/Main.dc.html
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@

<!-- titlebar -->
<div style="display: flex; align-items: center; gap: 12px; border-bottom: 1px solid #d8d8de; padding: 8px 14px; font-size: 13.6px;">
<span style="font-weight: 600;">Li+ Chat</span>
<span style="font-weight: 600;">Pullcept</span>
<span style="flex: 1; color: #6b6b76; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">Claude Code / Lin</span>
<label style="display: flex; align-items: center; gap: 6px; color: #6b6b76;">
名前
Expand All @@ -46,7 +46,7 @@
起動オプション
<input class="mono" value="--dangerously-skip-permissions --dangerously-load-development-channels server:github-webhook-mcp" style="width: 352px; font-size: 12px; color: #1b1b1f; background: #f4f4f7; border: 1px solid #d8d8de; border-radius: 4px; padding: 4px 7px;">
</label>
<span class="mono" style="flex: 1; color: #6b6b76; font-size: 11.5px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">claude --dangerously-skip-permissions --dangerously-load-development-channels server:liplus-chat-room server:github-webhook-mcp</span>
<span class="mono" style="flex: 1; color: #6b6b76; font-size: 11.5px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">claude --dangerously-skip-permissions --dangerously-load-development-channels server:pullcept-room server:github-webhook-mcp</span>
</div>

<!-- body: room + participants -->
Expand Down
2 changes: 1 addition & 1 deletion design/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,6 @@ UI 設計キャンバスの元ファイル。公開先は `docs/0-requirements.m

色と寸法は `src/styles.css` の実値をそのまま使っている。実装側のトークンを変えたら、こちらも合わせること。

公開版は Claude Code の `/design` スキルから再生成する。`liplus-chat-ui.html`(約 2.2MB、エディタ本体を含む)は生成物なので追跡しない。
公開版は Claude Code の `/design` スキルから再生成する。`pullcept-ui.html`(約 2.2MB、エディタ本体を含む)は生成物なので追跡しない。

キャンバス側で編集された場合は、先に公開版を読み戻してからこちらを直す。手元の古い版で上書きしないため。
2 changes: 1 addition & 1 deletion design/Vision.dc.html
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
<!-- sidebar -->
<div style="width: 216px; background: #f4f4f7; border-right: 1px solid #d8d8de; display: flex; flex-direction: column; min-height: 0;">
<div style="display: flex; align-items: center; gap: 8px; padding: 10px 14px; border-bottom: 1px solid #d8d8de; font-size: 13.6px; font-weight: 600;">
Li+ Chat
Pullcept
</div>

<div style="flex: 1; overflow: hidden; padding: 10px 0;">
Expand Down
Loading