annotatable-html 1.38.3 → 1.45.3

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,195 @@
1
+ 'use strict';
2
+
3
+ /* OpenCode's documented HTTP session API. No SDK dependency or provider credentials are needed. */
4
+ var http = require('http');
5
+ var https = require('https');
6
+ var runPoll = require('./poll-process.js').runPoll;
7
+
8
+ function request(base, route, method, body, directory, options) {
9
+ var url = new URL(base);
10
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') return Promise.reject(new Error('OpenCode 地址必须使用 http 或 https'));
11
+ if (url.username || url.password) return Promise.reject(new Error('OpenCode 服务密码请放在 OPENCODE_SERVER_PASSWORD 中'));
12
+ url.pathname = url.pathname.replace(/\/$/, '') + route;
13
+ url.search = '';
14
+ url.hash = '';
15
+ var headers = { Accept: 'application/json' };
16
+ if (directory) headers['x-opencode-directory'] = encodeURIComponent(directory);
17
+ if (process.env.OPENCODE_SERVER_PASSWORD) headers.Authorization = 'Basic ' + Buffer.from(
18
+ (process.env.OPENCODE_SERVER_USERNAME || 'opencode') + ':' + process.env.OPENCODE_SERVER_PASSWORD).toString('base64');
19
+ var data = body === undefined ? null : Buffer.from(JSON.stringify(body));
20
+ if (data) { headers['Content-Type'] = 'application/json'; headers['Content-Length'] = data.length; }
21
+ return new Promise(function (resolve, reject) {
22
+ var req = (url.protocol === 'https:' ? https : http).request(url, { method: method, headers: headers, agent: false }, function (res) {
23
+ var chunks = [], size = 0;
24
+ res.on('error', reject);
25
+ res.on('data', function (chunk) {
26
+ size += chunk.length;
27
+ if (size > 1024 * 1024) { req.destroy(new Error('OpenCode 响应过大')); return; }
28
+ chunks.push(chunk);
29
+ });
30
+ res.on('end', function () {
31
+ if (res.statusCode < 200 || res.statusCode >= 300) { reject(new Error('OpenCode HTTP ' + res.statusCode + ':' + route)); return; }
32
+ var text = Buffer.concat(chunks).toString('utf8');
33
+ if (!text) { resolve(null); return; }
34
+ try { resolve(JSON.parse(text)); } catch (_) { reject(new Error('OpenCode 返回了无效 JSON')); }
35
+ });
36
+ });
37
+ req.setTimeout(options && options.timeoutMs || 10000, function () { req.destroy(new Error('OpenCode 请求超时')); });
38
+ req.on('error', reject);
39
+ req.end(data);
40
+ });
41
+ }
42
+
43
+ async function pollAnnotations(htmlPath, opts) {
44
+ opts = opts || {};
45
+ var base = opts.opencodeUrl || process.env.ANNOTATABLE_OPENCODE_SERVER_URL;
46
+ var session = opts.opencodeSession || process.env.ANNOTATABLE_OPENCODE_SESSION_ID;
47
+ var directory = process.env.ANNOTATABLE_OPENCODE_DIRECTORY || process.cwd();
48
+ if (!base || !session) throw new Error('缺少 OpenCode 会话信息:新开已安装上下文插件的会话,或指定 --opencode-url 和 --opencode-session');
49
+ var route = '/session/' + encodeURIComponent(session);
50
+ var info = await request(base, route, 'GET', undefined, directory);
51
+ if (!info || info.id !== session) throw new Error('OpenCode 返回的会话与目标不符');
52
+ var result = await runPoll(htmlPath, opts);
53
+ if (result.hit) {
54
+ await request(base, route + '/prompt_async', 'POST', { parts: [{ type: 'text', synthetic: true,
55
+ text: '[annotatable-html 监听事件]\nPOLL_HIT ' + JSON.stringify(result.hit) +
56
+ '\n按批注工作流读取完整对话并处理。完成后重新挂同一文件的 poll --opencode。'
57
+ }] }, directory);
58
+ console.log('OPENCODE_NOTIFIED ' + JSON.stringify({ sessionId: session, file: result.hit.file }));
59
+ }
60
+ process.exitCode = result.code;
61
+ }
62
+
63
+ async function dutySettings(base, id, directory) {
64
+ var session = await request(base, '/session/' + encodeURIComponent(id), 'GET', undefined, directory);
65
+ if (!session || session.id !== id || !session.directory) throw new Error('OpenCode 返回其他会话或缺少目录');
66
+ var cwd = session.directory;
67
+ var config = await request(base, '/config', 'GET', undefined, cwd);
68
+ var messages = await request(base, '/session/' + encodeURIComponent(id) + '/message', 'GET', undefined, cwd);
69
+ var last = messages.slice().reverse().find(m => m.info && m.info.role === 'user' && m.info.model);
70
+ if (!last || !last.info.model.providerID || !last.info.model.modelID) throw new Error('OpenCode 会话缺少已选模型;先在原会话完成一轮');
71
+ var user = last.info, agent = user.agent || 'build';
72
+ var provider = config.provider && config.provider[user.model.providerID] || {};
73
+ var baseURL = provider.options && provider.options.baseURL || null;
74
+ if (baseURL) { var endpoint = new URL(baseURL); if (endpoint.username || endpoint.password || endpoint.search || endpoint.hash) throw new Error('OpenCode 提供方地址不能含内嵌凭据或查询参数'); }
75
+ return { cwd: cwd, model: user.model, provider: { npm: provider.npm || null, baseURL: baseURL }, agent: agent, variant: user.variant || null, tools: user.tools || null,
76
+ permission: config.permission || {}, sessionPermission: session.permission || null,
77
+ agentPermission: config.agent && config.agent[agent] && config.agent[agent].permission || {} };
78
+ }
79
+ async function dutyBinding(file, options) {
80
+ var processes = require('./duty-process');
81
+ var base = options.opencodeUrl || process.env.ANNOTATABLE_OPENCODE_SERVER_URL;
82
+ var id = options.opencodeSession || process.env.ANNOTATABLE_OPENCODE_SESSION_ID;
83
+ if (!base || !/^ses_[a-z0-9]+$/i.test(id || '')) throw new Error('OpenCode 续接需要原服务地址和会话编号');
84
+ var url = new URL(base);
85
+ if (url.protocol !== 'http:' || !['127.0.0.1', 'localhost'].includes(url.hostname) || url.username || url.password) throw new Error('自动续接只支持无内嵌密码的本机 OpenCode HTTP 服务');
86
+ var owner = await processes.portOwner(Number(url.port || 80));
87
+ if (!/^opencode(?:\.exe)?$/i.test(owner.Name)) throw new Error('无法确认 OpenCode 服务进程');
88
+ var settings = await dutySettings(base, id, options.opencodeDirectory || process.env.ANNOTATABLE_OPENCODE_DIRECTORY);
89
+ var paths = await request(base, '/path', 'GET');
90
+ return { threadId: id, binary: options.opencodeBin || owner.ExecutablePath, settings: settings,
91
+ opencode: { url: url.origin, owners: [processes.identity(owner)], serverCwd: paths.directory,
92
+ paths: { home: paths.home, config: paths.config, state: paths.state },
93
+ files: processes.configStamps(settings.cwd, ['opencode.json', 'opencode.jsonc', '.opencode/opencode.json', '.opencode/opencode.jsonc'],
94
+ [require('path').join(paths.config, 'opencode.json'), require('path').join(paths.config, 'opencode.jsonc')].concat(process.env.OPENCODE_CONFIG ? [require('path').resolve(paths.directory, process.env.OPENCODE_CONFIG)] : [])) } };
95
+ }
96
+ function createDutyAdapter(getBinding, updateBinding) {
97
+ var processes = require('./duty-process'), duty = require('./duty');
98
+ async function inspect(base, process) {
99
+ var b = getBinding();
100
+ if (!process || duty.canonicalPath(process.ExecutablePath) !== duty.canonicalPath(b.binary)) return null;
101
+ try {
102
+ var paths = await request(base, '/path', 'GET', undefined, undefined, { timeoutMs: 1000 });
103
+ if (duty.canonicalPath(paths.directory) !== duty.canonicalPath(b.opencode.serverCwd)) return null;
104
+ var s = await request(base, '/session/' + b.threadId, 'GET', undefined, b.settings.cwd, { timeoutMs: 1000 });
105
+ if (s && s.id === b.threadId && duty.canonicalPath(s.directory) === duty.canonicalPath(b.settings.cwd)) return { url: base, process: process };
106
+ } catch (_) {}
107
+ return null;
108
+ }
109
+ async function candidates(excludePid) {
110
+ var b = getBinding(), found = [], listeners = await processes.listeners(b.binary);
111
+ for (var listener of (Array.isArray(listeners) ? listeners : listeners ? [listeners] : [])) {
112
+ if (listener.process.ProcessId === excludePid) continue;
113
+ for (var port of listener.ports) {
114
+ var value = await inspect('http://127.0.0.1:' + port, listener.process); if (value) found.push(value);
115
+ }
116
+ }
117
+ return found;
118
+ }
119
+ async function online() {
120
+ var b = getBinding(), owner = await processes.processInfo(b.opencode.owners[0].ProcessId), selected;
121
+ if (processes.sameProcess(b.opencode.owners[0], owner)) selected = await inspect(b.opencode.url, owner);
122
+ else { var found = await candidates(); if (found.length === 1) selected = found[0]; }
123
+ if (!selected) return null;
124
+ if (!processes.sameProcess(b.opencode.owners[0], selected.process)) updateBinding(Object.assign({}, b,
125
+ { opencode: Object.assign({}, b.opencode, { url: selected.url, owners: [processes.identity(selected.process)] }) }));
126
+ var states = await request(selected.url, '/session/status', 'GET', undefined, b.settings.cwd);
127
+ return { available: true, active: !!(states[b.threadId] && states[b.threadId].type !== 'idle'), notify: hit =>
128
+ request(selected.url, '/session/' + b.threadId + '/prompt_async', 'POST', { parts: [{ type: 'text', synthetic: true, text: duty.prompt(b, hit) }] }, b.settings.cwd) };
129
+ }
130
+ async function released(excludePid) {
131
+ if (!(await processes.ownersExited(getBinding().opencode.owners)).released) return { released: false, reason: 'opencode-server-process-alive' };
132
+ if ((await candidates(excludePid)).length) return { released: false, reason: 'opencode-session-server-returned' };
133
+ return { released: true, reason: 'opencode-session-server-exited' };
134
+ }
135
+ async function run(hit, onStart) {
136
+ var b = getBinding(), cp = require('child_process');
137
+ if (b.opencode.files && !require('util').isDeepStrictEqual(b.opencode.files.map(x => processes.fileStamp(x.file)), b.opencode.files)) throw new Error('OpenCode 配置已改变,请重新注册');
138
+ if (!(await released()).released) throw new Error('OpenCode 原服务尚未退出');
139
+ var url = new URL(b.opencode.url), child = cp.spawn(b.binary, ['serve', '--port', url.port || '80', '--hostname', '127.0.0.1'],
140
+ { cwd: b.opencode.serverCwd, env: processes.agentEnv(), windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] });
141
+ child.stdout.resume(); child.stderr.resume();
142
+ var record = { host: 'opencode', threadId: b.threadId, pid: child.pid, startedAt: Date.now() }, exited = false;
143
+ var closed = new Promise(resolve => child.once('close', code => { exited = true; resolve(code); }));
144
+ var spawnError; child.once('error', error => { spawnError = error; });
145
+ if (onStart) onStart(record);
146
+ var base = url.origin, route = '/session/' + b.threadId;
147
+ var api = (p, method, body) => request(base, p, method || 'GET', body, b.settings.cwd);
148
+ try {
149
+ var until = Date.now() + 30000;
150
+ for (;;) {
151
+ if (spawnError) throw spawnError;
152
+ if (exited) throw new Error('OpenCode 无头服务启动失败,保留待办');
153
+ try { var health = await api('/global/health'); if (health.healthy) break; } catch (_) {}
154
+ if (Date.now() > until) throw new Error('OpenCode 无头服务未确认启动');
155
+ await new Promise(resolve => setTimeout(resolve, 300));
156
+ }
157
+ var owner = await processes.portOwner(Number(url.port || 80));
158
+ if (owner.ProcessId !== child.pid) throw new Error('服务端口属于其他进程,不接管');
159
+ var settings = await dutySettings(base, b.threadId, b.settings.cwd), paths = await request(base, '/path', 'GET');
160
+ if (JSON.stringify(settings) !== JSON.stringify(b.settings) ||
161
+ JSON.stringify({ home: paths.home, config: paths.config, state: paths.state }) !== JSON.stringify(b.opencode.paths)) throw new Error('OpenCode 模型、权限或存储位置已改变,请重新注册');
162
+ record.restored = settings;
163
+ if (!(await released(child.pid)).released) throw new Error('OpenCode 原会话已重开,暂停无头执行');
164
+ var before = new Set((await api(route + '/message')).map(m => m.info.id));
165
+ await api(route + '/prompt_async', 'POST', { model: settings.model, agent: settings.agent, variant: settings.variant || undefined,
166
+ tools: settings.tools || undefined, parts: [{ type: 'text', synthetic: true, text: duty.prompt(b, hit, { headless: true }) }] });
167
+ until = Date.now() + 300000;
168
+ for (;;) {
169
+ for (var kind of ['permission', 'question']) {
170
+ var requests = await api('/' + kind);
171
+ for (var item of requests.filter(x => x.sessionID === b.threadId)) {
172
+ await api('/' + kind + '/' + item.id + (kind === 'permission' ? '/reply' : '/reject'), 'POST', kind === 'permission' ? { reply: 'reject' } : {});
173
+ throw new Error('OpenCode 需要人工审批或补充信息,保留待办');
174
+ }
175
+ }
176
+ var messages = await api(route + '/message'), fresh = messages.filter(m => !before.has(m.info.id) && m.info.role === 'assistant');
177
+ var failed = fresh.find(m => m.info.error);
178
+ if (failed) throw new Error('OpenCode 模型返回 ' + failed.info.error.name);
179
+ var states = await api('/session/status'), last = fresh[fresh.length - 1];
180
+ if (states[b.threadId] && states[b.threadId].action && states[b.threadId].action.reason === 'free_tier_limit') throw new Error('原 OpenCode 模型额度已耗尽,保留待办');
181
+ if (last && last.info.time.completed && (!states[b.threadId] || states[b.threadId].type === 'idle')) {
182
+ record.turnId = last.info.id; record.outcome = 'completed'; return record;
183
+ }
184
+ if (Date.now() > until) throw new Error('OpenCode 回复超时,保留待办');
185
+ await new Promise(resolve => setTimeout(resolve, 1000));
186
+ }
187
+ } catch (error) {
188
+ try { if (!exited) await api(route + '/abort', 'POST'); } catch (_) {}
189
+ record.outcome = 'failed'; record.error = error.message; throw Object.assign(error, { dutyRun: record });
190
+ } finally { if (!exited) child.kill(); await closed; record.finishedAt = Date.now(); }
191
+ }
192
+ return { online: online, released: released, run: run, close: () => {} };
193
+ }
194
+
195
+ module.exports = { request: request, pollAnnotations: pollAnnotations, dutySettings: dutySettings, dutyBinding: dutyBinding, createDutyAdapter: createDutyAdapter };
@@ -0,0 +1,42 @@
1
+ 'use strict';
2
+
3
+ /* Run the existing single-use poll and capture only its structured hit.
4
+ * Shared by optional host notification transports; the core polling rules stay unchanged. */
5
+ var path = require('path');
6
+ var cp = require('child_process');
7
+ var readline = require('readline');
8
+
9
+ function runPoll(htmlPath, opts) {
10
+ opts = opts || {};
11
+ var argv = [path.join(__dirname, '..', 'agent', 'annot_poll.js'), path.resolve(htmlPath)];
12
+ [['--timeout', opts.timeout], ['--interval', opts.interval], ['--exit-code', opts.exitCode],
13
+ ['--exit-code-timeout', opts.timeoutExitCode]].forEach(function (item) {
14
+ if (item[1] !== undefined) argv.push(item[0], String(item[1]));
15
+ });
16
+ return new Promise(function (resolve, reject) {
17
+ var hit;
18
+ var child = cp.spawn(process.execPath, argv, { windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] });
19
+ var stop = function () { child.kill(); };
20
+ process.once('SIGINT', stop);
21
+ process.once('SIGTERM', stop);
22
+ child.stdout.pipe(process.stdout);
23
+ child.stderr.pipe(process.stderr);
24
+ var lines = readline.createInterface({ input: child.stdout });
25
+ lines.on('line', function (line) {
26
+ if (line.indexOf('POLL_HIT ') !== 0) return;
27
+ try {
28
+ var payload = JSON.parse(line.slice(9));
29
+ if (path.resolve(payload.file) === path.resolve(htmlPath)) hit = payload;
30
+ } catch (_) { /* Ignore unstructured output. */ }
31
+ });
32
+ child.once('error', reject);
33
+ child.once('close', function (code) {
34
+ lines.close();
35
+ process.removeListener('SIGINT', stop);
36
+ process.removeListener('SIGTERM', stop);
37
+ resolve({ code: code === null ? 1 : code, hit: hit });
38
+ });
39
+ });
40
+ }
41
+
42
+ module.exports = { runPoll: runPoll };
package/lib/serve.js CHANGED
@@ -58,6 +58,7 @@ function probeSelf(port, cb) {
58
58
 
59
59
  function start(root, port, opts) {
60
60
  root = path.resolve(root || process.cwd());
61
+ try { core.syncKitCode(root, path.join(__dirname, '..')); } catch (e) { /* kit 同步尽力而为 */ } // v1.38.4 值守开场必经 serve——顺手把文档侧过期 kit 拷贝拉齐(issue#7 根因)
61
62
  if (port === undefined || port === null || port === '' || isNaN(port)) port = 8973; // --port 0 = 系统挑空闲端口,是合法值别当缺省吞掉
62
63
  var studioProvider = opts && typeof opts.studioProvider === 'function' ? opts.studioProvider : null;
63
64
 
@@ -78,6 +79,25 @@ function start(root, port, opts) {
78
79
  /* 心跳探测:页面据此判断处于服务器模式 */
79
80
  if (req.method === 'POST' && u === '/__annot/ping') { res.writeHead(200); res.end('pong'); return; }
80
81
 
82
+ // A new local agent can resolve one user-specified page without a global duty list.
83
+ if (req.method === 'GET' && u === '/__annot/context') {
84
+ var contextPath;
85
+ try { contextPath = new URL('http://x' + req.url).searchParams.get('path') || ''; }
86
+ catch (contextError) { res.writeHead(400); res.end('bad path'); return; }
87
+ var contextFile = contextPath.charAt(0) === '/' && safeServePath(contextPath);
88
+ if (!contextFile || !/\.html?$/i.test(contextFile)) { res.writeHead(403); res.end('forbidden'); return; }
89
+ fs.readFile(contextFile, 'utf8', function (err, html) {
90
+ if (err) { res.writeHead(404); res.end('not found'); return; }
91
+ var contextStore;
92
+ try { contextStore = core.readStore(html); } catch (parseError) { /* invalid document */ }
93
+ if (!contextStore) { res.writeHead(422); res.end('not an annotatable document'); return; }
94
+ res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
95
+ res.end(JSON.stringify({ service: 'annotatable-html', version: core.pkgVersion(path.join(__dirname, '..')),
96
+ root: root, file: contextFile, docId: core.docIdOf(html) }));
97
+ });
98
+ return;
99
+ }
100
+
81
101
  /* live-sync 探测(v1.21.0):页面轮询本端点拿 rev/mtime,文件变了才热载,
82
102
  免去每秒全文拉取。路径先过 safeServePath 边界校验,再限 .html。 */
83
103
  if (req.method === 'GET' && u === '/__annot/rev') {
@@ -254,14 +274,16 @@ function start(root, port, opts) {
254
274
 
255
275
  /* v1.26.1:一个坏请求/边角 bug 不再打死整个 serve(值守生命线)——记日志继续伺服。
256
276
  代价:出异常的那条响应可能挂到浏览器超时重试,可接受;无此守卫时进程直接退出,全部自动保存失联。 */
257
- process.on('uncaughtException', function (err) {
277
+ if (!(opts && opts.managed)) process.on('uncaughtException', function (err) {
258
278
  console.error('[' + new Date().toISOString() + '] serve 吞掉未捕获异常(继续伺服): ' + ((err && err.stack) || err));
259
279
  });
260
280
 
261
281
  /* v1.27.1:绑定失败必须说实话。此前 EADDRINUSE 没挂 error 监听,异常落进上面的
262
282
  uncaughtException 兜底、嘴上打印「继续伺服」,实际服务器从未 bind 成功、事件循环
263
283
  清空后进程静默退出——值守以为活着,自动保存全失联。现如实报错并探测占用者身份后退出。 */
284
+ var rejectStart;
264
285
  server.on('error', function (e) {
286
+ if (opts && opts.managed) { if (rejectStart) rejectStart(e); return; }
265
287
  var code = (e && e.code) || 'UNKNOWN';
266
288
  if (code !== 'EADDRINUSE') {
267
289
  console.error('❌ serve 端口监听失败(' + code + '):' + ((e && e.message) || e));
@@ -282,10 +304,13 @@ function start(root, port, opts) {
282
304
  });
283
305
  });
284
306
 
285
- return new Promise(function (resolve) {
307
+ return new Promise(function (resolve, reject) {
308
+ rejectStart = reject;
286
309
  server.listen(port, '127.0.0.1', function () {
287
310
  var actual = (server.address() && server.address().port) || port; // --port 0 时以系统实配为准
288
- resolve({ url: 'http://localhost:' + actual, root: root, port: actual });
311
+ var info = { url: 'http://localhost:' + actual, root: root, port: actual };
312
+ if (opts && opts.managed) info.close = function () { return new Promise(function (done) { server.close(done); }); };
313
+ resolve(info);
289
314
  });
290
315
  });
291
316
  }
package/package.json CHANGED
@@ -1,50 +1,51 @@
1
- {
2
- "name": "annotatable-html",
3
- "version": "1.38.3",
4
- "description": "可批注 HTML:人类在浏览器划线批注,AI agent 读批注并回答(对话追问 + 版本回滚)。一条命令给任意 agent 装上批注工作流。Annotatable HTML docs with an AI-agent reply loop, threads and version history.",
5
- "bin": {
6
- "annotatable": "bin/cli.js"
7
- },
8
- "main": "lib/core.js",
9
- "files": [
10
- "bin",
11
- "lib",
12
- "agent",
13
- "templates",
14
- "README.md",
15
- "LICENSE"
16
- ],
17
- "scripts": {
18
- "test": "node test/run.js"
19
- },
20
- "keywords": [
21
- "annotation",
22
- "annotator",
23
- "highlight",
24
- "html",
25
- "ai",
26
- "agent",
27
- "llm",
28
- "code-review",
29
- "workflow",
30
- "批注",
31
- "划词",
32
- "标注"
33
- ],
34
- "author": "山居岁月",
35
- "license": "MIT",
36
- "engines": {
37
- "node": ">=14"
38
- },
39
- "devDependencies": {
40
- "gpt-tokenizer": "^4.0.0"
41
- },
42
- "repository": {
43
- "type": "git",
44
- "url": "git+https://github.com/Alidadei/HTML4agent.git"
45
- },
46
- "homepage": "https://github.com/Alidadei/HTML4agent#readme",
47
- "bugs": {
48
- "url": "https://github.com/Alidadei/HTML4agent/issues"
49
- }
50
- }
1
+ {
2
+ "name": "annotatable-html",
3
+ "version": "1.45.3",
4
+ "description": "可批注 HTML:人类在浏览器划线批注,AI agent 读批注并回答(对话追问 + 版本回滚)。一条命令给任意 agent 装上批注工作流。Annotatable HTML docs with an AI-agent reply loop, threads and version history.",
5
+ "bin": {
6
+ "annotatable": "bin/cli.js"
7
+ },
8
+ "main": "lib/core.js",
9
+ "files": [
10
+ "bin",
11
+ "lib",
12
+ "agent",
13
+ "templates",
14
+ "README.md",
15
+ "LICENSE"
16
+ ],
17
+ "scripts": {
18
+ "test": "node test/run.js",
19
+ "test:hosts": "node tests/codex-duty.test.cjs && node tests/duty-hosts.test.cjs && node tests/cli-reply.test.cjs && node tests/codex-duty-e2e.test.cjs && node tests/codex-desktop.test.cjs && node tests/codex-notify.test.cjs && node tests/opencode-notify.test.cjs && node tests/page-takeover.test.cjs"
20
+ },
21
+ "keywords": [
22
+ "annotation",
23
+ "annotator",
24
+ "highlight",
25
+ "html",
26
+ "ai",
27
+ "agent",
28
+ "llm",
29
+ "code-review",
30
+ "workflow",
31
+ "批注",
32
+ "划词",
33
+ "标注"
34
+ ],
35
+ "author": "山居岁月",
36
+ "license": "MIT",
37
+ "engines": {
38
+ "node": ">=14"
39
+ },
40
+ "devDependencies": {
41
+ "gpt-tokenizer": "^4.0.0"
42
+ },
43
+ "repository": {
44
+ "type": "git",
45
+ "url": "git+https://github.com/Alidadei/HTML4agent.git"
46
+ },
47
+ "homepage": "https://github.com/Alidadei/HTML4agent#readme",
48
+ "bugs": {
49
+ "url": "https://github.com/Alidadei/HTML4agent/issues"
50
+ }
51
+ }
@@ -1,99 +1,53 @@
1
1
  ---
2
2
  name: annotatable-html
3
- description: 可批注 HTML 工作流(配合 /html4agent)。仅当用户提到「可批注」「划线批注」「读批注」「已批」或要求批注相关操作时使用;普通 HTML 生成请求不适用。
3
+ description: 可批注 HTML 工作流(配合 /html4agent)。用户要求生成可批注 HTML、加批注层、读批注、恢复值守或接管指定 localhost 批注页面时使用;普通 HTML 生成请求不适用。
4
4
  ---
5
5
 
6
- # 可批注 HTML 工作流
6
+ # 可批注 HTML
7
7
 
8
- 人类在浏览器里对 HTML 划线批注,你(agent)值守监听批注、流式回答并写回文件。
8
+ 管理用户指定文档的批注、回复和正文修改。内容教学由独立工作流决定;本工具不按“教我、入门”等关键词切换教学模式,不默认生成教学卡或课程。
9
+ 不要向项目或全局 AGENTS.md 写入批注规则、值守状态或悬单清单。
9
10
 
10
- ## 生成可批注文档(仅限从零写新文档)
11
+ ## 选择当前任务
11
12
 
12
- 用户明确要求可批注文档、且没有现成 HTML 时(通常经 /html4agent 触发):
13
+ - 从零生成:`annotatable new 名称.html`,把正文写进各 section,不改批注脚本或数据区块。
14
+ - Markdown 转换:`annotatable md 文件.md`,保留原文内容。
15
+ - 已有 HTML 加批注:`annotatable inject 文件.html`。保留原内容、结构和样式,不用 new 重建。
16
+ - 读批注或接管页面:`annotatable read 文件路径或localhost页面地址`。已有文档不重新生成。
17
+ - 需要目录内工具或主题时才运行 `annotatable init`;它不安装工作区指令文件。
13
18
 
14
- npx annotatable-html init # 目录未初始化时先执行(已存在自动跳过)
15
- npx annotatable-html new 名称.html
19
+ ## 交付即值守
16
20
 
17
- 把内容写进生成文件的各 section;**不要改动**批注层脚本与 annotations-store 区块。
18
- 已有 HTML 成品(有既定版式的页面,尤其 PPT 复刻页)**不走本节**——用下方 inject,禁止 new 重建。
21
+ 用户明确要求宿主退出后继续处理时,按 [监听说明](references/hosts.md) 使用 duty。它管理保存服务和监听,同页不再另挂 serve 或 poll。其他情况使用下面的 poll 流程。
19
22
 
20
- ## 交付即值守(默认交付方式)
23
+ 1. 起后台服务:`annotatable serve 文档目录 --port 0`。读取它返回的实际地址并打开页面。已有可用服务时复用。
24
+ 2. 在当前会话挂后台 poll,保持工具调用及时返回。按当前宿主读取 [监听说明](references/hosts.md)。
25
+ 3. 给用户页面地址。serve 模式自动保存,批完无需回聊天框喊“已批”。
21
26
 
22
- 文档写完后,不要让用户「打开文件→绑定→批完说已批」。直接做三件事:
27
+ ## 恢复指定页面
23
28
 
24
- 1. 起伴随服务器(后台任务,会话期间保持运行):
25
- `npx annotatable-html serve 文档所在目录 --port 8980`
26
- 2. 有浏览器操作能力就自己打开 `http://localhost:8980/文档名.html`;没有就把 URL 给用户。服务器模式下批注**零绑定、零授权**、自动保存;
27
- 3. 挂监听(后台任务):`npx annotatable-html poll 文档.html`。有新批注、新追问时进程打印 `POLL_HIT <json>` 并以退出码 0 结束,harness 会唤醒你。
29
+ 用户给 localhost 地址时,先 `annotatable read 地址`。新版服务会返回对应的本地文档信息;记录输出中的实际文件路径。
30
+ 复用该页面的服务,在新会话重新挂 poll。若该页有运行中的 duty,接管前先查状态、停止旧控制器并等待其无头轮次结束。已有待回应批注会立即命中,无需等用户再次保存。
31
+ 旧服务缺少定位接口时,按报错更新并重启该服务;服务未运行或文档无访问权限时如实说明。
32
+ 只接管用户指定的页面,不扫描全局悬单。
28
33
 
29
- 告知用户:「打开这个地址直接划线批注,我在值守,批完不用喊我。」
34
+ ## 值守循环
30
35
 
31
- ## 值守循环(POLL_HIT 唤醒后)
36
+ 1. 收到 POLL_HIT 后运行 `annotatable read 文档`,读待回应批注的完整对话。纯高亮不答。
37
+ 2. 疑问或求解释直接回复;明确要求改正文时,按 [正文修改说明](references/operations.md) 使用 edit。
38
+ 3. 续接通知要求 UTF-8 JSON 文件时,按通知运行 reply 并回读核对;其他情况用流式命令,不按长度改成教学卡或外部交付文档:
32
39
 
33
- 1. `npx annotatable-html read 文档.html` 列出待回应批注(含完整对话线)。只处理 status:"new" 的条目;通读每条 thread 全部轮次再回答;只答所问、引用少量上下文、不扩写。
34
- 2. 批语是**疑问句/求讲解** → 流式回答(见下节)。批语是**祈使句**(「这里改成X」「删掉这句」)→ 走下方 edit 流程,不流式。
35
- 3. 一轮答完,**重新挂 poll**(后台任务),继续值守。除非用户喊停,值守持续整个会话。
40
+ ```sh
41
+ annotatable reply 文档 --stream '第一段' --id 批注id
42
+ annotatable reply 文档 --stream '后续段'
43
+ annotatable reply 文档 --stream-end '完整回答'
44
+ ```
36
45
 
37
- ## 流式回答(一边批注一边出答案)
46
+ 4. 退出码 4(用户停止)或 5(目标已删)时停下本轮;6(用户编辑中)时等其收笔;7 表示没有打开的流式轮,需要带 --id 开始。
47
+ 5. poll 模式答完重新后台挂同一命令。duty 模式按通知续接;Claude 当前进程可能需由 agent 续挂 --listen,重开原会话后由项目钩子自动接回,无需用户操作。不重新注册已停止的控制器。
38
48
 
39
- 回答不攒整段。每想好一段就追加一次,人类在页面上实时看到打字中的回答:
49
+ ## 写回边界
40
50
 
41
- npx annotatable-html reply 文档.html --stream '第一段…' --id 批注id # 开始(必带 --id)
42
- npx annotatable-html reply 文档.html --stream '第二段…' # 续段(自动接最近的流式轮)
43
- npx annotatable-html reply 文档.html --stream-end '完整全文' # 收尾(全文替换,落快照)
44
-
45
- 退出码即指令:
46
-
47
- - **4 = 人类按了页面上的 ⏹ 停止**:本轮作答就地终止。不要重试、不要续写。人类可能在准备追问。
48
- - **5 = 目标批注已被删除(或没有进行中的流式轮)**:同样终止本轮。
49
-
50
- 人类随时可以:划新的线、删旧批注、页内追问——下一次轮询都会唤醒你。若被 ⏹ 打断后收到新批注,按新批注重新作答。
51
-
52
- ## Markdown 一键转可批注
53
-
54
- 用户给 .md 文件(或说「把 Markdown 做成可批注版」)时:
55
-
56
- npx annotatable-html md 文件.md # 生成同名 .html;-o 改输出路径,--title 改标题
57
-
58
- 正文完全遵照原文(GFM 表格/代码围栏/任务列表/删除线照常),只叠加批注层;转换后按「交付即值守」走。LaTeX 公式(`$...$` 行内、`$$...$$` 独立式)在生成时编译为 MathML 烤进页面,浏览器原生渲染、产物零依赖;编译失败的单条保留 LaTeX 原文,代码块内的 `$` 一律不转。
59
-
60
- ## 给已有 HTML 加批注层
61
-
62
- 用户对任何 HTML(其他工具产出亦可)说「加批注层 / 做成可批注版 / 变成可批注的」时:
63
-
64
- npx annotatable-html inject 文件.html
65
-
66
- 自动备份原文件到同目录 .annotatable/(*.pre-inject.bak);已含批注层的文件会拒绝重复注入。
67
- **最小注入铁律**:原文件的内容、结构、样式一字不动,不加目录/导航/任何排版元素——批注层只贴上去。
68
-
69
- ## 长答/教学卡
70
-
71
- 批语含「教我/入门/展开讲/系统性讲」→ 不走流式,默认写页内教学卡:
72
- `npx annotatable-html answer 文档.html answers.json`(json=[{annId,title,rungs:[{title,body,checkpoint?,answer?}]}],不带 json 参数=列既有卡;「继续」时全量带上旧阶重发即整卡更新),reply 只留摘要。教学任务超过一张卡时 `npx annotatable-html teach 源文档.html` 生成教学文档脚手架(每节预置任务批注),用 edit 逐节填充。用户点名「归档成 md」或「暂存/沉淀」时才按 ANSWER 工作流写 answers/*.md。
73
-
74
- ## 批语是修改指令时:改正文
75
-
76
- 批语为祈使句(「这里改成X」「删掉这句」)→ 生成 edits.json:[{"id":"批注id","newText":"改后正文","reply":"可选"}],然后:
77
-
78
- npx annotatable-html edit 文档.html edits.json
79
-
80
- (自动定位替换正文 + 重算该批注锚定 + 快照 + rev+1;混合批注用 reply 字段一并回答。禁止手工改正文。)
81
-
82
- ## 图纸/生成器维护的页面:重建只走 regen
83
-
84
- 页面由生成器脚本(图纸)产出、又被人手/编辑模式改过时:**纯文字改动直接 edit 或小改,禁止整页重生成**。必须重生成(改图/大改版)时只准:
85
-
86
- npx annotatable-html regen 活页.html 图纸直出.html # 报表:列出盘上手改清单,不动盘
87
- npx annotatable-html regen 活页.html 图纸直出.html --go # 上盘:store/DOC_ID 原样保留
88
-
89
- 报表列出的手改必须先同步进图纸、重出 fresh 再 --go;确要丢弃手改才 --force(留痕)。人类编辑模式中(wordLock 旗鲜活)regen 退出码 3、reply/answer 退出码 6——一律让路等收笔,绝不抢写。
90
-
91
- ## 无法起 serve 时(file:// 兜底)
92
-
93
- 起不了服务器(如纯终端环境)才让用户双击文件用 Edge/Chrome 打开。此时批注写回靠浏览器文件授权:同文件夹绑过一次后,其余文档自动互联(新文件打开时点页面任意位置授权即可)。其余流程(poll/流式回答)不变。
94
-
95
- ## 铁律
96
-
97
- - 每次写回必须 rev+1 并维护 history 快照(工具已封装;手工改则务必遵守),否则浏览器端会检测到冲突拒绝覆盖;
98
- - 流式回答被打断(退出码 4/5)后立即停手;收尾(--stream-end)前若批注被删,不要补写;
99
- - 目录已初始化时以目录内 AGENTS.md 为准(本文件是其触发壳)。
51
+ 批注回复走 reply,正文修改走 edit,生成器重建走 regen。工具维护版本和历史;不手改 store JSON。
52
+ 用户明确要导出、归档或调用独立内容工作流时,按其要求处理;本技能不决定教学方法、检查点或学习进度。
53
+ 只在需要处理旧 answers-store 数据时读取 [兼容说明](references/compatibility.md)。
@@ -224,7 +224,7 @@
224
224
  }
225
225
  #annot-fab .badge{
226
226
  position:absolute;top:-4px;right:-4px;min-width:20px;height:20px;border-radius:10px;
227
- background:var(--danger);color:#fff;font-size:12px;line-height:20px;padding:0 5px;font-weight:600;
227
+ background:var(--danger,#b05050);color:#fff;font-size:12px;line-height:20px;padding:0 5px;font-weight:600;
228
228
  }
229
229
  #annot-panel{
230
230
  position:fixed;top:0;right:0;height:100vh;width:340px;max-width:92vw;z-index:115;
@@ -782,17 +782,13 @@
782
782
  </div>
783
783
  </section>
784
784
 
785
- <p class="foot">可批注文档模板(research-html 暖白简化风格)· 配套 AGENTS.md 与 agent/ 工具使用 · 批注数据在文末 annotations-store 区块</p>
785
+ <p class="foot">可批注文档模板(research-html 暖白简化风格)· 配套 annotatable-html skill 与批注工具使用 · 批注数据在文末 annotations-store 区块</p>
786
786
  </article>
787
787
  </div>
788
788
 
789
- <!-- ═══ AI批注数据 BEGIN — agent 回答协议(本区块是唯一需要读取的部分):
790
- ① 只处理 status:"new" 的批注;thread 数组是完整对话线(role: user/ai),回答前必须通读整条对话语境,只答所问、引用少量上下文、不扩写;
791
- ② 回答格式:短回答(≤5行)直接作为回复内容;长回答写入本文件同目录 answers/{项目名}_ANSWER__{主题}__{agent}.md(暂存区+本轮回答区;同主题复用不新建),回复内容放 1~3 行摘要 + 该 md 相对路径;
792
- ③ 写回方式:优先用固定工具 annot_reply.js(自动追加 ai 轮次、维护 history 快照、rev+1);reply 字段是最新 AI 轮次的镜像;
793
- ④ 批语含「暂存」或「留着」→ 将划线范围原文整理后写入该 ANSWER.md 暂存区;含「沉淀」或「沉淀到:文件名」→ 整理为可复用知识写入 docs/Learn/(或指定文件);
794
- ⑤ history 数组是版本账本:v1.10.0 起文件内只留骨架(rev/time/op/n,近期 60 条,可在历史面板⚙调整、随 store 的 histCaps 字段落盘,agent 工具会按同一数量裁剪;edit 条目附 bodyOps 纸条),全量快照账本在文外 .annotatable/<文件名>.hist.json(工具自动维护,含 DOC_ID 归属校验)。用户可在页面"版本历史"中回滚任意版本(含正文)。agent 通过 annot_reply.js / annot_edit.js 正常追加即可,不要手工改写 history 结构;
795
- ⑥ 值守与流式(v1.21.0):值守时用 `annotatable reply 文档.html --stream '增量段落' --id 批注id` 逐段追加(页面实时渲染打字中的回答),写完用 `--stream-end '全文'` 收尾;不带 --id 的续段自动接「最近开始且尚未收尾」的流式轮,绝不串到已收尾的批注(v1.37.3)。退出码 4=人类按了⏹(立即停止作答),5=批注已被删除(同样停止),7=没有打开中的流式轮(带 --id 开始新轮)。ai 轮次带 streaming:true 表示回答进行中;顶层 control.stop 是人类经页面按钮写下的打断请求,工具会自动消费,不要手工改。本轮答完重新挂 `annotatable poll` 继续值守 ═══ -->
789
+ <!-- ═══ AI批注数据 BEGIN — annotatable-html skill 的数据位置:
790
+ 使用 annotatable read/reply/edit/poll;工具维护版本与历史,不手改 store JSON。
791
+ 工作流由 skill 提供;回答内容与教学方法由用户选择的独立内容工作流决定 ═══ -->
796
792
  <script id="annotations-store" type="application/json">
797
793
 
798
794
 
@@ -802,10 +798,8 @@
802
798
  </script>
803
799
  <!-- ═══ AI批注数据 END ═══ -->
804
800
 
805
- <!-- ═══ AI教学数据 BEGIN — 长答/教学卡:agent 用 annotatable answer 写入(页面只读渲染,浏览器保存原样保留本区块):
806
- 结构 { rev, answers: [{ id, annId(来源批注id), title, rungs: [{ title 阶标题, body 阶正文, checkpoint 检查点(可选), answer 参考答案(可选) }] }] }
807
- 多阶或带 checkpoint 的条目在回答大页渲染为分阶教学卡(逐阶解锁推进);单阶无 checkpoint 渲染为整卡长答。
808
- 批注 store 保持瘦:agent 读待回应批注只需读上面的 annotations-store,不必吞教学全文 ═══ -->
801
+ <!-- ═══ AI教学数据 BEGIN — 既有扩展回答数据,兼容旧文档并保留内容。
802
+ 普通批注通过 reply 回答;本区块不是默认教学流程 ═══ -->
809
803
  <script id="answers-store" type="application/json">
810
804
 
811
805
 
@@ -3313,10 +3307,10 @@ function markSeen(a){
3313
3307
  }
3314
3308
  function unreadOf(a){
3315
3309
  var th = a.thread || [];
3316
- if(th.some(function(t){ return t.streaming; })) return 0; /* 作答中不算未读:收笔摘旗才亮(红点不等打字机) */
3317
3310
  var seen = seenOf(a), n = 0;
3318
3311
  th.forEach(function(t, i){
3319
- if(i >= seen && t.role === 'ai' && String(t.text || '').trim()) n++;
3312
+ /* Only completed replies count; a new stream must not hide earlier unread replies. */
3313
+ if(i >= seen && t.role === 'ai' && !t.streaming && String(t.text || '').trim()) n++;
3320
3314
  });
3321
3315
  return n;
3322
3316
  }
@@ -3550,9 +3544,9 @@ function refreshPanel(){
3550
3544
  }
3551
3545
  materializeSeen(sorted); // 冻结本次面板所见为已读基线(之后的 AI 轮才算新)
3552
3546
  }
3553
- /* 把手红点只数未读批注(与「● 未读」区同口径,v1.30.0 seen 记账),不报全部批注数 */
3547
+ /* 把手红点累计未读 AI 回答总数;同一批注的多条回答分别计数。 */
3554
3548
  var unreadCnt = 0;
3555
- sorted.forEach(function(a){ if(unreadOf(a) > 0) unreadCnt++; });
3549
+ sorted.forEach(function(a){ unreadCnt += unreadOf(a); });
3556
3550
  badge.textContent = unreadCnt;
3557
3551
  badge.style.display = unreadCnt ? 'block' : 'none';
3558
3552
  }
@@ -5705,4 +5699,4 @@ setTimeout(maybeShowBindOverlay, 800); // 等句柄恢复与 serve 探测落
5705
5699
  </script>
5706
5700
 
5707
5701
 
5708
- </body></html>
5702
+ </body></html>