@angelky7/dsh-browser-control 1.7.3 → 1.7.5

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,72 @@
1
+ /**
2
+ * 操作审计(v1.7.4):插件这一侧到底发过哪些命令、被拒了几次、各自用了多久。
3
+ *
4
+ * 存在的理由不是「日志」,而是回答两个现实问题:出事后「刚才谁动了这个页面」,
5
+ * 以及做「会话恢复」之前先得有一份可靠的行为底账。所以:
6
+ *
7
+ * - **记录在命令层**(controller.execute),不是工具层:模型看不到的内部重试、
8
+ * browser_drag 一次拖拽打出的 N 次 cdp 往返,都在账上,不美化。
9
+ * - **只存内存**(环形缓冲,200 条):跨 dsh 重启的持久化是「会话恢复」那一版的事;
10
+ * 这一版先把账记对,别把磁盘格式写死。
11
+ * - **参数摘要是脱敏后的**:输入框的值、cookie 的值、令牌类字段一律打码;
12
+ * 表达式截断。审计的用途是「做了什么」,不是「内容是什么」。
13
+ */
14
+ export interface AuditEntry {
15
+ seq: number;
16
+ /** epoch 毫秒 */
17
+ t: number;
18
+ command: string;
19
+ tabId?: number;
20
+ /** 脱敏后的参数摘要(JSON 文本,已截断) */
21
+ params: string;
22
+ ok: boolean;
23
+ code?: string;
24
+ effectState?: string;
25
+ /** 失败描述,截断 */
26
+ error?: string;
27
+ durationMs: number;
28
+ }
29
+ export interface AuditFilter {
30
+ tabId?: number;
31
+ /** 命令名子串匹配(如 "nav" 命中 nav / browser.info? 不需要——命令名如 tabs.open) */
32
+ command?: string;
33
+ /** 只返回 seq 严格大于它的条目(分页) */
34
+ sinceSeq?: number;
35
+ /** 最多返回多少条(取最新的 N 条,按 seq 升序给出) */
36
+ limit?: number;
37
+ }
38
+ /** 环形缓冲上限。200 条足够覆盖一次排障现场,又不至于让内存跟着会话长度涨。 */
39
+ export declare const AUDIT_MAX_ENTRIES = 200;
40
+ /**
41
+ * 把一次调用的参数压成可审计的摘要:敏感键打码、特定命令的 value 打码、
42
+ * 长表达式截断、整体限长。返回 JSON 文本——审计工具原样把它交给模型。
43
+ */
44
+ export declare function summarizeAuditParams(command: string, params: Record<string, unknown>): string;
45
+ /** 记账器。一个桥(一个插件实例)一份;`record` 只追加,`list` 只读。 */
46
+ export declare class AuditLog {
47
+ private readonly entries;
48
+ private nextSeq;
49
+ private readonly now;
50
+ constructor(now?: () => number);
51
+ /**
52
+ * 记一笔。
53
+ * @param input - 命令、摘要与结果;seq/t 由账本自己盖,durationMs 由调用方量。
54
+ */
55
+ record(input: {
56
+ command: string;
57
+ tabId?: number;
58
+ params: string;
59
+ ok: boolean;
60
+ code?: string;
61
+ effectState?: string;
62
+ error?: string;
63
+ durationMs: number;
64
+ }): AuditEntry;
65
+ /** 现有条目数(被挤掉的不算)。 */
66
+ size(): number;
67
+ /**
68
+ * 过滤并取最新的一批。
69
+ * @param filter - tabId / 命令子串 / sinceSeq / limit;返回按 seq 升序。
70
+ */
71
+ list(filter?: AuditFilter): AuditEntry[];
72
+ }
@@ -0,0 +1,107 @@
1
+ /** 环形缓冲上限。200 条足够覆盖一次排障现场,又不至于让内存跟着会话长度涨。 */
2
+ export const AUDIT_MAX_ENTRIES = 200;
3
+ /** 单条参数摘要上限:审计要的是形状,不是全文。 */
4
+ const AUDIT_PARAMS_MAX_CHARS = 300;
5
+ /** 失败描述上限。 */
6
+ const AUDIT_ERROR_MAX_CHARS = 200;
7
+ /** eval 表达式只留前 120 字符:里面可能有凭据。 */
8
+ const AUDIT_EXPRESSION_MAX_CHARS = 120;
9
+ /** 参数里这些键的值一律打码(大小写不敏感)。 */
10
+ const SECRET_KEY = /^(token|authorization|password|passwd|secret|apikey|api_key|cookie)$/i;
11
+ /**
12
+ * 哪个命令的 `value` 字段是敏感内容:`input` 是往页面里打的字(可能是密码),
13
+ * `cookies.set` 直接写凭据。其余命令的 value 字段(如果有)不动。
14
+ */
15
+ const VALUE_REDACTED_COMMANDS = new Set(['input', 'cookies.set']);
16
+ function redactValue(value) {
17
+ const length = typeof value === 'string' ? [...value].length : 0;
18
+ return `[redacted:${length} chars]`;
19
+ }
20
+ /**
21
+ * 把一次调用的参数压成可审计的摘要:敏感键打码、特定命令的 value 打码、
22
+ * 长表达式截断、整体限长。返回 JSON 文本——审计工具原样把它交给模型。
23
+ */
24
+ export function summarizeAuditParams(command, params) {
25
+ let text;
26
+ try {
27
+ text = JSON.stringify(params, (key, value) => {
28
+ if (SECRET_KEY.test(key))
29
+ return '[redacted]';
30
+ if (key === 'value' && VALUE_REDACTED_COMMANDS.has(command))
31
+ return redactValue(value);
32
+ if (key === 'expression' && typeof value === 'string' && [...value].length > AUDIT_EXPRESSION_MAX_CHARS) {
33
+ return `${[...value].slice(0, AUDIT_EXPRESSION_MAX_CHARS).join('')}…[+${[...value].length - AUDIT_EXPRESSION_MAX_CHARS} chars]`;
34
+ }
35
+ return value;
36
+ }) ?? '{}';
37
+ }
38
+ catch {
39
+ text = '[unserializable params]';
40
+ }
41
+ return [...text].length > AUDIT_PARAMS_MAX_CHARS
42
+ ? `${[...text].slice(0, AUDIT_PARAMS_MAX_CHARS).join('')}…[+${[...text].length - AUDIT_PARAMS_MAX_CHARS} chars]`
43
+ : text;
44
+ }
45
+ /** 记账器。一个桥(一个插件实例)一份;`record` 只追加,`list` 只读。 */
46
+ export class AuditLog {
47
+ entries = [];
48
+ nextSeq = 1;
49
+ now;
50
+ constructor(now = Date.now) {
51
+ this.now = now;
52
+ }
53
+ /**
54
+ * 记一笔。
55
+ * @param input - 命令、摘要与结果;seq/t 由账本自己盖,durationMs 由调用方量。
56
+ */
57
+ record(input) {
58
+ const entry = {
59
+ seq: this.nextSeq++,
60
+ t: this.now(),
61
+ command: input.command,
62
+ params: input.params,
63
+ ok: input.ok,
64
+ durationMs: Math.max(0, Math.round(input.durationMs)),
65
+ };
66
+ if (input.tabId !== undefined)
67
+ entry.tabId = input.tabId;
68
+ if (input.code !== undefined)
69
+ entry.code = input.code;
70
+ if (input.effectState !== undefined)
71
+ entry.effectState = input.effectState;
72
+ if (input.error !== undefined) {
73
+ entry.error = [...input.error].length > AUDIT_ERROR_MAX_CHARS
74
+ ? `${[...input.error].slice(0, AUDIT_ERROR_MAX_CHARS).join('')}…`
75
+ : input.error;
76
+ }
77
+ this.entries.push(entry);
78
+ // 环形缓冲:超出上限从最旧的一端丢。seq 不回收——调用方拿 seq 分页时
79
+ // 「中间缺了号」本身就是「被挤掉了」的机器可读信号。
80
+ if (this.entries.length > AUDIT_MAX_ENTRIES)
81
+ this.entries.splice(0, this.entries.length - AUDIT_MAX_ENTRIES);
82
+ return entry;
83
+ }
84
+ /** 现有条目数(被挤掉的不算)。 */
85
+ size() {
86
+ return this.entries.length;
87
+ }
88
+ /**
89
+ * 过滤并取最新的一批。
90
+ * @param filter - tabId / 命令子串 / sinceSeq / limit;返回按 seq 升序。
91
+ */
92
+ list(filter = {}) {
93
+ let matched = this.entries;
94
+ if (filter.tabId !== undefined)
95
+ matched = matched.filter((e) => e.tabId === filter.tabId);
96
+ if (filter.command !== undefined && filter.command.length > 0) {
97
+ const needle = filter.command.toLowerCase();
98
+ matched = matched.filter((e) => e.command.toLowerCase().includes(needle));
99
+ }
100
+ if (filter.sinceSeq !== undefined) {
101
+ const floor = filter.sinceSeq;
102
+ matched = matched.filter((e) => e.seq > floor);
103
+ }
104
+ const limit = Math.min(AUDIT_MAX_ENTRIES, Math.max(1, Math.trunc(filter.limit ?? 50)));
105
+ return matched.slice(Math.max(0, matched.length - limit));
106
+ }
107
+ }
@@ -40,7 +40,14 @@ export interface Config {
40
40
  /**
41
41
  * Directory screenshots are written to and `cleanup` clears. Relative paths
42
42
  * resolve against the process working directory at resolve time.
43
- */ shotsDir?: string;
43
+ */
44
+ shotsDir?: string;
45
+ /**
46
+ * Directory the command journal is written to (v1.7.5 session recovery).
47
+ * Defaults to `~/.dsh-browser-control` — stable across restarts regardless
48
+ * of the process working directory.
49
+ */
50
+ stateDir?: string;
44
51
  /**
45
52
  * Read-only mode: refuse every command that would change the page or the
46
53
  * browser (click / input / press / nav / scroll / tabs.* / emulate / dialog /
@@ -18,12 +18,15 @@
18
18
  * @module @deepseek-ai/dsh-browser-bridge
19
19
  */
20
20
  import { mkdir, writeFile } from 'node:fs/promises';
21
+ import { homedir } from 'node:os';
21
22
  import path from 'node:path';
22
23
  import z from '@deepseek-ai/schemastery';
23
24
  import { defineTool } from '@deepseek-ai/dsh-tools';
24
25
  import { BridgeServer, cleanupArtifacts } from "./server.js";
25
26
  import { BridgeError, classify } from "./errors.js";
26
27
  import { policyRefusal } from "./policy.js";
28
+ import { AUDIT_MAX_ENTRIES, AuditLog, summarizeAuditParams } from "./audit.js";
29
+ import { Journal, readJournal } from "./journal.js";
27
30
  /** Navigation wait ceiling (same as browser_wait); the bridge gets this plus BRIDGE_MARGIN_MS. */
28
31
  const NAV_MAX_MS = 120_000;
29
32
  /**
@@ -54,6 +57,8 @@ export const Config = z.object({
54
57
  port: z.number().step(1).min(1024).max(65_535).default(9777),
55
58
  token: z.string().default('dsh-local'),
56
59
  shotsDir: z.string().default('dsh-browser-shots'),
60
+ // 审计账本落盘目录(v1.7.5 会话恢复)。默认放用户主目录,跟 dsh 的启动目录无关。
61
+ stateDir: z.string().default(path.join(homedir(), '.dsh-browser-control')),
57
62
  readOnly: z.boolean().default(false),
58
63
  allowScript: z.boolean().default(true),
59
64
  });
@@ -116,6 +121,10 @@ class BridgeController {
116
121
  lastError;
117
122
  chain = Promise.resolve();
118
123
  current;
124
+ journal;
125
+ journalKey = '';
126
+ /** 操作审计账本(内存环形缓冲,200 条;命令层记录,见 audit.ts 顶部注释)。 */
127
+ audit = new AuditLog();
119
128
  constructor(log) {
120
129
  this.log = log;
121
130
  }
@@ -123,6 +132,17 @@ class BridgeController {
123
132
  get shotsDir() {
124
133
  return this.current === undefined ? undefined : path.resolve(this.current.shotsDir);
125
134
  }
135
+ /** 审计账本落盘目录;配置到达之前是 undefined。 */
136
+ get stateDir() {
137
+ return this.current === undefined ? undefined : path.resolve(this.current.stateDir);
138
+ }
139
+ /**
140
+ * Record one audit entry in memory and, when the journal is open, on disk.
141
+ * Journal appends are queued and never block the command path.
142
+ */
143
+ recordAudit(input) {
144
+ this.journal?.append(this.audit.record(input));
145
+ }
126
146
  /**
127
147
  * Current policy flags. Tools that compose several commands (browser_drag
128
148
  * runs `cdp`/`eval`) read this to refuse with a tool-level message; the
@@ -161,6 +181,16 @@ class BridgeController {
161
181
  requireExtensionCommand(command, since) {
162
182
  if (this.hasExtensionCommand(command))
163
183
  return;
184
+ // 预检拒绝也要上账:这是「升级插件后模型发现功能不可用」的最常见现场。
185
+ this.recordAudit({
186
+ command,
187
+ params: '',
188
+ ok: false,
189
+ code: 'extension_outdated',
190
+ effectState: 'not_started',
191
+ error: `connected extension v${this.extensionVersion() ?? '?'} lacks ${command} (added in ${since})`,
192
+ durationMs: 0,
193
+ });
164
194
  throw extensionOutdatedError(this.extensionVersion(), command, since);
165
195
  }
166
196
  /**
@@ -184,6 +214,20 @@ class BridgeController {
184
214
  }
185
215
  async reconcileNow(config) {
186
216
  const shotsDir = path.resolve(config.shotsDir);
217
+ const stateDir = path.resolve(config.stateDir);
218
+ if (stateDir !== this.journalKey) {
219
+ const previousJournal = this.journal;
220
+ this.journal = undefined;
221
+ this.journalKey = stateDir;
222
+ await previousJournal?.close();
223
+ try {
224
+ this.journal = await Journal.open(stateDir, this.log);
225
+ }
226
+ catch (error) {
227
+ // 账本打不开不是让桥起不来的理由:降级为纯内存审计。
228
+ this.log(`journal: disabled — ${errorMessage(error)}`);
229
+ }
230
+ }
187
231
  const key = config.enabled ? `${config.port}|${config.token}|${shotsDir}` : '';
188
232
  if (key === this.serverKey)
189
233
  return;
@@ -221,12 +265,34 @@ class BridgeController {
221
265
  * @returns the extension's result payload verbatim.
222
266
  */
223
267
  async execute(command, params, signal, timeoutMs) {
224
- this.policyGate(command, params);
225
- const server = this.server;
226
- if (server === undefined) {
227
- throw classify(command, new Error(this.lastError ?? '浏览器控制未启用 —— 在 profile 的 cordis.patch.yml 里给 browser-bridge 配 enabled: true(dsh 0.1.7+ 的宿主也会在设置 → 插件 自动列出本插件开关;0.1.2 及更早的宿主没有这张卡片,只能改配置文件)'));
268
+ const started = Date.now();
269
+ const summary = summarizeAuditParams(command, params);
270
+ const tabId = typeof params.tabId === 'number' ? params.tabId : undefined;
271
+ try {
272
+ this.policyGate(command, params);
273
+ const server = this.server;
274
+ if (server === undefined) {
275
+ throw classify(command, new Error(this.lastError ?? '浏览器控制未启用 —— 在 profile 的 cordis.patch.yml 里给 browser-bridge 配 enabled: true(dsh 0.1.7+ 的宿主也会在设置 → 插件 自动列出本插件开关;0.1.2 及更早的宿主没有这张卡片,只能改配置文件)'));
276
+ }
277
+ const result = await server.execute(command, params, timeoutMs === undefined ? { signal } : { signal, timeoutMs });
278
+ this.recordAudit({ command, tabId, params: summary, ok: true, durationMs: Date.now() - started });
279
+ return result;
280
+ }
281
+ catch (error) {
282
+ const code = error.code;
283
+ const effectState = error.effectState;
284
+ this.recordAudit({
285
+ command,
286
+ tabId,
287
+ params: summary,
288
+ ok: false,
289
+ code: typeof code === 'string' ? code : undefined,
290
+ effectState: typeof effectState === 'string' ? effectState : undefined,
291
+ error: errorMessage(error),
292
+ durationMs: Date.now() - started,
293
+ });
294
+ throw error;
228
295
  }
229
- return server.execute(command, params, timeoutMs === undefined ? { signal } : { signal, timeoutMs });
230
296
  }
231
297
  /**
232
298
  * Refuse a command the local policy forbids, before anything is sent or deleted.
@@ -252,27 +318,50 @@ class BridgeController {
252
318
  * @returns counts and names of what was removed.
253
319
  */
254
320
  async cleanup() {
255
- // cleanup() 不经过 execute(),所以它必须自己站一次闸门:否则只读档下
256
- // 模型仍能用 browser_cleanup 删掉用户的整个截图目录。
257
- this.policyGate('cleanup');
258
- const dir = this.shotsDir;
259
- if (dir === undefined)
260
- throw classify('cleanup', new Error('browser-bridge is not configured yet —— 浏览器控制尚未加载配置,无法确定清理目录'));
321
+ const started = Date.now();
261
322
  try {
262
- return await cleanupArtifacts({ shotsDir: dir });
323
+ // cleanup() 不经过 execute(),所以它必须自己站一次闸门:否则只读档下
324
+ // 模型仍能用 browser_cleanup 删掉用户的整个截图目录。
325
+ this.policyGate('cleanup');
326
+ const dir = this.shotsDir;
327
+ if (dir === undefined)
328
+ throw classify('cleanup', new Error('browser-bridge is not configured yet —— 浏览器控制尚未加载配置,无法确定清理目录'));
329
+ try {
330
+ const result = await cleanupArtifacts({ shotsDir: dir });
331
+ this.recordAudit({ command: 'cleanup', params: '{}', ok: true, durationMs: Date.now() - started });
332
+ return result;
333
+ }
334
+ catch (error) {
335
+ // 删除是逐条进行的,中途失败时前面的文件已经没了;不包一层分类,
336
+ // 模型拿到的就是一句没有 code / effectState 的裸 fs 错误。
337
+ throw classify('cleanup', error);
338
+ }
263
339
  }
264
340
  catch (error) {
265
- // 删除是逐条进行的,中途失败时前面的文件已经没了;不包一层分类,
266
- // 模型拿到的就是一句没有 code / effectState 的裸 fs 错误。
267
- throw classify('cleanup', error);
341
+ const code = error.code;
342
+ const effectState = error.effectState;
343
+ this.recordAudit({
344
+ command: 'cleanup',
345
+ params: '{}',
346
+ ok: false,
347
+ code: typeof code === 'string' ? code : undefined,
348
+ effectState: typeof effectState === 'string' ? effectState : undefined,
349
+ error: errorMessage(error),
350
+ durationMs: Date.now() - started,
351
+ });
352
+ throw error;
268
353
  }
269
354
  }
270
355
  /** Stop the listener; safe to call repeatedly and during teardown. */
271
- stop() {
356
+ async stop() {
272
357
  const previous = this.server;
273
358
  this.server = undefined;
274
359
  this.serverKey = '';
275
- return previous?.stop() ?? Promise.resolve();
360
+ await (previous?.stop() ?? Promise.resolve());
361
+ const journal = this.journal;
362
+ this.journal = undefined;
363
+ this.journalKey = '';
364
+ await journal?.close();
276
365
  }
277
366
  }
278
367
  function errorMessage(error) {
@@ -1692,6 +1781,8 @@ function applyBrowserTools(ctx, controller) {
1692
1781
  },
1693
1782
  }));
1694
1783
  registerDragTool(ctx, controller);
1784
+ registerAuditTool(ctx, controller);
1785
+ registerJournalTool(ctx, controller);
1695
1786
  }
