@jjh32456/dsh-longlive 1.6.6 → 1.6.8

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.
Files changed (43) hide show
  1. package/CHANGELOG.md +40 -4
  2. package/QUICKSTART.md +10 -10
  3. package/README.en.md +4 -3
  4. package/README.md +10 -4
  5. package/SPEC.md +2 -2
  6. package/cordis.patch.yml +9 -0
  7. package/docs/ARCHITECTURE.md +3 -2
  8. package/docs/CONFIG.md +33 -6
  9. package/docs/DEV.md +3 -3
  10. package/docs/FAQ.md +4 -4
  11. package/docs/FEATURES.md +36 -5
  12. package/docs/OPERATIONS.md +1 -1
  13. package/docs/QUICKSTART.md +3 -3
  14. package/docs/README.en.md +3 -2
  15. package/docs/TROUBLESHOOTING.md +3 -3
  16. package/package.json +13 -8
  17. package/src/activity/index.js +649 -0
  18. package/src/activity/model.js +341 -0
  19. package/src/activity/store.js +625 -0
  20. package/src/compaction/engine-core.js +144 -97
  21. package/src/config.js +29 -2
  22. package/src/features.js +26 -0
  23. package/src/heartbeat.js +156 -23
  24. package/src/identity/handover.js +52 -1
  25. package/src/live.js +22 -2
  26. package/src/recall/privacy.mjs +105 -16
  27. package/src/router/service/board.mjs +95 -15
  28. package/src/router/service/hint.mjs +14 -0
  29. package/src/router/service/service.mjs +27 -11
  30. package/test/activity-realtime.test.mjs +959 -0
  31. package/test/activity-stream.test.mjs +512 -0
  32. package/test/autobind.test.mjs +25 -11
  33. package/test/config-schema.test.mjs +6 -0
  34. package/test/feature-flags.test.mjs +17 -0
  35. package/test/fixtures/activity-append-child.mjs +29 -0
  36. package/test/hot-truncate.test.mjs +113 -0
  37. package/test/live.test.mjs +6 -3
  38. package/test/packaging.test.mjs +7 -5
  39. package/test/router-background/router.test.mjs +44 -12
  40. package/test/run-rc.mjs +16 -0
  41. package/test/safety-defaults.test.mjs +5 -3
  42. package/test/state-truth.test.mjs +937 -0
  43. package/test/version-consistency.test.mjs +286 -0
package/src/heartbeat.js CHANGED
@@ -1,24 +1,65 @@
1
1
  // Auto-heartbeat for the "正在" live registry.
2
2
  //
3
- // Two clocks keep a bound row fresh without the caller passing sessionId:
4
- // 1. turn lifecycle — `session/event` with `turn/start` / `turn/end` for a
5
- // session this writer has already bound;
3
+ // Two clocks keep a row fresh without the caller passing sessionId:
4
+ // 1. turn lifecycle — `session/event` with `turn/start` / `turn/end`. The
5
+ // event itself carries the session id, so turn boundaries maintain EVERY
6
+ // non-subagent session's row (R4.5), bound or not; a missing row is
7
+ // created on the first boundary (128-row cap respected, R4.3);
6
8
  // 2. a fallback interval (`heartbeatMs`, default 15 s, `0` disables), which
7
- // is what keeps the row fresh through long tool calls and idle stretches.
9
+ // keeps rows of sessions bound by a longlive tool call fresh through long
10
+ // tool calls and idle stretches. It never fabricates rows.
8
11
  //
9
- // Identity is bound, never guessed: the id comes from a tool call's explicit
10
- // `exec.agent` (see identity.js). Turn events and the timer only refresh ids
11
- // already in that bound set, so a root-scoped plugin observing every session
12
- // can never heartbeat somebody else's row.
12
+ // Turn boundaries are a state machine, not a heartbeat: `turn/start` writes
13
+ // `state='running'` plus the turn number, `turn/end` writes
14
+ // `idle`/`waiting`/`blocked` plus the turn number (R4.3). It is event driven,
15
+ // so it still runs when the fallback timer is off (`heartbeatMs: 0`); a timer
16
+ // tick, by contrast, only renews `heartbeatAt` and never touches state or
17
+ // `last_action`.
13
18
  //
14
- // Every write is best effort: a missing/corrupt row, an unset memoryRoot or a
15
- // racing writer must never fail a tool call or crash the host.
19
+ // Every write is best effort: a missing/corrupt row, an unset memoryRoot, a
20
+ // full registry or a racing writer must never fail a tool call or crash the
21
+ // host.
16
22
 
17
- import { liveTouch, liveUpdate } from './live.js';
23
+ import { liveGet, liveList, liveTouch, liveUpdate } from './live.js';
18
24
  import { resolveCurrentSessionId } from './identity.js';
19
25
 
20
26
  export const DEFAULT_HEARTBEAT_MS = 15_000;
21
27
  export const MAX_BOUND_SESSIONS = 64;
