pi-tinychat 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/pi.js CHANGED
@@ -1,196 +1,196 @@
1
- import { spawn } from "node:child_process";
2
- import { EventEmitter } from "node:events";
3
-
4
- /** @typedef {import("./pi-types.js").PiCommand} PiCommand */
5
- /** @typedef {import("./pi-types.js").PiRecord} PiRecord */
6
- /** @typedef {import("./pi-types.js").PiResponse} PiResponse */
7
-
8
- const MAX_STDERR = 8 * 1024;
9
- const DEFAULT_TIMEOUT = 60_000;
10
-
11
- /**
12
- * Minimal client for the official Pi RPC mode.
13
- *
14
- * Records are framed on LF only (JSON strings may legally contain U+2028 and
15
- * U+2029), so this intentionally avoids node:readline.
16
- */
17
- export class PiBridge extends EventEmitter {
18
- #process = null;
19
- #buffer = "";
20
- /** @type {Map<string, { resolve: (value: any) => void, reject: (reason?: any) => void, timer: NodeJS.Timeout }>} */
21
- #pending = new Map();
22
- #sequence = 0;
23
- #stderr = "";
24
- #stopping = false;
25
- #exitInfo = null;
26
- #launcher;
27
-
28
- constructor({ cwd, launcher, env = process.env, piArgs = [] }) {
29
- super();
30
- this.cwd = cwd;
31
- this.#launcher = launcher;
32
- this.env = env;
33
- this.piArgs = piArgs;
34
- }
35
-
36
- get running() {
37
- return this.#process !== null && this.#process.exitCode === null;
38
- }
39
-
40
- get lastExit() {
41
- return this.#exitInfo;
42
- }
43
-
44
- start() {
45
- if (this.#process) return;
46
- this.#exitInfo = null;
47
- this.#stderr = "";
48
- this.#buffer = "";
49
- this.#stopping = false;
50
-
51
- const args = [...this.#launcher.args, ...this.piArgs, "--mode", "rpc"];
52
- const child = spawn(this.#launcher.command, args, {
53
- cwd: this.cwd,
54
- env: this.env,
55
- stdio: ["pipe", "pipe", "pipe"],
56
- shell: Boolean(this.#launcher.shell),
57
- windowsHide: true,
58
- });
59
- this.#process = child;
60
-
61
- child.stdout.setEncoding("utf8");
62
- child.stdout.on("data", (chunk) => this.#onStdout(chunk));
63
- child.stderr.setEncoding("utf8");
64
- child.stderr.on("data", (chunk) => {
65
- this.#stderr = (this.#stderr + chunk).slice(-MAX_STDERR);
66
- });
67
- child.stdin.on("error", () => {
68
- /* reported through exit */
69
- });
70
- child.once("error", (error) => this.#onExit(null, null, error));
71
- child.once("exit", (code, signal) => this.#onExit(code, signal));
72
- }
73
-
74
- #onStdout(chunk) {
75
- this.#buffer += chunk;
76
- while (true) {
77
- const newline = this.#buffer.indexOf("\n");
78
- if (newline === -1) break;
79
- const line = this.#buffer.slice(0, newline).replace(/\r$/, "");
80
- this.#buffer = this.#buffer.slice(newline + 1);
81
- if (!line) continue;
82
- let record;
83
- try {
84
- record = JSON.parse(line);
85
- } catch {
86
- continue;
87
- }
88
- this.#dispatch(record);
89
- }
90
- }
91
-
92
- /** @param {PiRecord | PiResponse} record */
93
- #dispatch(record) {
94
- if (record.type === "response") {
95
- const pending = this.#pending.get(record.id);
96
- if (!pending) return;
97
- this.#pending.delete(record.id);
98
- clearTimeout(pending.timer);
99
- if (record.success) {
100
- pending.resolve("data" in record ? (record.data ?? null) : null);
101
- } else {
102
- pending.reject(new Error("error" in record ? record.error : "Pi command failed"));
103
- }
104
- return;
105
- }
106
- this.emit("record", record);
107
- }
108
-
109
- #onExit(code, signal, error) {
110
- const child = this.#process;
111
- if (!child) return;
112
- this.#process = null;
113
- const reason = error
114
- ? `Pi の起動に失敗しました: ${error.message}`
115
- : `Pi のプロセスが終了しました (code=${code ?? "-"} signal=${signal ?? "-"})`;
116
- const info = { reason, stderr: this.#stderr, stopping: this.#stopping };
117
- this.#exitInfo = info;
118
- for (const pending of this.#pending.values()) {
119
- clearTimeout(pending.timer);
120
- pending.reject(new Error(reason));
121
- }
122
- this.#pending.clear();
123
- if (!this.#stopping) this.emit("exit", info);
124
- }
125
-
126
- /**
127
- * Send one RPC command and resolve with its `data` payload.
128
- * @template {PiCommand} Command
129
- * @param {Command} command
130
- * @param {{ timeout?: number }} [options]
131
- * @returns {Promise<import("./pi-types.js").PiCommandData<Command["type"]>>}
132
- */
133
- request(command, { timeout = DEFAULT_TIMEOUT } = {}) {
134
- const child = this.#process;
135
- if (!child || child.exitCode !== null) {
136
- return Promise.reject(
137
- new Error(this.#exitInfo?.reason ?? "Pi に接続されていません"),
138
- );
139
- }
140
- if (this.#exitInfo) return Promise.reject(new Error(this.#exitInfo.reason));
141
-
142
- const id = `req_${++this.#sequence}`;
143
- return new Promise((resolve, reject) => {
144
- const timer = setTimeout(() => {
145
- this.#pending.delete(id);
146
- reject(new Error(`Pi の応答がタイムアウトしました (${command.type})`));
147
- }, timeout);
148
- timer.unref?.();
149
- this.#pending.set(id, { resolve, reject, timer });
150
- try {
151
- child.stdin.write(`${JSON.stringify({ ...command, id })}\n`);
152
- } catch (error) {
153
- this.#pending.delete(id);
154
- clearTimeout(timer);
155
- reject(error);
156
- }
157
- });
158
- }
159
-
160
- /**
161
- * Send a fire-and-forget record (no response is expected).
162
- * @param {PiRecord | PiCommand} record
163
- */
164
- sendRaw(record) {
165
- const stdin = this.#process?.stdin;
166
- if (!stdin?.writable) throw new Error("Pi に接続されていません");
167
- stdin.write(`${JSON.stringify(record)}\n`);
168
- }
169
-
170
- async stop() {
171
- const child = this.#process;
172
- if (!child) return;
173
- this.#stopping = true;
174
- await new Promise((resolve) => {
175
- const done = () => {
176
- clearTimeout(kill);
177
- clearTimeout(force);
178
- resolve();
179
- };
180
- const kill = setTimeout(() => child.kill("SIGTERM"), 1500);
181
- const force = setTimeout(() => child.kill("SIGKILL"), 3000);
182
- child.once("exit", done);
183
- try {
184
- child.stdin.end();
185
- } catch {
186
- /* ignore */
187
- }
188
- });
189
- this.#process = null;
190
- }
191
-
192
- async restart() {
193
- await this.stop();
194
- this.start();
195
- }
196
- }
1
+ import { spawn } from "node:child_process";
2
+ import { EventEmitter } from "node:events";
3
+
4
+ /** @typedef {import("./pi-types.js").PiCommand} PiCommand */
5
+ /** @typedef {import("./pi-types.js").PiRecord} PiRecord */
6
+ /** @typedef {import("./pi-types.js").PiResponse} PiResponse */
7
+
8
+ const MAX_STDERR = 8 * 1024;
9
+ const DEFAULT_TIMEOUT = 60_000;
10
+
11
+ /**
12
+ * Minimal client for the official Pi RPC mode.
13
+ *
14
+ * Records are framed on LF only (JSON strings may legally contain U+2028 and
15
+ * U+2029), so this intentionally avoids node:readline.
16
+ */
17
+ export class PiBridge extends EventEmitter {
18
+ #process = null;
19
+ #buffer = "";
20
+ /** @type {Map<string, { resolve: (value: any) => void, reject: (reason?: any) => void, timer: NodeJS.Timeout }>} */
21
+ #pending = new Map();
22
+ #sequence = 0;
23
+ #stderr = "";
24
+ #stopping = false;
25
+ #exitInfo = null;
26
+ #launcher;
27
+
28
+ constructor({ cwd, launcher, env = process.env, piArgs = [] }) {
29
+ super();
30
+ this.cwd = cwd;
31
+ this.#launcher = launcher;
32
+ this.env = env;
33
+ this.piArgs = piArgs;
34
+ }
35
+
36
+ get running() {
37
+ return this.#process !== null && this.#process.exitCode === null;
38
+ }
39
+
40
+ get lastExit() {
41
+ return this.#exitInfo;
42
+ }
43
+
44
+ start() {
45
+ if (this.#process) return;
46
+ this.#exitInfo = null;
47
+ this.#stderr = "";
48
+ this.#buffer = "";
49
+ this.#stopping = false;
50
+
51
+ const args = [...this.#launcher.args, ...this.piArgs, "--mode", "rpc"];
52
+ const child = spawn(this.#launcher.command, args, {
53
+ cwd: this.cwd,
54
+ env: this.env,
55
+ stdio: ["pipe", "pipe", "pipe"],
56
+ shell: Boolean(this.#launcher.shell),
57
+ windowsHide: true,
58
+ });
59
+ this.#process = child;
60
+
61
+ child.stdout.setEncoding("utf8");
62
+ child.stdout.on("data", (chunk) => this.#onStdout(chunk));
63
+ child.stderr.setEncoding("utf8");
64
+ child.stderr.on("data", (chunk) => {
65
+ this.#stderr = (this.#stderr + chunk).slice(-MAX_STDERR);
66
+ });
67
+ child.stdin.on("error", () => {
68
+ /* reported through exit */
69
+ });
70
+ child.once("error", (error) => this.#onExit(null, null, error));
71
+ child.once("exit", (code, signal) => this.#onExit(code, signal));
72
+ }
73
+
74
+ #onStdout(chunk) {
75
+ this.#buffer += chunk;
76
+ while (true) {
77
+ const newline = this.#buffer.indexOf("\n");
78
+ if (newline === -1) break;
79
+ const line = this.#buffer.slice(0, newline).replace(/\r$/, "");
80
+ this.#buffer = this.#buffer.slice(newline + 1);
81
+ if (!line) continue;
82
+ let record;
83
+ try {
84
+ record = JSON.parse(line);
85
+ } catch {
86
+ continue;
87
+ }
88
+ this.#dispatch(record);
89
+ }
90
+ }
91
+
92
+ /** @param {PiRecord | PiResponse} record */
93
+ #dispatch(record) {
94
+ if (record.type === "response") {
95
+ const pending = this.#pending.get(record.id);
96
+ if (!pending) return;
97
+ this.#pending.delete(record.id);
98
+ clearTimeout(pending.timer);
99
+ if (record.success) {
100
+ pending.resolve("data" in record ? (record.data ?? null) : null);
101
+ } else {
102
+ pending.reject(new Error("error" in record ? record.error : "Pi command failed"));
103
+ }
104
+ return;
105
+ }
106
+ this.emit("record", record);
107
+ }
108
+
109
+ #onExit(code, signal, error) {
110
+ const child = this.#process;
111
+ if (!child) return;
112
+ this.#process = null;
113
+ const reason = error
114
+ ? `Pi の起動に失敗しました: ${error.message}`
115
+ : `Pi のプロセスが終了しました (code=${code ?? "-"} signal=${signal ?? "-"})`;
116
+ const info = { reason, stderr: this.#stderr, stopping: this.#stopping };
117
+ this.#exitInfo = info;
118
+ for (const pending of this.#pending.values()) {
119
+ clearTimeout(pending.timer);
120
+ pending.reject(new Error(reason));
121
+ }
122
+ this.#pending.clear();
123
+ if (!this.#stopping) this.emit("exit", info);
124
+ }
125
+
126
+ /**
127
+ * Send one RPC command and resolve with its `data` payload.
128
+ * @template {PiCommand} Command
129
+ * @param {Command} command
130
+ * @param {{ timeout?: number }} [options]
131
+ * @returns {Promise<import("./pi-types.js").PiCommandData<Command["type"]>>}
132
+ */
133
+ request(command, { timeout = DEFAULT_TIMEOUT } = {}) {
134
+ const child = this.#process;
135
+ if (!child || child.exitCode !== null) {
136
+ return Promise.reject(
137
+ new Error(this.#exitInfo?.reason ?? "Pi に接続されていません"),
138
+ );
139
+ }
140
+ if (this.#exitInfo) return Promise.reject(new Error(this.#exitInfo.reason));
141
+
142
+ const id = `req_${++this.#sequence}`;
143
+ return new Promise((resolve, reject) => {
144
+ const timer = setTimeout(() => {
145
+ this.#pending.delete(id);
146
+ reject(new Error(`Pi の応答がタイムアウトしました (${command.type})`));
147
+ }, timeout);
148
+ timer.unref?.();
149
+ this.#pending.set(id, { resolve, reject, timer });
150
+ try {
151
+ child.stdin.write(`${JSON.stringify({ ...command, id })}\n`);
152
+ } catch (error) {
153
+ this.#pending.delete(id);
154
+ clearTimeout(timer);
155
+ reject(error);
156
+ }
157
+ });
158
+ }
159
+
160
+ /**
161
+ * Send a fire-and-forget record (no response is expected).
162
+ * @param {PiRecord | PiCommand} record
163
+ */
164
+ sendRaw(record) {
165
+ const stdin = this.#process?.stdin;
166
+ if (!stdin?.writable) throw new Error("Pi に接続されていません");
167
+ stdin.write(`${JSON.stringify(record)}\n`);
168
+ }
169
+
170
+ async stop() {
171
+ const child = this.#process;
172
+ if (!child) return;
173
+ this.#stopping = true;
174
+ await new Promise((resolve) => {
175
+ const done = () => {
176
+ clearTimeout(kill);
177
+ clearTimeout(force);
178
+ resolve();
179
+ };
180
+ const kill = setTimeout(() => child.kill("SIGTERM"), 1500);
181
+ const force = setTimeout(() => child.kill("SIGKILL"), 3000);
182
+ child.once("exit", done);
183
+ try {
184
+ child.stdin.end();
185
+ } catch {
186
+ /* ignore */
187
+ }
188
+ });
189
+ this.#process = null;
190
+ }
191
+
192
+ async restart() {
193
+ await this.stop();
194
+ this.start();
195
+ }
196
+ }
package/src/plan.js CHANGED
@@ -1,90 +1,91 @@
1
- export const PLAN_TOOL = "tiny_plan";
2
- export const PLAN_STATE_ENTRY = "tiny_plan_state";
3
-
4
- // Plans saved by the pre-rename Pi Dock build remain readable.
5
- const LEGACY_PLAN_TOOL = "dock_plan";
6
- const LEGACY_PLAN_STATE_ENTRY = "dock_plan_state";
7
-
8
- /** @typedef {import("./pi-types.js").PiSessionEntry} PiSessionEntry */
9
- /** @typedef {"pending" | "in_progress" | "done" | "cancelled"} PlanItemStatus */
10
- /** @typedef {{ id: string, text: string, status: PlanItemStatus }} PlanItem */
11
- /** @typedef {{ title: string, items: PlanItem[] }} Plan */
12
- /** @typedef {{ title?: unknown, items?: unknown }} PlanDetails */
13
-
14
- const STATUSES = new Set(["pending", "in_progress", "done", "cancelled"]);
15
-
16
- /**
17
- * Normalise the `details` payload of a tiny_plan tool result.
18
- * Returns null when details is not a plan; a cleared plan is an empty item list.
19
- * @param {unknown} details
20
- * @returns {Plan | null}
21
- */
22
- export function normalizePlan(details) {
23
- if (!details || typeof details !== "object") return null;
24
- const plan = /** @type {PlanDetails} */ (details);
25
- if (!Array.isArray(plan.items)) return null;
26
- const items = [];
27
- for (const item of plan.items) {
28
- if (!item || typeof item !== "object") continue;
29
- if (typeof item.id !== "string" || typeof item.text !== "string") continue;
30
- items.push({
31
- id: item.id,
32
- text: item.text,
33
- status: STATUSES.has(item.status) ? item.status : "pending",
34
- });
35
- }
36
- const title =
37
- typeof plan.title === "string" && plan.title.trim() ? plan.title.trim() : "";
38
- return { title, items };
39
- }
40
-
41
- /** @param {PiSessionEntry} entry */
42
- function planFromEntry(entry) {
43
- if (!entry || typeof entry !== "object") return null;
44
- if (
45
- entry.type === "custom" &&
46
- (entry.customType === PLAN_STATE_ENTRY || entry.customType === LEGACY_PLAN_STATE_ENTRY)
47
- ) {
48
- return normalizePlan(entry.data);
49
- }
50
- if (entry.type !== "message") return null;
51
- const message = entry.message;
52
- if (
53
- message?.role !== "toolResult" ||
54
- (message.toolName !== PLAN_TOOL && message.toolName !== LEGACY_PLAN_TOOL)
55
- ) {
56
- return null;
57
- }
58
- return normalizePlan(message.details);
59
- }
60
-
61
- /**
62
- * Pick the newest plan from the active branch. get_entries also returns
63
- * abandoned branches, so walking from leafId is required.
64
- * @param {PiSessionEntry[]} entries
65
- * @param {string | null} [leafId]
66
- * @returns {Plan | null}
67
- */
68
- export function planFromEntries(entries, leafId) {
69
- if (!Array.isArray(entries) || !entries.length) return null;
70
- const byId = new Map();
71
- for (const entry of entries) {
72
- if (entry && typeof entry.id === "string") byId.set(entry.id, entry);
73
- }
74
- let current = typeof leafId === "string" ? leafId : entries[entries.length - 1]?.id;
75
- const branch = [];
76
- const seen = new Set();
77
- while (typeof current === "string" && byId.has(current) && !seen.has(current)) {
78
- const entry = byId.get(current);
79
- branch.push(entry);
80
- seen.add(current);
81
- current = entry.parentId ?? null;
82
- }
83
- // Newest plan on the active branch. Only the agent replaces or clears a
84
- // plan; a new user message leaves the previous plan visible.
85
- for (const entry of branch) {
86
- const plan = planFromEntry(entry);
87
- if (plan) return plan;
88
- }
89
- return null;
90
- }
1
+ export const PLAN_TOOL = "tiny_plan";
2
+ export const PLAN_STATE_ENTRY = "tiny_plan_state";
3
+
4
+ // Plans saved by the pre-rename Pi Dock build remain readable.
5
+ const LEGACY_PLAN_TOOL = "dock_plan";
6
+ const LEGACY_PLAN_STATE_ENTRY = "dock_plan_state";
7
+
8
+ /** @typedef {import("./pi-types.js").PiSessionEntry} PiSessionEntry */
9
+ /** @typedef {"pending" | "in_progress" | "done" | "cancelled"} PlanItemStatus */
10
+ /** @typedef {{ id: string, text: string, status: PlanItemStatus }} PlanItem */
11
+ /** @typedef {{ title: string, items: PlanItem[], note?: string }} Plan */
12
+ /** @typedef {{ title?: unknown, items?: unknown, note?: unknown }} PlanDetails */
13
+
14
+ const STATUSES = new Set(["pending", "in_progress", "done", "cancelled"]);
15
+
16
+ /**
17
+ * Normalise the `details` payload of a tiny_plan tool result.
18
+ * Returns null when details is not a plan; a cleared plan is an empty item list.
19
+ * @param {unknown} details
20
+ * @returns {Plan | null}
21
+ */
22
+ export function normalizePlan(details) {
23
+ if (!details || typeof details !== "object") return null;
24
+ const plan = /** @type {PlanDetails} */ (details);
25
+ if (!Array.isArray(plan.items)) return null;
26
+ const items = [];
27
+ for (const item of plan.items) {
28
+ if (!item || typeof item !== "object") continue;
29
+ if (typeof item.id !== "string" || typeof item.text !== "string") continue;
30
+ items.push({
31
+ id: item.id,
32
+ text: item.text,
33
+ status: STATUSES.has(item.status) ? item.status : "pending",
34
+ });
35
+ }
36
+ const title =
37
+ typeof plan.title === "string" && plan.title.trim() ? plan.title.trim() : "";
38
+ const note = typeof plan.note === "string" ? plan.note.replace(/\s+/g, " ").trim() : "";
39
+ return { title, items, ...(note ? { note } : {}) };
40
+ }
41
+
42
+ /** @param {PiSessionEntry} entry */
43
+ function planFromEntry(entry) {
44
+ if (!entry || typeof entry !== "object") return null;
45
+ if (
46
+ entry.type === "custom" &&
47
+ (entry.customType === PLAN_STATE_ENTRY || entry.customType === LEGACY_PLAN_STATE_ENTRY)
48
+ ) {
49
+ return normalizePlan(entry.data);
50
+ }
51
+ if (entry.type !== "message") return null;
52
+ const message = entry.message;
53
+ if (
54
+ message?.role !== "toolResult" ||
55
+ (message.toolName !== PLAN_TOOL && message.toolName !== LEGACY_PLAN_TOOL)
56
+ ) {
57
+ return null;
58
+ }
59
+ return normalizePlan(message.details);
60
+ }
61
+
62
+ /**
63
+ * Pick the newest plan from the active branch. get_entries also returns
64
+ * abandoned branches, so walking from leafId is required.
65
+ * @param {PiSessionEntry[]} entries
66
+ * @param {string | null} [leafId]
67
+ * @returns {Plan | null}
68
+ */
69
+ export function planFromEntries(entries, leafId) {
70
+ if (!Array.isArray(entries) || !entries.length) return null;
71
+ const byId = new Map();
72
+ for (const entry of entries) {
73
+ if (entry && typeof entry.id === "string") byId.set(entry.id, entry);
74
+ }
75
+ let current = typeof leafId === "string" ? leafId : entries[entries.length - 1]?.id;
76
+ const branch = [];
77
+ const seen = new Set();
78
+ while (typeof current === "string" && byId.has(current) && !seen.has(current)) {
79
+ const entry = byId.get(current);
80
+ branch.push(entry);
81
+ seen.add(current);
82
+ current = entry.parentId ?? null;
83
+ }
84
+ // Newest plan on the active branch. Only the agent replaces or clears a
85
+ // plan; a new user message leaves the previous plan visible.
86
+ for (const entry of branch) {
87
+ const plan = planFromEntry(entry);
88
+ if (plan) return plan;
89
+ }
90
+ return null;
91
+ }