@vitex/nook 0.0.0-stage → 0.1.0

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/package.json CHANGED
@@ -1,6 +1,39 @@
1
1
  {
2
2
  "name": "@vitex/nook",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "publishConfig": {
5
+ "access": "public"
6
+ },
7
+ "description": "Nook — a quiet web interface for the Pi coding agent",
8
+ "type": "module",
9
+ "bin": {
10
+ "nook": "bin/nook.js"
11
+ },
12
+ "files": [
13
+ "bin",
14
+ "src/backend",
15
+ "dist/frontend",
16
+ "README.md"
17
+ ],
18
+ "workspaces": [
19
+ "src/backend",
20
+ "src/frontend"
21
+ ],
22
+ "scripts": {
23
+ "dev:backend": "npm --workspace @nook/backend run dev",
24
+ "dev:frontend": "npm --workspace @nook/frontend run dev",
25
+ "build:frontend": "npm --workspace @nook/frontend run build",
26
+ "build": "npm run build:frontend && node scripts/build.js",
27
+ "start": "node bin/nook.js start",
28
+ "service": "node bin/nook.js service",
29
+ "prepublishOnly": "npm run build"
30
+ },
31
+ "dependencies": {
32
+ "@earendil-works/pi-coding-agent": "0.86.1",
33
+ "typebox": "1.3.27"
34
+ },
35
+ "engines": {
36
+ "node": ">=22.19.0"
37
+ },
38
+ "license": "ISC"
39
+ }
@@ -0,0 +1,229 @@
1
+ /**
2
+ * 会话池:一个 Pi 会话文件对应一个常驻 AgentSession。
3
+ *
4
+ * 同一会话同时只允许一个 prompt。事件直接交给 `emit`(服务端负责 SSE 广播),
5
+ * 这样 Pi 的 message_update / tool_execution_* 能原样流到前端。
6
+ */
7
+ import crypto from 'node:crypto';
8
+ import { createAgentSession } from '@earendil-works/pi-coding-agent';
9
+ import { findSessionFile, openSessionManager } from '../sessions.js';
10
+ import { getModelRuntime } from './runtime.js';
11
+ import { loadCustomTools } from './tools.js';
12
+ import { log, logErr, shortId } from '../logging.js';
13
+
14
+ const IDLE_MS = 30 * 60 * 1000;
15
+ const MAX_IDLE = 12;
16
+
17
+ export class SessionPool {
18
+ constructor({ emit }) {
19
+ this.emit = emit;
20
+ this.entries = new Map();
21
+ this.ready = null;
22
+ this.gcTimer = setInterval(() => this.gc(), 10 * 60 * 1000);
23
+ if (this.gcTimer.unref) this.gcTimer.unref();
24
+ }
25
+
26
+ async init() {
27
+ if (!this.ready) {
28
+ this.ready = (async () => {
29
+ const [runtime, customTools] = await Promise.all([
30
+ getModelRuntime(),
31
+ loadCustomTools(),
32
+ ]);
33
+ this.runtime = runtime;
34
+ this.customTools = customTools;
35
+ return this;
36
+ })();
37
+ }
38
+ return this.ready;
39
+ }
40
+
41
+ key(project, sessionId) {
42
+ return `${project.id}:${sessionId}`;
43
+ }
44
+
45
+ async get(project, sessionId) {
46
+ await this.init();
47
+ const key = this.key(project, sessionId);
48
+ const cached = this.entries.get(key);
49
+ if (cached) {
50
+ cached.lastUsed = Date.now();
51
+ return cached;
52
+ }
53
+
54
+ const file = findSessionFile(project, sessionId);
55
+ const sessionManager = openSessionManager(project, sessionId, file);
56
+ const { session } = await createAgentSession({
57
+ cwd: project.path,
58
+ modelRuntime: this.runtime,
59
+ sessionManager,
60
+ customTools: this.customTools,
61
+ });
62
+
63
+ const entry = {
64
+ key,
65
+ project,
66
+ projectId: project.id,
67
+ sessionId,
68
+ sessionManager,
69
+ session,
70
+ running: false,
71
+ run: null,
72
+ lastUsed: Date.now(),
73
+ unsubscribe: null,
74
+ };
75
+ entry.unsubscribe = session.subscribe((event) => {
76
+ try {
77
+ this.emit(project, sessionId, event);
78
+ } catch (e) {
79
+ logErr('转发会话事件失败:', e?.message || e);
80
+ }
81
+ });
82
+ this.entries.set(key, entry);
83
+ this.evict();
84
+ log(`打开会话 ${shortId(sessionId)}(${file ? 'pi' : 'new'})`);
85
+ return entry;
86
+ }
87
+
88
+ /**
89
+ * 发送消息。
90
+ * mode: 'prompt' 普通发送;'steer' 运行中插话;'followUp' 运行中排队跟进。
91
+ * 运行中传普通 prompt 会抛 RUNNING;steer/followUp 只入队,不新建 run。
92
+ */
93
+ async send(project, sessionId, text, mode = 'prompt') {
94
+ const entry = await this.get(project, sessionId);
95
+ if (entry.running) {
96
+ if (mode === 'steer') {
97
+ await entry.session.steer(text);
98
+ return { queued: true, mode };
99
+ }
100
+ if (mode === 'followUp') {
101
+ await entry.session.followUp(text);
102
+ return { queued: true, mode };
103
+ }
104
+ const err = new Error('这个会话正在运行');
105
+ err.code = 'RUNNING';
106
+ throw err;
107
+ }
108
+
109
+ const run = {
110
+ id: crypto.randomUUID(),
111
+ startedAt: Date.now(),
112
+ userText: text,
113
+ startSeq: 0,
114
+ };
115
+ entry.running = true;
116
+ entry.run = run;
117
+ run.startSeq = this.emit(project, sessionId, {
118
+ type: 'nook_run_start',
119
+ runId: run.id,
120
+ startedAt: run.startedAt,
121
+ userText: text,
122
+ });
123
+
124
+ (async () => {
125
+ try {
126
+ await entry.session.prompt(text);
127
+ } catch (e) {
128
+ logErr('prompt 失败:', e?.message || e);
129
+ this.emit(project, sessionId, { type: 'nook_error', message: e?.message || String(e) });
130
+ } finally {
131
+ if (entry.run?.id === run.id) {
132
+ entry.running = false;
133
+ entry.run = null;
134
+ entry.lastUsed = Date.now();
135
+ this.emit(project, sessionId, { type: 'nook_run_end', runId: run.id });
136
+ }
137
+ }
138
+ })();
139
+
140
+ return { runId: run.id, startSeq: run.startSeq, queued: false };
141
+ }
142
+
143
+ async abort(project, sessionId) {
144
+ const entry = this.entries.get(this.key(project, sessionId));
145
+ if (!entry) return { aborted: false, cleared: { steering: [], followUp: [] } };
146
+ const cleared = entry.session.clearQueue();
147
+ if (!entry.running) return { aborted: false, cleared };
148
+ try {
149
+ await entry.session.abort();
150
+ } catch (e) {
151
+ logErr('abort 失败:', e?.message || e);
152
+ }
153
+ return { aborted: true, cleared };
154
+ }
155
+
156
+ clearQueue(project, sessionId) {
157
+ const entry = this.entries.get(this.key(project, sessionId));
158
+ if (!entry) return { steering: [], followUp: [] };
159
+ return entry.session.clearQueue();
160
+ }
161
+
162
+ getRun(project, sessionId) {
163
+ const entry = this.entries.get(this.key(project, sessionId));
164
+ if (!entry?.run) return null;
165
+ return {
166
+ runId: entry.run.id,
167
+ startedAt: entry.run.startedAt,
168
+ userText: entry.run.userText,
169
+ startSeq: entry.run.startSeq,
170
+ queue: {
171
+ steering: [...entry.session.getSteeringMessages()],
172
+ followUp: [...entry.session.getFollowUpMessages()],
173
+ },
174
+ };
175
+ }
176
+
177
+ isRunning(projectId, sessionId) {
178
+ const entry = this.entries.get(`${projectId}:${sessionId}`);
179
+ return Boolean(entry?.running);
180
+ }
181
+
182
+ runningSessions(projectId) {
183
+ const out = new Set();
184
+ for (const entry of this.entries.values()) {
185
+ if (entry.projectId === projectId && entry.running) out.add(entry.sessionId);
186
+ }
187
+ return out;
188
+ }
189
+
190
+ disposeEntry(entry) {
191
+ if (!entry || entry.running) return false;
192
+ try { entry.unsubscribe?.(); } catch { }
193
+ try { entry.session.dispose(); } catch { }
194
+ this.entries.delete(entry.key);
195
+ return true;
196
+ }
197
+
198
+ dispose(projectId, sessionId) {
199
+ return this.disposeEntry(this.entries.get(`${projectId}:${sessionId}`));
200
+ }
201
+
202
+ disposeProject(projectId) {
203
+ for (const entry of [...this.entries.values()]) {
204
+ if (entry.projectId === projectId) this.disposeEntry(entry);
205
+ }
206
+ }
207
+
208
+ disposeAll() {
209
+ for (const entry of [...this.entries.values()]) this.disposeEntry(entry);
210
+ clearInterval(this.gcTimer);
211
+ }
212
+
213
+ evict() {
214
+ if (this.entries.size <= MAX_IDLE) return;
215
+ const idle = [...this.entries.values()]
216
+ .filter((e) => !e.running)
217
+ .sort((a, b) => a.lastUsed - b.lastUsed);
218
+ while (this.entries.size > MAX_IDLE && idle.length) {
219
+ this.disposeEntry(idle.shift());
220
+ }
221
+ }
222
+
223
+ gc() {
224
+ const now = Date.now();
225
+ for (const entry of [...this.entries.values()]) {
226
+ if (!entry.running && now - entry.lastUsed > IDLE_MS) this.disposeEntry(entry);
227
+ }
228
+ }
229
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * 共享的 Pi 模型运行时。
3
+ * 模型选择完全交给 Pi 的设置,Nook 不指定默认值。
4
+ */
5
+ import { ModelRuntime } from '@earendil-works/pi-coding-agent';
6
+
7
+ let runtimePromise;
8
+
9
+ export function getModelRuntime() {
10
+ if (!runtimePromise) {
11
+ runtimePromise = ModelRuntime.create({
12
+ allowModelNetwork: true,
13
+ modelRefreshTimeoutMs: 15_000,
14
+ });
15
+ }
16
+ return runtimePromise;
17
+ }
@@ -0,0 +1,180 @@
1
+ /**
2
+ * 把 SDK 事件压成适合通过 SSE 传输的形状:
3
+ * - 去掉 message_update 里不断增长的 partial 快照
4
+ * - 工具结果/参数截断,避免手机端一次收几 MB
5
+ * - 保留前端工具卡片需要的 toolcall 参数快照
6
+ */
7
+ import { stripAnsi } from '../text.js';
8
+
9
+ const VALUE_LIMIT = 4000;
10
+ const RESULT_LIMIT = 8000;
11
+
12
+ function truncateString(s, limit) {
13
+ const str = String(s ?? '');
14
+ if (str.length <= limit) return str;
15
+ return str.slice(0, limit) + `\n…(已截断 ${str.length - limit} 字符)`;
16
+ }
17
+
18
+ export function truncateValue(value, limit = VALUE_LIMIT, depth = 0) {
19
+ if (value == null) return value;
20
+ if (typeof value === 'string') return truncateString(value, limit);
21
+ if (typeof value !== 'object') return value;
22
+ if (depth > 6) return '[…]';
23
+ if (Array.isArray(value)) return value.map((v) => truncateValue(v, limit, depth + 1));
24
+ const out = {};
25
+ for (const [k, v] of Object.entries(value)) out[k] = truncateValue(v, limit, depth + 1);
26
+ return out;
27
+ }
28
+
29
+ function serializeContent(content) {
30
+ if (typeof content === 'string') return content;
31
+ if (!Array.isArray(content)) return [];
32
+ return content.map((block) => {
33
+ if (!block || typeof block !== 'object') return block;
34
+ if (block.type === 'text') return { type: 'text', text: block.text };
35
+ if (block.type === 'thinking') {
36
+ return { type: 'thinking', thinking: block.thinking, redacted: Boolean(block.redacted) };
37
+ }
38
+ if (block.type === 'toolCall') {
39
+ return {
40
+ type: 'toolCall',
41
+ id: block.id,
42
+ name: block.name,
43
+ arguments: truncateValue(block.arguments),
44
+ };
45
+ }
46
+ if (block.type === 'image') return { type: 'text', text: '[图片]' };
47
+ return block;
48
+ });
49
+ }
50
+
51
+ function serializeToolResult(content) {
52
+ const text = Array.isArray(content)
53
+ ? content.filter((b) => b?.type === 'text').map((b) => b.text || '').join('\n')
54
+ : typeof content === 'string' ? content : '';
55
+ const cut = truncateString(stripAnsi(text), RESULT_LIMIT);
56
+ return { content: [{ type: 'text', text: cut }], truncated: cut.length !== text.length, length: text.length };
57
+ }
58
+
59
+ export function serializeMessage(message) {
60
+ if (!message || typeof message !== 'object') return message;
61
+ if (message.role === 'toolResult') {
62
+ return {
63
+ role: 'toolResult',
64
+ toolCallId: message.toolCallId,
65
+ toolName: message.toolName,
66
+ content: serializeToolResult(message.content).content,
67
+ isError: Boolean(message.isError),
68
+ timestamp: message.timestamp,
69
+ };
70
+ }
71
+ return {
72
+ role: message.role,
73
+ content: serializeContent(message.content),
74
+ stopReason: message.stopReason,
75
+ errorMessage: message.errorMessage,
76
+ timestamp: message.timestamp,
77
+ };
78
+ }
79
+
80
+ function toolCallFromPartial(partial, contentIndex, limit = 1200) {
81
+ const block = partial?.content?.[contentIndex];
82
+ if (!block || block.type !== 'toolCall') return null;
83
+ return {
84
+ id: block.id,
85
+ name: block.name,
86
+ arguments: truncateValue(block.arguments ?? block.partialArgs ?? block.partialJson, limit),
87
+ };
88
+ }
89
+
90
+ export function serializeEvent(event) {
91
+ if (!event || typeof event !== 'object') return event;
92
+ switch (event.type) {
93
+ case 'message_update': {
94
+ const a = event.assistantMessageEvent;
95
+ if (!a) return { type: 'message_update' };
96
+ const out = { type: a.type, contentIndex: a.contentIndex };
97
+ if (typeof a.delta === 'string') out.delta = a.delta;
98
+ if (a.type === 'toolcall_start' || a.type === 'toolcall_delta' || a.type === 'toolcall_end') {
99
+ const tc = a.type === 'toolcall_end' ? a.toolCall : toolCallFromPartial(a.partial, a.contentIndex);
100
+ if (tc) out.toolCall = {
101
+ id: tc.id,
102
+ name: tc.name,
103
+ arguments: truncateValue(tc.arguments, a.type === 'toolcall_end' ? 4000 : 1200),
104
+ };
105
+ }
106
+ return { type: 'message_update', assistantMessageEvent: out };
107
+ }
108
+ case 'message_start':
109
+ case 'message_end':
110
+ return { type: event.type, message: serializeMessage(event.message) };
111
+ case 'tool_execution_start':
112
+ return {
113
+ type: event.type,
114
+ toolCallId: event.toolCallId,
115
+ toolName: event.toolName,
116
+ args: truncateValue(event.args),
117
+ };
118
+ case 'tool_execution_update':
119
+ // 前端不需要流式工具输出;只保留事件骨架,避免把大段输出塞进 SSE / 内存环形缓冲
120
+ return {
121
+ type: event.type,
122
+ toolCallId: event.toolCallId,
123
+ toolName: event.toolName,
124
+ };
125
+ case 'tool_execution_end':
126
+ return {
127
+ type: event.type,
128
+ toolCallId: event.toolCallId,
129
+ toolName: event.toolName,
130
+ result: serializeToolResult(event.result?.content).content,
131
+ isError: Boolean(event.isError),
132
+ };
133
+ case 'turn_start':
134
+ return { type: event.type, turnIndex: event.turnIndex, timestamp: event.timestamp };
135
+ case 'turn_end':
136
+ return { type: event.type, turnIndex: event.turnIndex, timestamp: event.timestamp };
137
+ case 'agent_end':
138
+ return { type: 'agent_end', willRetry: Boolean(event.willRetry) };
139
+ case 'agent_settled':
140
+ return { type: 'agent_settled' };
141
+ case 'compaction_start':
142
+ return { type: event.type, reason: event.reason };
143
+ case 'compaction_end':
144
+ return {
145
+ type: event.type,
146
+ reason: event.reason,
147
+ aborted: Boolean(event.aborted),
148
+ willRetry: Boolean(event.willRetry),
149
+ errorMessage: event.errorMessage,
150
+ };
151
+ case 'auto_retry_start':
152
+ return {
153
+ type: event.type,
154
+ attempt: event.attempt,
155
+ maxAttempts: event.maxAttempts,
156
+ delayMs: event.delayMs,
157
+ errorMessage: event.errorMessage,
158
+ };
159
+ case 'auto_retry_end':
160
+ return { type: event.type, success: Boolean(event.success), attempt: event.attempt, finalError: event.finalError };
161
+ case 'session_info_changed':
162
+ return { type: event.type, name: event.name };
163
+ case 'thinking_level_changed':
164
+ return { type: event.type, level: event.level };
165
+ case 'model_select':
166
+ return { type: event.type, model: event.model ? { provider: event.model.provider, id: event.model.id } : null };
167
+ case 'queue_update':
168
+ return { type: event.type, steering: event.steering, followUp: event.followUp };
169
+ case 'bash_execution_update':
170
+ return { type: event.type, id: event.id, delta: truncateString(event.delta, 2000) };
171
+ case 'entry_appended':
172
+ return { type: 'entry_appended' };
173
+ case 'nook_run_start':
174
+ case 'nook_run_end':
175
+ case 'nook_error':
176
+ return event;
177
+ default:
178
+ return { type: event.type || 'unknown' };
179
+ }
180
+ }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Nook 的附加工具。Pi 自带的 read/bash/edit/write 仍然保留。
3
+ * 这里先只加 server_time。
4
+ */
5
+ import { defineTool } from '@earendil-works/pi-coding-agent';
6
+ import { Type } from 'typebox';
7
+
8
+ const serverTime = defineTool({
9
+ name: 'server_time',
10
+ label: '服务器时间',
11
+ description: '返回运行 agent 的服务器当前时间(本地时区),用于需要时间信息的任务。',
12
+ parameters: Type.Object({
13
+ format: Type.Optional(
14
+ Type.Union([Type.Literal('iso'), Type.Literal('human')], {
15
+ default: 'human',
16
+ description: '输出格式:iso 为 ISO 字符串,human 为可读时间',
17
+ }),
18
+ ),
19
+ }),
20
+ async execute(_toolCallId, params) {
21
+ const now = new Date();
22
+ if (params.format === 'iso') {
23
+ return { content: [{ type: 'text', text: now.toISOString() }], details: null };
24
+ }
25
+ return {
26
+ content: [{ type: 'text', text: now.toLocaleString('zh-CN', { timeZoneName: 'short' }) }],
27
+ details: null,
28
+ };
29
+ },
30
+ });
31
+
32
+ export async function loadCustomTools() {
33
+ return [serverTime];
34
+ }