1696
1787
  /* ------------------- browser_drag: composed plugin-side over the cdp channel ------------------- */
1697
1788
  /** One CDP round-trip over the embedded `cdp` passthrough (extension v1.7.2+). */
@@ -1958,6 +2049,140 @@ function registerDragTool(ctx, controller) {
1958
2049
  },
1959
2050
  }));
1960
2051
  }
2052
+ function registerAuditTool(ctx, controller) {
2053
+ ctx.tools.register(defineTool({
2054
+ name: 'browser_audit',
2055
+ description: 'Read this plugin session\'s in-memory audit trail: every command sent to the browser extension (and every policy/gate refusal), with redacted params, outcome code and duration. Newest last; capped at 200 entries and wiped when dsh restarts. Values typed into pages and cookie values are redacted; eval expressions are truncated. Plugin-local read — works in read-only mode.',
2056
+ parameters: {
2057
+ command: { type: 'string', description: 'Only entries whose command contains this substring (e.g. "nav", "cookies", "tabs").' },
2058
+ tabId: { type: 'number', description: 'Only entries that carried this tabId.' },
2059
+ sinceSeq: { type: 'number', description: 'Only entries with seq greater than this (paging).' },
2060
+ limit: { type: 'number', description: `Max entries to return (newest last), 1–${AUDIT_MAX_ENTRIES}; default 50.` },
2061
+ },
2062
+ output: {
2063
+ schema: {
2064
+ type: 'object',
2065
+ additionalProperties: false,
2066
+ properties: {
2067
+ total: { type: 'number', required: true },
2068
+ shown: { type: 'number', required: true },
2069
+ entries: {
2070
+ type: 'array',
2071
+ required: true,
2072
+ items: {
2073
+ type: 'object',
2074
+ additionalProperties: false,
2075
+ properties: {
2076
+ seq: { type: 'number', required: true },
2077
+ t: { type: 'number', required: true },
2078
+ command: { type: 'string', required: true },
2079
+ tabId: { type: 'number' },
2080
+ params: { type: 'string', required: true },
2081
+ ok: { type: 'boolean', required: true },
2082
+ code: { type: 'string' },
2083
+ effectState: { type: 'string' },
2084
+ error: { type: 'string' },
2085
+ durationMs: { type: 'number', required: true },
2086
+ },
2087
+ },
2088
+ },
2089
+ },
2090
+ },
2091
+ render: (_args, value) => {
2092
+ const v = value;
2093
+ if (v.entries.length === 0) {
2094
+ return [{ type: 'text', text: `Audit: ${v.total} entries, none match — the trail starts when a command is executed.` }];
2095
+ }
2096
+ const lines = v.entries.map((e) => {
2097
+ const tab = e.tabId === undefined ? '' : ` tab ${e.tabId}`;
2098
+ const outcome = e.ok ? 'ok' : `FAIL ${e.code ?? 'error'}${e.effectState ? `/${e.effectState}` : ''}`;
2099
+ const err = e.ok || e.error === undefined ? '' : ` — ${e.error}`;
2100
+ return `#${e.seq} ${formatClock(e.t)} ${e.command}${tab} ${outcome} ${e.durationMs}ms ${e.params}${err}`;
2101
+ });
2102
+ return [{ type: 'text', text: clampRendered(`Audit: ${v.total} entries (showing ${v.shown})\n${lines.join('\n')}`) }];
2103
+ },
2104
+ },
2105
+ presentCall: () => ({ card: 'generic', title: 'Browser audit', kind: 'other' }),
2106
+ async execute(args) {
2107
+ const entries = controller.audit.list({
2108
+ tabId: args.tabId,
2109
+ command: args.command,
2110
+ sinceSeq: args.sinceSeq,
2111
+ limit: args.limit,
2112
+ });
2113
+ return { total: controller.audit.size(), shown: entries.length, entries };
2114
+ },
2115
+ }));
2116
+ }
2117
+ function registerJournalTool(ctx, controller) {
2118
+ ctx.tools.register(defineTool({
2119
+ name: 'browser_journal',
2120
+ description: 'Read the persistent command journal across dsh restarts: every command from previous runs of this plugin (written to <stateDir>/journal.jsonl, last 1000 entries), grouped by run. Same redaction as browser_audit. Plugin-local disk read — works with the extension offline and in read-only mode, so it is the way to recover "what was I doing" after a restart.',
2121
+ parameters: {
2122
+ command: { type: 'string', description: 'Only entries whose command contains this substring.' },
2123
+ tabId: { type: 'number', description: 'Only entries that carried this tabId.' },
2124
+ limit: { type: 'number', description: 'Max entries to return (newest last), 1–500; default 50.' },
2125
+ },
2126
+ output: {
2127
+ schema: {
2128
+ type: 'object',
2129
+ additionalProperties: false,
2130
+ properties: {
2131
+ total: { type: 'number', required: true },
2132
+ shown: { type: 'number', required: true },
2133
+ runs: { type: 'number', required: true },
2134
+ entries: {
2135
+ type: 'array',
2136
+ required: true,
2137
+ items: {
2138
+ type: 'object',
2139
+ additionalProperties: false,
2140
+ properties: {
2141
+ run: { type: 'string', required: true },
2142
+ seq: { type: 'number', required: true },
2143
+ t: { type: 'number', required: true },
2144
+ command: { type: 'string', required: true },
2145
+ tabId: { type: 'number' },
2146
+ params: { type: 'string', required: true },
2147
+ ok: { type: 'boolean', required: true },
2148
+ code: { type: 'string' },
2149
+ effectState: { type: 'string' },
2150
+ error: { type: 'string' },
2151
+ durationMs: { type: 'number', required: true },
2152
+ },
2153
+ },
2154
+ },
2155
+ },
2156
+ },
2157
+ render: (_args, value) => {
2158
+ const v = value;
2159
+ if (v.entries.length === 0) {
2160
+ return [{ type: 'text', text: `Journal: ${v.total} entries on disk, none match — nothing recorded across runs yet.` }];
2161
+ }
2162
+ const lines = [];
2163
+ let run = '';
2164
+ for (const e of v.entries) {
2165
+ if (e.run !== run) {
2166
+ run = e.run;
2167
+ lines.push(`── run ${run}`);
2168
+ }
2169
+ const tab = e.tabId === undefined ? '' : ` tab ${e.tabId}`;
2170
+ const outcome = e.ok ? 'ok' : `FAIL ${e.code ?? 'error'}`;
2171
+ lines.push(`#${e.seq} ${formatClock(e.t)} ${e.command}${tab} ${outcome} ${e.durationMs}ms ${e.params}`);
2172
+ }
2173
+ return [{ type: 'text', text: clampRendered(`Journal: ${v.total} entries in ${v.runs} run(s) (showing ${v.shown})\n${lines.join('\n')}`) }];
2174
+ },
2175
+ },
2176
+ presentCall: () => ({ card: 'generic', title: 'Browser journal', kind: 'other' }),
2177
+ async execute(args) {
2178
+ const dir = controller.stateDir;
2179
+ if (dir === undefined)
2180
+ throw new Error('browser-bridge is not configured yet — the journal has no stateDir to read');
2181
+ const { total, entries } = await readJournal(dir, { command: args.command, tabId: args.tabId, limit: args.limit });
2182
+ return { total, shown: entries.length, runs: new Set(entries.map((e) => e.run)).size, entries };
2183
+ },
2184
+ }));
2185
+ }
1961
2186
  /** Cordis plugin entry: wire the settings-driven lifecycle plus the model-facing tools. */