28
+ /** DESIGN §2.1 live-row cap; mirrored here so turn boundaries cannot blow it up. */
29
+ export const MAX_LIVE_ROWS = 128;
30
+
31
+ /**
32
+ * R4.3 pure state machine: state a live row must carry when a turn starts.
33
+ * @returns {'running'}
34
+ */
35
+ export function stateForTurnStart() {
36
+ return 'running';
37
+ }
38
+
39
+ /**
40
+ * R4.3 pure state machine: state a live row must carry when a turn ends.
41
+ * `waiting`/`blocked` are user-facing promises and survive the turn boundary;
42
+ * anything else falls back to `idle`.
43
+ * @param {unknown} priorState declared state read before the turn/end write
44
+ * @returns {'idle'|'waiting'|'blocked'}
45
+ */
46
+ export function stateForTurnEnd(priorState) {
47
+ return priorState === 'waiting' || priorState === 'blocked' ? priorState : 'idle';
48
+ }
49
+
50
+ /**
51
+ * Pure description of the live-row patch caused by one turn boundary. Kept
52
+ * separate from I/O so the state machine can be pinned without a registry.
53
+ * @param {'turn/start'|'turn/end'} type
54
+ * @param {{turn?:unknown, priorState?:unknown}} [options]
55
+ * @returns {{state:string, turn:number|null, kind:'turn', detail:string}}
56
+ */
57
+ export function turnBoundaryPlan(type, options = {}) {
58
+ const kind = type === 'turn/end' ? 'turn/end' : 'turn/start';
59
+ const state = kind === 'turn/start' ? stateForTurnStart() : stateForTurnEnd(options.priorState);
60
+ const turn = Number.isInteger(options.turn) && options.turn >= 0 ? options.turn : null;
61
+ return { state, turn, kind: 'turn', detail: turn == null ? kind : `${kind} #${turn}` };
62
+ }
22
63
 
