@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.
@@ -0,0 +1,337 @@
1
+ /**
2
+ * 后台服务管理。
3
+ *
4
+ * macOS 使用 launchd,Linux 使用 systemd 用户服务(Ubuntu 24.04+)。
5
+ * 所有路径都根据当前运行环境动态生成,不写死任何本机路径。
6
+ */
7
+ import { execFileSync } from 'node:child_process';
8
+ import fs from 'node:fs';
9
+ import os from 'node:os';
10
+ import path from 'node:path';
11
+ import { PROJECT_DIR, getLogsDir, getNodeBin, ensureHome } from './config.js';
12
+ import { log, logErr } from './logging.js';
13
+
14
+ const LABEL_DEFAULT = 'com.nook.server';
15
+ const IS_MACOS = process.platform === 'darwin';
16
+ const IS_LINUX = process.platform === 'linux';
17
+
18
+ function label() {
19
+ return process.env.NOOK_SERVICE_LABEL || LABEL_DEFAULT;
20
+ }
21
+
22
+ function manager() {
23
+ if (IS_MACOS) return 'launchd';
24
+ if (IS_LINUX) return 'systemd';
25
+ return 'unsupported';
26
+ }
27
+
28
+ function serviceFile() {
29
+ if (IS_MACOS) {
30
+ return path.join(os.homedir(), 'Library', 'LaunchAgents', `${label()}.plist`);
31
+ }
32
+ if (IS_LINUX) {
33
+ const configHome = process.env.XDG_CONFIG_HOME
34
+ ? path.resolve(process.env.XDG_CONFIG_HOME)
35
+ : path.join(os.homedir(), '.config');
36
+ return path.join(configHome, 'systemd', 'user', `${label()}.service`);
37
+ }
38
+ return '';
39
+ }
40
+
41
+ function scriptPath() {
42
+ return path.resolve(process.env.NOOK_LAUNCHER || path.join(PROJECT_DIR, 'bin', 'nook.js'));
43
+ }
44
+
45
+ function run(cmd, args, { allowFailure = false, ...options } = {}) {
46
+ try {
47
+ return execFileSync(cmd, args, {
48
+ encoding: 'utf8',
49
+ stdio: ['ignore', 'pipe', 'pipe'],
50
+ ...options,
51
+ });
52
+ } catch (e) {
53
+ if (allowFailure) return String(e?.stdout || '');
54
+ const detail = String(e?.stderr || e?.message || e).trim();
55
+ throw new Error(`${cmd} ${args.join(' ')} 失败:${detail}`);
56
+ }
57
+ }
58
+
59
+ function systemctl(args, options = {}) {
60
+ const env = { ...process.env };
61
+ if (!env.XDG_RUNTIME_DIR && typeof process.getuid === 'function') {
62
+ const runtimeDir = `/run/user/${process.getuid()}`;
63
+ if (fs.existsSync(runtimeDir)) env.XDG_RUNTIME_DIR = runtimeDir;
64
+ }
65
+ return run('systemctl', ['--user', ...args], { env, ...options });
66
+ }
67
+
68
+ function hasSystemd() {
69
+ if (!IS_LINUX || !fs.existsSync('/run/systemd/system')) return false;
70
+ try {
71
+ execFileSync('systemctl', ['--version'], { stdio: 'ignore' });
72
+ return true;
73
+ } catch {
74
+ return false;
75
+ }
76
+ }
77
+
78
+ function requireSupported() {
79
+ if (IS_MACOS) return;
80
+ if (IS_LINUX) {
81
+ if (!hasSystemd()) throw new Error('Linux 后台服务需要 systemd(Ubuntu 24.04+)');
82
+ return;
83
+ }
84
+ throw new Error(`当前平台不支持后台服务:${process.platform}`);
85
+ }
86
+
87
+ function xml(s) {
88
+ return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
89
+ }
90
+
91
+ function systemdEscape(s) {
92
+ return String(s)
93
+ .replace(/\\/g, '\\\\')
94
+ .replace(/"/g, '\\"')
95
+ .replace(/\n/g, '\\n')
96
+ .replace(/\r/g, '\\r')
97
+ .replace(/\t/g, '\\t')
98
+ .replace(/%/g, '%%');
99
+ }
100
+
101
+ function systemdQuote(s) {
102
+ return `"${systemdEscape(s)}"`;
103
+ }
104
+
105
+ export function renderPlist({ label: serviceLabel, nodeBin, script, workingDir, port, host, stdout, stderr, env }) {
106
+ const envLines = Object.entries(env || {}).map(([k, v]) => ` <key>${xml(k)}</key>\n <string>${xml(v)}</string>`);
107
+ return [
108
+ '<?xml version="1.0" encoding="UTF-8"?>',
109
+ '<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">',
110
+ '<plist version="1.0">',
111
+ '<dict>',
112
+ ` <key>Label</key>\n <string>${xml(serviceLabel)}</string>`,
113
+ ' <key>ProgramArguments</key>',
114
+ ' <array>',
115
+ ` <string>${xml(nodeBin)}</string>`,
116
+ ` <string>${xml(script)}</string>`,
117
+ ' <string>start</string>',
118
+ ' <string>--port</string>',
119
+ ` <string>${xml(String(port))}</string>`,
120
+ ' <string>--host</string>',
121
+ ` <string>${xml(String(host))}</string>`,
122
+ ' </array>',
123
+ ' <key>WorkingDirectory</key>',
124
+ ` <string>${xml(workingDir)}</string>`,
125
+ ' <key>RunAtLoad</key>\n <true/>',
126
+ ' <key>KeepAlive</key>\n <true/>',
127
+ ' <key>ThrottleInterval</key>\n <integer>5</integer>',
128
+ ' <key>EnvironmentVariables</key>',
129
+ ' <dict>',
130
+ ...envLines,
131
+ ' </dict>',
132
+ ` <key>StandardOutPath</key>\n <string>${xml(stdout)}</string>`,
133
+ ` <key>StandardErrorPath</key>\n <string>${xml(stderr)}</string>`,
134
+ '</dict>',
135
+ '</plist>',
136
+ '',
137
+ ].join('\n');
138
+ }
139
+
140
+ export function renderSystemdUnit({ label: serviceLabel, nodeBin, script, workingDir, port, host, env }) {
141
+ const envLines = Object.entries(env || {})
142
+ .map(([k, v]) => `Environment=${systemdQuote(`${k}=${v}`)}`);
143
+ return [
144
+ '[Unit]',
145
+ 'Description=Nook - Pi web interface',
146
+ '',
147
+ '[Service]',
148
+ 'Type=simple',
149
+ `WorkingDirectory=${systemdQuote(workingDir)}`,
150
+ `ExecStart=${systemdQuote(nodeBin)} ${systemdQuote(script)} start --host ${systemdQuote(host)} --port ${systemdQuote(String(port))}`,
151
+ 'Restart=always',
152
+ 'RestartSec=5',
153
+ ...envLines,
154
+ '',
155
+ '[Install]',
156
+ 'WantedBy=default.target',
157
+ '',
158
+ ].join('\n');
159
+ }
160
+
161
+ function isLoaded() {
162
+ if (IS_MACOS) {
163
+ try {
164
+ const out = run('launchctl', ['list', label()], { allowFailure: true });
165
+ return out.includes(label());
166
+ } catch {
167
+ return false;
168
+ }
169
+ }
170
+ if (IS_LINUX) {
171
+ if (!hasSystemd()) return false;
172
+ return systemctl(['is-active', label()], { allowFailure: true }).trim() === 'active';
173
+ }
174
+ return false;
175
+ }
176
+
177
+ function plistArgs(text) {
178
+ const host = text.match(/<string>--host<\/string>\s*<string>([^<]*)<\/string>/)?.[1] || '';
179
+ const port = Number(text.match(/<string>--port<\/string>\s*<string>([^<]*)<\/string>/)?.[1] || 0);
180
+ return { host, port: Number.isFinite(port) ? port : 0 };
181
+ }
182
+
183
+ function unitArgs(text) {
184
+ const exec = text.match(/^ExecStart=(.*)$/m)?.[1] || '';
185
+ const hostMatch = exec.match(/--host\s+(?:"((?:\\.|[^"\\])*)"|(\S+))/);
186
+ const portMatch = exec.match(/--port\s+(?:"(\d+)"|(\d+))/);
187
+ const port = Number(portMatch?.[1] || portMatch?.[2] || 0);
188
+ return {
189
+ host: hostMatch?.[1] ?? hostMatch?.[2] ?? '',
190
+ port: Number.isFinite(port) ? port : 0,
191
+ };
192
+ }
193
+
194
+ function serviceArgs(file) {
195
+ try {
196
+ const text = fs.readFileSync(file, 'utf8');
197
+ return IS_MACOS ? plistArgs(text) : unitArgs(text);
198
+ } catch {
199
+ return { host: '', port: 0 };
200
+ }
201
+ }
202
+
203
+ export function serviceStatus() {
204
+ const file = serviceFile();
205
+ const installed = Boolean(file) && fs.existsSync(file);
206
+ const args = installed ? serviceArgs(file) : { host: '', port: 0 };
207
+ return {
208
+ label: label(),
209
+ manager: manager(),
210
+ file,
211
+ installed,
212
+ running: isLoaded(),
213
+ host: args.host,
214
+ port: args.port,
215
+ };
216
+ }
217
+
218
+ function enableLinger() {
219
+ try {
220
+ execFileSync('loginctl', ['enable-linger', os.userInfo().username], { stdio: 'ignore' });
221
+ } catch {
222
+ logErr(`提示:未能启用 linger;未登录时服务可能不会运行。可手动执行:sudo loginctl enable-linger ${os.userInfo().username}`);
223
+ }
224
+ }
225
+
226
+ export function installService({ port, host }) {
227
+ requireSupported();
228
+ ensureHome();
229
+ const file = serviceFile();
230
+ const logs = getLogsDir();
231
+ fs.mkdirSync(path.dirname(file), { recursive: true });
232
+ fs.mkdirSync(logs, { recursive: true });
233
+
234
+ const env = {
235
+ NOOK_SERVICE: '1',
236
+ ...(process.env.PATH ? { PATH: process.env.PATH } : {}),
237
+ ...(process.env.NOOK_HOME ? { NOOK_HOME: process.env.NOOK_HOME } : {}),
238
+ };
239
+
240
+ const content = IS_MACOS
241
+ ? renderPlist({
242
+ label: label(),
243
+ nodeBin: getNodeBin(),
244
+ script: scriptPath(),
245
+ workingDir: PROJECT_DIR,
246
+ port,
247
+ host,
248
+ stdout: path.join(logs, 'stdout.log'),
249
+ stderr: path.join(logs, 'stderr.log'),
250
+ env,
251
+ })
252
+ : renderSystemdUnit({
253
+ label: label(),
254
+ nodeBin: getNodeBin(),
255
+ script: scriptPath(),
256
+ workingDir: PROJECT_DIR,
257
+ port,
258
+ host,
259
+ env,
260
+ });
261
+
262
+ fs.writeFileSync(file, content);
263
+ log(`已写入 ${file}`);
264
+
265
+ if (IS_MACOS) {
266
+ run('launchctl', ['unload', file], { allowFailure: true });
267
+ run('launchctl', ['load', file]);
268
+ } else {
269
+ enableLinger();
270
+ systemctl(['daemon-reload']);
271
+ systemctl(['enable', label()]);
272
+ systemctl(['restart', label()]);
273
+ }
274
+
275
+ log(`服务已安装并启动(${isLoaded() ? 'running' : 'stopped'})`);
276
+ return serviceStatus();
277
+ }
278
+
279
+ export function uninstallService() {
280
+ const file = serviceFile();
281
+ if (file && fs.existsSync(file)) {
282
+ if (IS_MACOS) {
283
+ run('launchctl', ['unload', file], { allowFailure: true });
284
+ } else if (IS_LINUX) {
285
+ systemctl(['disable', '--now', label()], { allowFailure: true });
286
+ }
287
+ fs.rmSync(file, { force: true });
288
+ if (IS_LINUX) systemctl(['daemon-reload'], { allowFailure: true });
289
+ log('服务已卸载');
290
+ } else {
291
+ log('没有已安装的服务');
292
+ }
293
+ return serviceStatus();
294
+ }
295
+
296
+ export function startService() {
297
+ const file = serviceFile();
298
+ if (!file || !fs.existsSync(file)) throw new Error('尚未安装服务,请先运行 nook service install');
299
+ if (isLoaded()) { log('服务已在运行'); return serviceStatus(); }
300
+ if (IS_MACOS) {
301
+ run('launchctl', ['load', file]);
302
+ } else {
303
+ systemctl(['start', label()]);
304
+ }
305
+ log('服务已启动');
306
+ return serviceStatus();
307
+ }
308
+
309
+ export function stopService() {
310
+ const file = serviceFile();
311
+ if (!file || !fs.existsSync(file)) { log('尚未安装服务'); return serviceStatus(); }
312
+ if (!isLoaded()) { log('服务未在运行'); return serviceStatus(); }
313
+ try {
314
+ if (IS_MACOS) {
315
+ run('launchctl', ['unload', file]);
316
+ } else {
317
+ systemctl(['stop', label()]);
318
+ }
319
+ log('服务已停止');
320
+ } catch (e) {
321
+ logErr(e.message);
322
+ }
323
+ return serviceStatus();
324
+ }
325
+
326
+ export function restartService() {
327
+ const file = serviceFile();
328
+ if (!file || !fs.existsSync(file)) throw new Error('尚未安装服务,请先运行 nook service install');
329
+ if (IS_MACOS) {
330
+ run('launchctl', ['unload', file], { allowFailure: true });
331
+ run('launchctl', ['load', file]);
332
+ } else {
333
+ systemctl(['restart', label()]);
334
+ }
335
+ log('服务已重启');
336
+ return serviceStatus();
337
+ }
@@ -0,0 +1,187 @@
1
+ /**
2
+ * Pi 会话读取。
3
+ *
4
+ * Nook 不发明会话格式,只通过 Pi SDK 的 SessionManager 读写:
5
+ * ~/.pi/agent/sessions/<encoded-cwd>/
6
+ *
7
+ * 新建会话也写入 Pi 默认目录,所以 Pi CLI 的 `pi -r` 也能看到。
8
+ */
9
+ import fs from 'node:fs';
10
+ import { execFileSync } from 'node:child_process';
11
+ import { SessionManager } from '@earendil-works/pi-coding-agent';
12
+ import { logErr } from './logging.js';
13
+ import { stripAnsi } from './text.js';
14
+
15
+ const FEISHU_PREFIX = /^当前用户通过飞书通道以机器人的方式和你沟通,用户的 open_id 是\s*\S+\s*/;
16
+ const TOOL_RESULT_LIMIT = 8000;
17
+
18
+ export function newSessionId() {
19
+ return cryptoRandomUUID();
20
+ }
21
+
22
+ function cryptoRandomUUID() {
23
+ return globalThis.crypto?.randomUUID?.() || `${Date.now()}-${Math.random().toString(16).slice(2)}`;
24
+ }
25
+
26
+ export function cleanUserText(text) {
27
+ return String(text ?? '').replace(FEISHU_PREFIX, '').trim();
28
+ }
29
+
30
+ function textFromContent(content) {
31
+ if (typeof content === 'string') return content;
32
+ if (!Array.isArray(content)) return '';
33
+ return content
34
+ .map((block) => {
35
+ if (!block || typeof block !== 'object') return '';
36
+ if (block.type === 'text') return block.text || '';
37
+ if (block.type === 'image') return '[图片]';
38
+ return '';
39
+ })
40
+ .filter(Boolean)
41
+ .join('\n');
42
+ }
43
+
44
+ export function truncateText(text, limit = TOOL_RESULT_LIMIT) {
45
+ const s = stripAnsi(text);
46
+ if (s.length <= limit) return { text: s, truncated: false, length: s.length };
47
+ return { text: s.slice(0, limit), truncated: true, length: s.length };
48
+ }
49
+
50
+ function titleFrom(firstMessage, name) {
51
+ const named = String(name || '').replace(/\s+/g, ' ').trim();
52
+ if (named) return named;
53
+ const clean = cleanUserText(firstMessage).replace(/\s+/g, ' ').trim();
54
+ return clean || '新会话';
55
+ }
56
+
57
+ function serializeInfo(info) {
58
+ return {
59
+ id: info.id,
60
+ name: info.name || '',
61
+ title: titleFrom(info.firstMessage, info.name),
62
+ firstMessage: cleanUserText(info.firstMessage),
63
+ created: info.created instanceof Date ? info.created.toISOString() : info.created,
64
+ modified: info.modified instanceof Date ? info.modified.toISOString() : info.modified,
65
+ messageCount: info.messageCount || 0,
66
+ path: info.path,
67
+ };
68
+ }
69
+
70
+ /** 列出项目下由 Pi 管理的会话 */
71
+ export async function listSessions(project) {
72
+ try {
73
+ const infos = await SessionManager.list(project.path);
74
+ return infos
75
+ .map(serializeInfo)
76
+ .sort((a, b) => String(b.modified).localeCompare(String(a.modified)));
77
+ } catch (e) {
78
+ logErr('读取 Pi 会话目录失败:', e?.message || e);
79
+ return [];
80
+ }
81
+ }
82
+
83
+ export function isValidSessionId(id) {
84
+ return /^[a-zA-Z0-9_-]{1,120}$/.test(String(id || ''));
85
+ }
86
+
87
+ /** 找到会话文件;找不到返回 null(表示这是一个尚未落盘的新会话) */
88
+ export function findSessionFile(project, sessionId) {
89
+ if (!isValidSessionId(sessionId)) return null;
90
+ try {
91
+ const file = SessionManager.findById(project.path, sessionId);
92
+ if (file) return { path: file };
93
+ } catch { }
94
+ return null;
95
+ }
96
+
97
+ export function openSessionManager(project, sessionId, file = findSessionFile(project, sessionId)) {
98
+ if (file) return SessionManager.open(file.path, undefined, project.path);
99
+ return SessionManager.create(project.path, undefined, { id: sessionId });
100
+ }
101
+
102
+ /** 把 Pi 的 session tree 转成 UI 消息流 */
103
+ export function readTranscript(sm) {
104
+ const entries = sm.buildContextEntries();
105
+ const messages = [];
106
+ const toolIndex = new Map();
107
+
108
+ for (const entry of entries) {
109
+ if (!entry || entry.type !== 'message') continue;
110
+ const msg = entry.message;
111
+ if (!msg || typeof msg !== 'object') continue;
112
+
113
+ if (msg.role === 'user') {
114
+ const text = cleanUserText(textFromContent(msg.content));
115
+ if (!text) continue;
116
+ messages.push({ role: 'user', text, timestamp: msg.timestamp || null });
117
+ continue;
118
+ }
119
+
120
+ if (msg.role === 'assistant') {
121
+ const blocks = [];
122
+ for (const block of Array.isArray(msg.content) ? msg.content : []) {
123
+ if (!block || typeof block !== 'object') continue;
124
+ if (block.type === 'thinking') {
125
+ if (block.thinking && !block.redacted) blocks.push({ type: 'thinking', text: block.thinking });
126
+ } else if (block.type === 'text') {
127
+ if (block.text) blocks.push({ type: 'text', text: block.text });
128
+ } else if (block.type === 'toolCall') {
129
+ const tool = {
130
+ type: 'tool',
131
+ id: block.id || '',
132
+ name: block.name || 'tool',
133
+ args: block.arguments ?? {},
134
+ result: null,
135
+ isError: false,
136
+ resultTruncated: false,
137
+ };
138
+ blocks.push(tool);
139
+ if (tool.id) toolIndex.set(tool.id, tool);
140
+ }
141
+ }
142
+ if (msg.stopReason === 'error' && msg.errorMessage) {
143
+ blocks.push({ type: 'error', text: msg.errorMessage });
144
+ }
145
+ if (blocks.length === 0) continue;
146
+ messages.push({ role: 'assistant', blocks, timestamp: msg.timestamp || null });
147
+ continue;
148
+ }
149
+
150
+ if (msg.role === 'toolResult') {
151
+ const tool = toolIndex.get(msg.toolCallId);
152
+ if (!tool) continue;
153
+ const raw = stripAnsi(textFromContent(msg.content));
154
+ const cut = truncateText(raw);
155
+ tool.result = cut.text;
156
+ tool.resultTruncated = cut.truncated;
157
+ tool.resultLength = cut.length;
158
+ tool.isError = Boolean(msg.isError);
159
+ continue;
160
+ }
161
+
162
+ if (msg.role === 'custom' && msg.display) {
163
+ const text = cleanUserText(textFromContent(msg.content));
164
+ if (text) messages.push({ role: 'assistant', blocks: [{ type: 'text', text }], timestamp: msg.timestamp || null });
165
+ }
166
+ }
167
+
168
+ const name = sm.getSessionName?.() || '';
169
+ const firstUser = messages.find((m) => m.role === 'user')?.text || '';
170
+ return {
171
+ id: sm.getSessionId(),
172
+ name,
173
+ title: titleFrom(firstUser, name),
174
+ cwd: sm.getCwd(),
175
+ messages,
176
+ };
177
+ }
178
+
179
+ export function deleteSessionFile(file) {
180
+ if (!file?.path) return false;
181
+ try {
182
+ execFileSync('trash', [file.path], { stdio: 'ignore', timeout: 5000 });
183
+ return true;
184
+ } catch {
185
+ try { fs.rmSync(file.path, { force: true }); return true; } catch (e) { logErr('删除会话失败:', e?.message || e); return false; }
186
+ }
187
+ }
@@ -0,0 +1,4 @@
1
+ /** 去掉终端 ANSI 控制序列,避免工具结果在 H5 里出现乱码。 */
2
+ export function stripAnsi(text) {
3
+ return String(text ?? '').replace(/\u001b\[[0-?]*[ -/]*[@-~]/g, '');
4
+ }