1962
2187
  export function apply(ctx, config) {
1963
2188
  const resolved = config;
@@ -0,0 +1,45 @@
1
+ import type { AuditEntry } from './audit.ts';
2
+ /** 磁盘上最多保留的条数(启动时裁剪)。 */
3
+ export declare const JOURNAL_MAX_LINES = 1000;
4
+ /** 一行 = 一次运行的编号 + 审计条目。 */
5
+ export interface JournalEntry extends AuditEntry {
6
+ /** 这次插件运行的身份(启动时刻,ISO 秒)。跨运行恢复靠它分组。 */
7
+ run: string;
8
+ }
9
+ export interface JournalFilter {
10
+ tabId?: number;
11
+ command?: string;
12
+ limit?: number;
13
+ }
14
+ /**
15
+ * 打开(并按需裁剪)一个 journal 目录。
16
+ * @param stateDir - 目录(不存在会创建);journal 文件是 `<stateDir>/journal.jsonl`。
17
+ * @param log - 一行式日志;打开/裁剪的结论写进去。
18
+ * @returns 带 run 编号的追加器;`close()` 等所有在途写入落盘。
19
+ */
20
+ export declare class Journal {
21
+ /** 这次运行的编号:启动时刻(毫秒精度;秒级精度下两次启动同秒会撞号)。 */
22
+ readonly runId: string;
23
+ private readonly file;
24
+ private readonly log;
25
+ private chain;
26
+ private warned;
27
+ private constructor();
28
+ static open(stateDir: string, log: (line: string) => void): Promise<Journal>;
29
+ /**
30
+ * 追加一条(异步、排队写;失败只告警一次,绝不阻塞命令路径)。
31
+ * @param entry - AuditLog 刚记下的条目(参数已脱敏)。
32
+ */
33
+ append(entry: AuditEntry): void;
34
+ /** 等在途写入落盘(teardown / 测试用)。 */
35
+ close(): Promise<void>;
36
+ }
37
+ /**
38
+ * 读 journal 的尾部(不要求 Journal 实例——读在线程外也能工作,扩展离线也行)。
39
+ * @param stateDir - journal 所在目录。
40
+ * @param filter - tabId / 命令子串 / 条数(默认 50,最大 500;取最新)。
41
+ */
42
+ export declare function readJournal(stateDir: string, filter?: JournalFilter): Promise<{
43
+ total: number;
44
+ entries: JournalEntry[];
45
+ }>;