23
64
  function cleanId(value) {
24
65
  if (typeof value !== 'string') return null;
@@ -33,12 +74,19 @@ function cleanId(value) {
33
74
  * @param {object} [options]
34
75
  * @param {object|null} [options.ctx] host context (used for `session/event`)
35
76
  * @param {string} [options.memoryRoot] live-registry root; absent disables writes
36
- * @param {number} [options.heartbeatMs] fallback interval; `0` disables all
37
- * automatic writes (binding still happens through the tool resolver)
77
+ * @param {number} [options.heartbeatMs] fallback interval; `0` disables the
78
+ * timer and tool-call touches (binding still happens through the tool
79
+ * resolver). Turn-boundary state writes (`turn/start` → running + turn,
80
+ * `turn/end` → idle/waiting/blocked + turn) are event driven and keep
81
+ * working, because "no timer" is not "no state machine" (R4.3).
38
82
  * @param {() => (Date|number)} [options.now] clock injection for tests
39
83
  * @param {Function} [options.touch] `liveTouch` injection for tests
40
84
  * @param {Function} [options.update] `liveUpdate` injection for tests (first
41
85
  * registration of a session that has no row yet)
86
+ * @param {Function} [options.get] `liveGet` injection for tests (turn/end reads
87
+ * the prior declared state so waiting/blocked survive)
88
+ * @param {number} [options.maxRows] live-row cap for rows created by turn
89
+ * boundaries (default 128, DESIGN §2.1)
42
90
  * @param {Function} [options.setInterval] timer injection for tests
43
91
  * @param {Function} [options.clearInterval]
44
92
  * @param {Function} [options.resolve] `(exec) => {id, source}` injection
@@ -51,6 +99,9 @@ export function createAutoHeartbeat(options = {}) {
51
99
  now,
52
100
  touch = liveTouch,
53
101
  update = liveUpdate,
102
+ // Read side, used only to preserve waiting/blocked across turn/end.
103
+ get = liveGet,
104
+ maxRows = MAX_LIVE_ROWS,
54
105
  setInterval: setIntervalFn = globalThis.setInterval,
55
106
  clearInterval: clearIntervalFn = globalThis.clearInterval,
56
107
  resolve = (exec) => resolveCurrentSessionId({ ctx, exec }),
@@ -61,7 +112,9 @@ export function createAutoHeartbeat(options = {}) {
61
112
  ? memoryRoot.trim()
62
113
  : null;
63
114
  const interval = typeof heartbeatMs === 'number' ? heartbeatMs : Number(heartbeatMs);
64
- // Only a finite positive interval enables automatic writes; 0/invalid = off.
115
+ // Only a finite positive interval enables the fallback timer and the
116
+ // tool-call touches; 0/invalid = those two off. Turn-boundary state writes
117
+ // (R4.3) are event driven and deliberately independent of this flag.
65
118
  const enabled = Number.isFinite(interval) && interval > 0;
66
119
 
67
120
  const bound = new Set();
@@ -156,18 +209,82 @@ export function createAutoHeartbeat(options = {}) {
156
209
  return touched;
157
210
  }
158
211
 
212
+ /**
213
+ * Prior row snapshot for a turn boundary: does it exist, and what state does
214
+ * it declare? Read-only; never throws. `known:false` means the read itself
215
+ * failed, in which case we still attempt the write (liveUpdate is atomic and
216
+ * the row keeps its prior values for omitted fields).
217
+ */
218
+ function readPriorRow(id) {
219
+ if (root == null || typeof get !== 'function') return { known: false, found: false, state: null };
220
+ const config = { memoryRoot: root };
221
+ const at = stamp();
222
+ if (at !== undefined) config.now = at;
223
+ try {
224
+ const got = get({ sessionId: id }, config);
225
+ if (got?.ok === true) {
226
+ return { known: true, found: true, state: typeof got.live?.state === 'string' ? got.live.state : null };
227
+ }
228
+ if (got?.status === 'absent') return { known: true, found: false, state: null };
229
+ // corrupt/unreadable-but-present: it occupies a slot; a write repairs it.
230
+ return { known: true, found: true, state: null };
231
+ } catch {
232
+ return { known: false, found: false, state: null };
233
+ }
234
+ }
235
+
236
+ /** True when a NEW row may still be created without exceeding the 128 cap. */
237
+ function hasRowCapacity() {
238
+ if (root == null || typeof liveList !== 'function') return true;
239
+ const config = { memoryRoot: root };
240
+ const at = stamp();
241
+ if (at !== undefined) config.now = at;
242
+ try {
243
+ const listed = liveList({ includeDead: true }, config);
244
+ const scanned = Number.isInteger(listed?.scanned)
245
+ ? listed.scanned
246
+ : (Array.isArray(listed?.entries) ? listed.entries.length : 0);
247
+ return scanned < maxRows;
248
+ } catch {
249
+ // Cannot tell -> do not create rows, but an existing row may be updated.
250
+ return false;
251
+ }
252
+ }
253
+
254
+ /** One best-effort live-row patch at a turn boundary (state + turn + action). */
255
+ function writeTurnBoundary(id, { state, turn, kind, detail }) {
256
+ if (root == null || typeof update !== 'function') return { ok: false, status: 'disabled' };
257
+ const args = { sessionId: id, state, last_action: { kind, detail } };
258
+ if (Number.isInteger(turn) && turn >= 0) args.turn = turn;
259
+ const config = { memoryRoot: root };
260
+ const at = stamp();
261
+ if (at !== undefined) config.now = at;
262
+ try {
263
+ return update(args, config);
264
+ } catch {
265
+ // A state-machine write is decoration for other sessions, never the
266
+ // turn itself: swallow and let the turn proceed.
267
+ return { ok: false, status: 'error' };
268
+ }
269
+ }
270
+
159
271
  function onSessionEvent(session, event) {
160
272
  let id = null;
273
+ let origin = null;
161
274
  try {
162
- id = cleanId(session?.id) ?? cleanId(session?.header?.id);
275
+ id = cleanId(session?.id)
276
+ ?? cleanId(session?.header?.id)
277
+ ?? cleanId(event?.session?.id)
278
+ ?? cleanId(event?.session?.header?.id);
279
+ const header = session?.header ?? session?.session?.header ?? event?.session?.header ?? {};
280
+ origin = typeof header.origin === 'string' ? header.origin : null;
163
281
  } catch {
164
282
  id = null;
165
283
  }
166
284
  // Auto-truncation watches every session in this agent's scope, not only
167
285
  // the ones bound to the live registry: a session that never called a
168
286
  // longlive tool can still grow past the threshold. It runs before the
169
- // `enabled` gate so `heartbeatMs:0` (live writes off) does not also turn
170
- // maintenance off.
287
+ // live-state gate so `heartbeatMs:0` does not also turn maintenance off.
171
288
  if (typeof onTurnEnd === 'function' && event?.type === 'turn/end' && id) {
172
289
  try {
173
290
  onTurnEnd({ session, event, sessionId: id, turn: event?.data?.turn });
@@ -175,15 +292,31 @@ export function createAutoHeartbeat(options = {}) {
175
292
  /* automation is best effort; never fail the host feed */
176
293
  }
177
294
  }
178
- if (!enabled) return;
179
- if (!id || !bound.has(id)) return;
180
295
  const type = event?.type;
296
+ if (!id) return;
181
297
  if (type !== 'turn/start' && type !== 'turn/end') return;
298
+ // R4.6: subagents are counted separately on the board; they never get a
299
+ // live row from the heartbeat (same rule as the Router's L1 writer).
300
+ if (origin === 'subagent') return;
301
+ if (root == null || typeof update !== 'function') return;
182
302
  const turn = event?.data?.turn;
183
- touchOne(id, {
184
- kind: 'turn',
185
- detail: turn == null ? type : `${type} #${turn}`,
186
- });
303
+ // R4.5: the event carries the session id, so turn boundaries maintain
304
+ // EVERY non-subagent session's row — a session that only runs native tools
305
+ // (never calls a longlive tool, so it is never `bind`ed) must still switch
306
+ // state/turn. Create the row on its first boundary, within the 128 cap.
307
+ const prior = readPriorRow(id);
308
+ if (!prior.found && !hasRowCapacity()) return;
309
+ if (type === 'turn/start') {
310
+ // R4.3 状态机:turn/start 立刻让 live 行 state: 'running' 并写入 turn 号
311
+ // (liveUpdate 一次完成;与心跳 tick 无关,tick 只续 heartbeatAt)。
312
+ const plan = turnBoundaryPlan('turn/start', { turn });
313
+ writeTurnBoundary(id, plan);
314
+ return;
315
+ }
316
+ // turn/end: idle/blocked/waiting 都落 turn 号;waiting/blocked 是给用户
317
+ // 看的承诺,turn 结束不能把它抹成 idle。
318
+ const endPlan = turnBoundaryPlan('turn/end', { turn, priorState: prior.state });
319
+ writeTurnBoundary(id, endPlan);
187
320
  }
188
321
 
189
322
  function tick() {
@@ -416,6 +416,41 @@ function allocateBudget(parts, total) {
416
416
  return budgets;
417
417
  }
418
418
 
419
+ /**
420
+ * R6.2: 「状态卡写于 X 分钟前」——X 是 STATE.md mtime 与 now 的差,不是卡片
421
+ * 生成时间,也不是会话开始时间。拿不到 mtime 时返回 ''(宁可不写,不编造)。
422
+ * @param {unknown} ageMs
423
+ * @returns {string}
424
+ */
425
+ export function stateCardAgeText(ageMs) {
426
+ if (typeof ageMs !== 'number' || !Number.isFinite(ageMs)) return '';
427
+ const minutes = Math.max(0, Math.round(ageMs / 60000));
428
+ return `状态卡写于 ${minutes} 分钟前`;
429
+ }
430
+
431
+ /**
432
+ * Resolve the STATE.md age in ms from explicit inputs, falling back to stat()
433
+ * on `statePath`. Missing/unknown age returns null — the card then omits the
434
+ * sentence instead of inventing a number.
435
+ */
436
+ function resolveStateAgeMs(input, safeNow) {
437
+ if (typeof input.stateAgeMs === 'number' && Number.isFinite(input.stateAgeMs)) {
438
+ return Math.max(0, input.stateAgeMs);
439
+ }
440
+ let mtimeMs = typeof input.stateMtimeMs === 'number' && Number.isFinite(input.stateMtimeMs)
441
+ ? input.stateMtimeMs
442
+ : null;
443
+ if (mtimeMs === null && typeof input.statePath === 'string' && input.statePath.trim() !== '') {
444
+ try {
445
+ mtimeMs = fs.statSync(input.statePath).mtimeMs;
446
+ } catch {
447
+ mtimeMs = null;
448
+ }
449
+ }
450
+ if (mtimeMs === null) return null;
451
+ return Math.max(0, safeNow.getTime() - mtimeMs);
452
+ }
453
+
419
454
  /**
420
455
  * 生成开机卡。纯函数:所有输入显式传入,方便覆盖缺失/超长/中文路径等边界。
421
456
  * @param {{
@@ -424,11 +459,14 @@ function allocateBudget(parts, total) {
424
459
  * maxChars?: number,
425
460
  * now?: Date|number|string,
426
461
  * statePath?: string,
462
+ * stateMtimeMs?: number,
463
+ * stateAgeMs?: number,
427
464
  * handoverPath?: string,
428
465
  * }} [input]
429
466
  * @returns {{
430
467
  * text:string, chars:number, truncated:boolean, warnings:string[],
431
468
  * found:Record<string,boolean>, handoverName:string|null,
469
+ * stateAgeMinutes:number|null,
432
470
  * }}
433
471
  */
434
472
  export function buildOpenCard(input = {}) {
@@ -439,6 +477,8 @@ export function buildOpenCard(input = {}) {
439
477
  ? input.now
440
478
  : new Date(input.now ?? Date.now());
441
479
  const safeNow = Number.isFinite(now.getTime()) ? now : new Date();
480
+ const stateAgeMs = resolveStateAgeMs(input, safeNow);
481
+ const stateAgeMinutes = stateAgeMs === null ? null : Math.max(0, Math.round(stateAgeMs / 60000));
442
482
  const warnings = [];
443
483
  const stateText = typeof input.stateText === 'string' ? normalizeText(input.stateText) : '';
444
484
  if (input.stateText === null || input.stateText === undefined) warnings.push('STATE_MISSING');
@@ -458,7 +498,8 @@ export function buildOpenCard(input = {}) {
458
498
  if (!input.handover) warnings.push('HANDOVER_MISSING');
459
499
  else if (handoverText.trim() === '') warnings.push('HANDOVER_EMPTY');
460
500
 
461
- const header = `【开机卡】${formatCardTime(safeNow)}`;
501
+ const ageText = stateCardAgeText(stateAgeMs);
502
+ const header = `【开机卡】${formatCardTime(safeNow)}${ageText === '' ? '' : ` · ${ageText}`}`;
462
503
  const headings = {
463
504
  where: '▍我在哪',
464
505
  waiting: '▍在等什么',
@@ -516,6 +557,7 @@ export function buildOpenCard(input = {}) {
516
557
  warnings,
517
558
  found: { ...parsed.found, handover: handoverText.trim() !== '' },
518
559
  handoverName,
560
+ stateAgeMinutes,
519
561
  };
520
562
  }
521
563
 
@@ -558,6 +600,14 @@ export function collectOpenCard(options = {}) {
558
600
  const errors = [];
559
601
  const stateRead = readTextFile(statePath);
560
602
  if (!stateRead.ok) errors.push(`STATE_READ_FAILED:${stateRead.error}`);
603
+ // R6.2: the open card must say how old the state card is. Read the mtime
604
+ // once here; a missing file leaves it unknown and the sentence is omitted.
605
+ let stateMtimeMs = null;
606
+ try {
607
+ stateMtimeMs = fs.statSync(statePath).mtimeMs;
608
+ } catch {
609
+ stateMtimeMs = null;
610
+ }
561
611
 
562
612
  let handover = null;
563
613
  let handoverPath = null;
@@ -593,6 +643,7 @@ export function collectOpenCard(options = {}) {
593
643
  maxChars: options.maxChars,
594
644
  now: options.now,
595
645
  statePath,
646
+ stateMtimeMs,
596
647
  handoverPath: handoverPath ?? undefined,
597
648
  });
598
649
 
package/src/live.js CHANGED
@@ -82,6 +82,9 @@ export const LIVE_RECORD_FIELDS = Object.freeze([
82
82
  'state',
83
83
  'turn',
84
84
  'goal',
85
+ // R3: the clock of the *goal*, not of the row. A heartbeat touch must never
86
+ // move it (see liveTouch), and the board renders focus.updatedAt from it.
87
+ 'goalUpdatedAt',
85
88
  'blocked_on',
86
89
  'queued',
87
90
  'last_action',
@@ -410,6 +413,9 @@ function decodeRecord(text, expectedSessionId) {
410
413
  }
411
414
  const updatedMs = parseTime(raw.updatedAt) ?? heartbeatMs;
412
415
  const startedMs = parseTime(raw.startedAt) ?? heartbeatMs;
416
+ // R3 additive field: rows written before this version simply have no goal
417
+ // clock; keep it as '' (unknown) instead of inventing one from heartbeatAt.
418
+ const goalUpdatedMs = parseTime(raw.goalUpdatedAt);
413
419
  if (!Array.isArray(raw.queued) || !raw.queued.every((item) => typeof item === 'string')) {
414
420
  return { ok: false, reason: 'queued 必须是字符串数组' };
415
421
  }
@@ -427,6 +433,7 @@ function decodeRecord(text, expectedSessionId) {
427
433
  state: raw.state,
428
434
  turn: Number.isInteger(raw.turn) && raw.turn >= 0 ? raw.turn : 0,
429
435
  goal: typeof raw.goal === 'string' ? raw.goal : '',
436
+ goalUpdatedAt: goalUpdatedMs != null ? isoTime(goalUpdatedMs) : '',
430
437
  blocked_on: typeof raw.blocked_on === 'string' ? raw.blocked_on : '',
431
438
  queued: [...raw.queued],
432
439
  last_action: normalizeLastAction(raw.last_action, updatedMs),
@@ -485,6 +492,9 @@ function liveEntry(record, { file, root, now, hostname }) {
485
492
  pidAlive: alive,
486
493
  turn: record.turn,
487
494
  goal: record.goal,
495
+ // R3: the goal write clock (ISO) or null when the row never declared one.
496
+ // Never derived from heartbeatAt — that is exactly the lie R3 fixes.
497
+ goalUpdatedAt: record.goalUpdatedAt === '' ? null : record.goalUpdatedAt,
488
498
  blocked_on: record.blocked_on,
489
499
  queued: record.queued,
490
500
  last_action: record.last_action,
@@ -513,6 +523,7 @@ function corruptEntry({ sessionId, file, root, reason, hostHint = null, hostname
513
523
  pidAlive: null,
514
524
  turn: null,
515
525
  goal: '',
526
+ goalUpdatedAt: null,
516
527
  blocked_on: '',
517
528
  queued: [],
518
529
  last_action: null,
@@ -852,6 +863,12 @@ export function liveUpdate(args = {}, config = {}) {
852
863
  const base = prior.record;
853
864
  const created = base == null;
854
865
  const goal = fit ? fit.value : (base?.goal ?? '');
866
+ // R3: `goalUpdatedAt` is the goal's own clock. Only a write that supplies a
867
+ // goal stamps it; patches and heartbeat touches keep the previous value, so
868
+ // "目标 30 分钟没更新" can be told apart from "心跳 5 秒前刚动过".
869
+ const goalUpdatedAt = goalInput !== undefined
870
+ ? isoTime(now)
871
+ : (base?.goalUpdatedAt ?? '');
855
872
  // blocked_on describes a blocked session; anything else clears it, so a
856
873
  // running row cannot keep advertising an obstacle nobody is waiting on.
857
874
  const priorBlockedOn = base?.blocked_on ?? '';
@@ -873,6 +890,7 @@ export function liveUpdate(args = {}, config = {}) {
873
890
  state,
874
891
  turn: turn ?? base?.turn ?? 0,
875
892
  goal,
893
+ goalUpdatedAt,
876
894
  blocked_on: blockedOn,
877
895
  queued: queued.value,
878
896
  last_action: lastAction.value,
@@ -940,8 +958,10 @@ export function liveUpdate(args = {}, config = {}) {
940
958
  * - it never creates a row (`status:'absent'` instead), so merely running a
941
959
  * longlive tool does not fabricate a session row;
942
960
  * - it never repairs/lists corrupt rows (`status:'corrupt'`, bytes untouched);
943
- * - it preserves `state`, `goal`, `blocked_on`, `queued`, `turn`, `startedAt`
944
- * and `ttlMs`: heartbeat says "this writer is still here", not "state changed";
961
+ * - it preserves `state`, `goal`, `goalUpdatedAt`, `blocked_on`, `queued`,
962
+ * `turn`, `startedAt` and `ttlMs`: heartbeat says "this writer is still
963
+ * here", not "state changed". In particular the goal's own clock must not
964
+ * move, or the board could no longer tell a stale goal from a live one;
945
965
  * - `lastAction:false` keeps `last_action` exactly as-is (the timer tick);
946
966
  * otherwise only the action stamp is replaced, bounded like every other write.
947
967
  */
@@ -8,11 +8,20 @@
8
8
  * 为什么用 createRequire 而不是静态 import:
9
9
  * - auto-recall 的核心要保持「零 import、可单测」;
10
10
  * - 隐私门是 ESM,但 Node ≥22 支持同步 require(ESM)(本机 v24.21.0 已验证);
11
- * - 这样门缺失时模块仍能加载,退到核心自带的保守 PII 兜底,绝不因「门没装」而放行。
11
+ * - 这样门缺失时模块仍能加载,退到核心自带的保守兜底,绝不因「门没装」而放行。
12
12
  *
13
13
  * 命中口径 = 隐私门的 verdict==='local_only' **或** 核心兜底正则命中(取并集,宁可多拦)。
14
- * 调用方:bin/eval.mjs、bin/eval-fresh.mjs、bin/recall-once.mjs、plugin/src/index.js
15
- * 以及 test/auto-recall.test.mjs(证明真门被调用)。
14
+ * 兜底分两类:
15
+ * - PII:手机号 / 邮箱 / 身份证(1.6.7 起就有);
16
+ * - 密钥/令牌(1.6.8 红队 S3 补):sk-*、Bearer …、*_API_KEY=*、token=/secret=/password=、
17
+ * 私钥头、AWS/GitHub/Slack 凭证前缀。活动板是跨会话共享的,
18
+ * 手机号只泄漏一个人的联系方式,密钥泄漏的是账号本身。
19
+ *
20
+ * 发布形态下(没有 privacy-gate 仓库)只靠兜底;DSH_PRIVACY_GATE 显式配置但不可达时
21
+ * 在加载期 `process.emitWarning` 一次,并强制走强化后的本地规则 —— 不允许静默放行。
22
+ *
23
+ * 调用方:bin/eval.mjs、bin/eval-fresh.mjs、bin/recall-once.mjs、plugin/src/index.js、
24
+ * src/activity/{store,index}.js 以及 test/auto-recall.test.mjs(证明真门被调用)。
16
25
  */
17
26
 
18
27
  import { createRequire } from 'node:module';
@@ -22,15 +31,20 @@ import { fileURLToPath } from 'node:url';
22
31
  const HERE = path.dirname(fileURLToPath(import.meta.url));
23
32
  const require = createRequire(import.meta.url);
24
33
 
34
+ const ENV_GATE = typeof process.env.DSH_PRIVACY_GATE === 'string' && process.env.DSH_PRIVACY_GATE.trim() !== ''
35
+ ? process.env.DSH_PRIVACY_GATE.trim()
36
+ : null;
37
+
25
38
  /** 环境变量优先;其次是作者树旁边的 privacy-gate;最后是绝对路径兜底。 */
26
39
  export const GATE_CANDIDATES = [
27
- process.env.DSH_PRIVACY_GATE,
40
+ ENV_GATE,
28
41
  path.resolve(HERE, '../../../privacy-gate/src/memory/privacy-gate.js'),
29
42
  ].filter((p) => typeof p === 'string' && p.trim() !== '');
30
43
 
31
44
  let realScan = null;
32
45
  let loadedFrom = null;
33
46
  let loadError = null;
47
+ const loadErrors = [];
34
48
  for (const candidate of GATE_CANDIDATES) {
35
49
  try {
36
50
  const mod = require(candidate);
@@ -39,44 +53,116 @@ for (const candidate of GATE_CANDIDATES) {
39
53
  loadedFrom = candidate;
40
54
  break;
41
55
  }
42
- loadError = `scanPrivacy not exported by ${candidate}`;
56
+ loadErrors.push(`scanPrivacy not exported by ${candidate}`);
43
57
  } catch (error) {
44
- loadError = `${candidate}: ${error?.code ?? error?.message ?? error}`;
58
+ loadErrors.push(`${candidate}: ${error?.code ?? error?.message ?? error}`);
59
+ }
60
+ }
61
+ loadError = loadErrors.length > 0 ? loadErrors[loadErrors.length - 1] : null;
62
+ // Diagnostics should name the explicitly configured gate when that is the one
63
+ // that failed — not the author-tree fallback that was tried after it.
64
+ if (ENV_GATE !== null) {
65
+ const envFailure = loadErrors.find((entry) => entry.startsWith(ENV_GATE));
66
+ if (envFailure) loadError = envFailure;
67
+ }
68
+
69
+ /** 显式配置了门却装不上:不静默放行,加载期告警一次,扫描强制走强化本地规则。 */
70
+ const gateConfigured = ENV_GATE !== null;
71
+ const forcedFallback = gateConfigured && realScan === null;
72
+ if (forcedFallback) {
73
+ try {
74
+ process.emitWarning(
75
+ `[longlive:privacy] DSH_PRIVACY_GATE 已配置但不可达(${loadError ?? 'unknown'})`
76
+ + ';已强制使用强化本地兜底规则(含密钥/令牌模式),不会静默放行。',
77
+ );
78
+ } catch {
79
+ /* warning is best effort; the scan below is what enforces the rule */
45
80
  }
46
81
  }
47
82
 
48
- /* 核心兜底:只认三类最硬的 PII,避免把普通数字(token 数、ID 号)误判。 */
83
+ /* 核心兜底 · 一类最硬的 PII:避免把普通数字(token 数、ID 号)误判。 */
49
84
  const PII_PATTERNS = [
50
85
  { kind: 'phone', re: /(?<!\d)1[3-9]\d{9}(?!\d)/g },
51
86
  { kind: 'email', re: /[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g },
52
87
  { kind: 'id-card', re: /(?<!\d)\d{17}[\dXx](?!\d)/g },
53
88
  ];
54
89
 
55
- /** 核心自带的保守扫描(不依赖任何外部文件)。 */
56
- export function fallbackScan(text) {
57
- const s = String(text ?? '');
58
- const spans = [];
59
- for (const { kind, re } of PII_PATTERNS) {
90
+ /*
91
+ * 核心兜底 · 密钥/令牌类。全部要求「像凭证」才拦,尽量不误伤普通文本:
92
+ * - sk- 前缀要求前面不是标识符字符(`task-id-1234567890` 里的 `sk-id-…` 不算);
93
+ * - `token=`/`api_key=` 这类赋值要求至少 6–8 位值,`token 数 12`、`--test` 不会命中;
94
+ * - 赋值形态允许 `[a-z0-9_]{0,32}` 前缀(RE-S3.1:`my_api_key: …`、`apiKey=…`、
95
+ * `secret token: …`),分隔符允许 `= : : ,` 与两侧空格;**纯空格不算分隔符**,
96
+ * 所以 `password reset flow` / `password 字段在 config.js 里` 不误伤;
97
+ * - PRIVATE KEY 头、AWS/GitHub/Slack 前缀是强特征,直接拦。
98
+ */
99
+ const ASSIGN_VALUE = String.raw`['"]?[A-Za-z0-9._\-/+!@#$%^&*]{6,}`;
100
+ const SECRET_PATTERNS = [
101
+ // RE-S3.7: sk-/SK- 前缀大小写不敏感(连续形式;空格/Unicode 变体是 P3 残余建议)。
102
+ { kind: 'api-key', re: /(?<![A-Za-z0-9_-])sk-[A-Za-z0-9][A-Za-z0-9_-]{8,}/gi },
103
+ { kind: 'api-key', re: /(?<![A-Za-z0-9_])[A-Z][A-Z0-9]*(?:_[A-Z0-9]+)*(?:_API_KEY|_APIKEY|_KEY|_TOKEN|_SECRET|_PASSWORD|_PASSWD)\s*[=:]\s*\S+/g },
104
+ // RE-S3.1: 小写/带前缀的赋值形态(大小写不敏感):my_api_key: "…"、apiKey=…。
105
+ {
106
+ kind: 'api-key',
107
+ re: new RegExp(
108
+ String.raw`(?<![A-Za-z0-9])[A-Za-z0-9_]{0,32}`
109
+ + String.raw`(?:api[_-]?key|access[_-]?key|secret[_-]?key|private[_-]?key|app[_-]?key|token|secret|password|passwd|pwd|credential)`
110
+ + String.raw`\s*[:=:,]\s*` + ASSIGN_VALUE,
111
+ 'gi',
112
+ ),
113
+ },
114
+ // RE-S3.1: 多词标签(允许空格/下划线):secret token: …、api key = …。
115
+ {
116
+ kind: 'token',
117
+ re: new RegExp(
118
+ String.raw`(?<![A-Za-z0-9])(?:api|access|secret|private|auth|refresh|session)[\s_-]+(?:key|token|secret|password)`
119
+ + String.raw`\s*[:=:,]\s*` + ASSIGN_VALUE,
120
+ 'gi',
121
+ ),
122
+ },
123
+ { kind: 'api-key', re: /(?<![A-Za-z0-9_])(?:api[_-]?key|access[_-]?key|secret[_-]?key|private[_-]?key|app[_-]?key)\s*[=:]\s*['"]?[A-Za-z0-9._\-/+]{8,}/gi },
124
+ { kind: 'token', re: /(?<![A-Za-z0-9_])(?:token|auth|access[_-]?token|refresh[_-]?token|session[_-]?token)\s*[=:]\s*['"]?[A-Za-z0-9._\-/+]{8,}/gi },
125
+ { kind: 'password', re: /(?<![A-Za-z0-9_])(?:password|passwd|pwd)\s*[=:]\s*['"]?[A-Za-z0-9._\-/+!@#$%^&*]{6,}/gi },
126
+ { kind: 'bearer', re: /\bBearer\s+[A-Za-z0-9._~+/-]{12,}=*/gi },
127
+ { kind: 'private-key', re: /-----BEGIN (?:[A-Z0-9]+ )*PRIVATE KEY-----/g },
128
+ { kind: 'aws-key', re: /(?<![A-Z0-9])(?:AKIA|ASIA)[0-9A-Z]{16}(?![0-9A-Z])/g },
129
+ { kind: 'github-token', re: /\bgh[pousr]_[A-Za-z0-9]{20,}/g },
130
+ { kind: 'slack-token', re: /\bxox[baprs]-[A-Za-z0-9-]{10,}/g },
131
+ ];
132
+
133
+ function scanWith(patterns, text, spans) {
134
+ for (const { kind, re } of patterns) {
60
135
  re.lastIndex = 0;
61
- let m = re.exec(s);
136
+ let m = re.exec(text);
62
137
  while (m !== null) {
63
138
  spans.push({ start: m.index, end: m.index + m[0].length, kind });
64
139
  if (m.index === re.lastIndex) re.lastIndex += 1;
65
- m = re.exec(s);
140
+ m = re.exec(text);
66
141
  }
67
142
  }
143
+ }
144
+
145
+ /** 核心自带的保守扫描(不依赖任何外部文件):PII + 凭证类,两类都算 local_only。 */
146
+ export function fallbackScan(text) {
147
+ const s = String(text ?? '');
148
+ const spans = [];
149
+ scanWith(PII_PATTERNS, s, spans);
150
+ scanWith(SECRET_PATTERNS, s, spans);
68
151
  spans.sort((a, b) => a.start - b.start);
69
- if (spans.length === 0) return { verdict: 'none', spans: [], reasons: [], source: 'core-fallback' };
152
+ if (spans.length === 0) {
153
+ return { verdict: 'none', spans: [], reasons: [], source: forcedFallback ? 'core-fallback:forced' : 'core-fallback' };
154
+ }
70
155
  return {
71
156
  verdict: 'local_only',
72
157
  spans,
73
158
  reasons: [...new Set(spans.map((x) => x.kind))],
74
- source: 'core-fallback',
159
+ source: forcedFallback ? 'core-fallback:forced' : 'core-fallback',
75
160
  };
76
161
  }
77
162
 
78
163
  /**
79
164
  * 注入前的隐私扫描(同步)。
165
+ * - 核心兜底(含凭证类)先扫,命中直接 local_only;
80
166
  * - 隐私门可用 → 调它;门抛错 → fail-closed;
81
167
  * - 门说 none 但兜底正则命中 → 仍按命中处理(并集)。
82
168
  * @param {string} text
@@ -113,6 +199,9 @@ export const privacyGateStatus = Object.freeze({
113
199
  loaded: realScan !== null,
114
200
  from: loadedFrom,
115
201
  error: realScan === null ? loadError : null,
202
+ configured: gateConfigured,
203
+ forcedFallback,
204
+ scope: 'pii+secret',
116
205
  });
117
206
 
118
207
  /** 给 chooseRecall 用的一次性参数:`options: { ...privacyDefaults }`。 */