@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.
- package/CHANGELOG.md +40 -4
- package/QUICKSTART.md +10 -10
- package/README.en.md +4 -3
- package/README.md +10 -4
- package/SPEC.md +2 -2
- package/cordis.patch.yml +9 -0
- package/docs/ARCHITECTURE.md +3 -2
- package/docs/CONFIG.md +33 -6
- package/docs/DEV.md +3 -3
- package/docs/FAQ.md +4 -4
- package/docs/FEATURES.md +36 -5
- package/docs/OPERATIONS.md +1 -1
- package/docs/QUICKSTART.md +3 -3
- package/docs/README.en.md +3 -2
- package/docs/TROUBLESHOOTING.md +3 -3
- package/package.json +13 -8
- package/src/activity/index.js +649 -0
- package/src/activity/model.js +341 -0
- package/src/activity/store.js +625 -0
- package/src/compaction/engine-core.js +144 -97
- package/src/config.js +29 -2
- package/src/features.js +26 -0
- package/src/heartbeat.js +156 -23
- package/src/identity/handover.js +52 -1
- package/src/live.js +22 -2
- package/src/recall/privacy.mjs +105 -16
- package/src/router/service/board.mjs +95 -15
- package/src/router/service/hint.mjs +14 -0
- package/src/router/service/service.mjs +27 -11
- package/test/activity-realtime.test.mjs +959 -0
- package/test/activity-stream.test.mjs +512 -0
- package/test/autobind.test.mjs +25 -11
- package/test/config-schema.test.mjs +6 -0
- package/test/feature-flags.test.mjs +17 -0
- package/test/fixtures/activity-append-child.mjs +29 -0
- package/test/hot-truncate.test.mjs +113 -0
- package/test/live.test.mjs +6 -3
- package/test/packaging.test.mjs +7 -5
- package/test/router-background/router.test.mjs +44 -12
- package/test/run-rc.mjs +16 -0
- package/test/safety-defaults.test.mjs +5 -3
- package/test/state-truth.test.mjs +937 -0
- 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
|
|
4
|
-
// 1. turn lifecycle — `session/event` with `turn/start` / `turn/end
|
|
5
|
-
// session
|
|
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
|
-
//
|
|
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
|
-
//
|
|
10
|
-
// `
|
|
11
|
-
//
|
|
12
|
-
//
|
|
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
|
|
15
|
-
// racing writer must never fail a tool call or crash the
|
|
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
|
|
37
|
-
*
|
|
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
|
|
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)
|
|
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
|
-
//
|
|
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
|
-
|
|
184
|
-
|
|
185
|
-
|
|
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() {
|
package/src/identity/handover.js
CHANGED
|
@@ -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
|
|
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`, `
|
|
944
|
-
* and `ttlMs`: heartbeat says "this writer is still
|
|
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
|
*/
|
package/src/recall/privacy.mjs
CHANGED
|
@@ -8,11 +8,20 @@
|
|
|
8
8
|
* 为什么用 createRequire 而不是静态 import:
|
|
9
9
|
* - auto-recall 的核心要保持「零 import、可单测」;
|
|
10
10
|
* - 隐私门是 ESM,但 Node ≥22 支持同步 require(ESM)(本机 v24.21.0 已验证);
|
|
11
|
-
* -
|
|
11
|
+
* - 这样门缺失时模块仍能加载,退到核心自带的保守兜底,绝不因「门没装」而放行。
|
|
12
12
|
*
|
|
13
13
|
* 命中口径 = 隐私门的 verdict==='local_only' **或** 核心兜底正则命中(取并集,宁可多拦)。
|
|
14
|
-
*
|
|
15
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
56
|
+
loadErrors.push(`scanPrivacy not exported by ${candidate}`);
|
|
43
57
|
} catch (error) {
|
|
44
|
-
|
|
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
|
-
/*
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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(
|
|
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(
|
|
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)
|
|
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 }`。 */
|