@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
|
@@ -0,0 +1,625 @@
|
|
|
1
|
+
// activity/store.js — the activity board's file layer (1.6.1).
|
|
2
|
+
//
|
|
3
|
+
// One append-only JSONL board for every session:
|
|
4
|
+
// <memoryRoot>/.longlive/board/activity.jsonl
|
|
5
|
+
// <memoryRoot>/.longlive/board/activity-YYYYMM.jsonl (overflow archive)
|
|
6
|
+
//
|
|
7
|
+
// Every append is a read-modify-write under a cross-process lock, then an
|
|
8
|
+
// atomic tmp -> fsync -> read-back -> rename. That combination is what makes
|
|
9
|
+
// two sessions writing at the same time produce unique, contiguous seq values
|
|
10
|
+
// instead of a lost update. The recent window is bounded (500 rows by default);
|
|
11
|
+
// old rows move to the monthly archive in the same transition so the board
|
|
12
|
+
// never grows without limit.
|
|
13
|
+
//
|
|
14
|
+
// The store never throws on an IO failure: it returns {ok:false, code}. The
|
|
15
|
+
// caller (index.js) is required to swallow that and let the turn continue.
|
|
16
|
+
|
|
17
|
+
import crypto from 'node:crypto';
|
|
18
|
+
import fs from 'node:fs';
|
|
19
|
+
import path from 'node:path';
|
|
20
|
+
|
|
21
|
+
import { privacyScan as defaultPrivacyScan } from '../recall/privacy.mjs';
|
|
22
|
+
import {
|
|
23
|
+
ACTIVITY_KINDS,
|
|
24
|
+
ACTIVITY_TEXT_MAX,
|
|
25
|
+
DEDUPE_WINDOW,
|
|
26
|
+
DISTILL_MIN_COUNT,
|
|
27
|
+
DISTILL_TOTAL_COUNT,
|
|
28
|
+
SIMILARITY_THRESHOLD,
|
|
29
|
+
clip,
|
|
30
|
+
distillActivityText,
|
|
31
|
+
normalizeKey,
|
|
32
|
+
normalizeText,
|
|
33
|
+
oneLine,
|
|
34
|
+
similarity,
|
|
35
|
+
} from './model.js';
|
|
36
|
+
|
|
37
|
+
export const ACTIVITY_SCHEMA_VERSION = 1;
|
|
38
|
+
export const ACTIVITY_BOARD_DIR = 'board';
|
|
39
|
+
export const ACTIVITY_FILE_NAME = 'activity.jsonl';
|
|
40
|
+
export const ACTIVITY_RECENT_MAX = 500;
|
|
41
|
+
export const ACTIVITY_LOCK_STALE_MS = 10000;
|
|
42
|
+
export const ACTIVITY_LOCK_TIMEOUT_MS = 2000;
|
|
43
|
+
export const ACTIVITY_SESSION_RE = /^[a-z0-9][a-z0-9._-]{0,127}$/;
|
|
44
|
+
/** Monotonic seq high-water mark: survives board deletion/compaction. */
|
|
45
|
+
export const ACTIVITY_SEQ_HIGH_WATER_FILE = '.activity.seq';
|
|
46
|
+
/** Written when the store detects the board was reset (deleted/emptied). */
|
|
47
|
+
export const ACTIVITY_RESET_MARKER_FILE = 'stream.reset.json';
|
|
48
|
+
|
|
49
|
+
const ARCHIVE_RE = /^activity-(\d{6})\.jsonl$/;
|
|
50
|
+
|
|
51
|
+
export function activityBoardDir(memoryRoot) {
|
|
52
|
+
return path.join(String(memoryRoot ?? ''), '.longlive', ACTIVITY_BOARD_DIR);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function activityBoardFile(memoryRoot) {
|
|
56
|
+
return path.join(activityBoardDir(memoryRoot), ACTIVITY_FILE_NAME);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** `<board>/.activity.seq` — the seq high-water sidecar (RE-R15.5). */
|
|
60
|
+
export function activitySeqHighWaterFile(memoryRoot) {
|
|
61
|
+
return path.join(activityBoardDir(memoryRoot), ACTIVITY_SEQ_HIGH_WATER_FILE);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** `<board>/stream.reset.json` — last detected board-reset evidence (RE-R15.5). */
|
|
65
|
+
export function activityResetMarkerFile(memoryRoot) {
|
|
66
|
+
return path.join(activityBoardDir(memoryRoot), ACTIVITY_RESET_MARKER_FILE);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function activityArchiveFile(memoryRoot, monthKey) {
|
|
70
|
+
return path.join(activityBoardDir(memoryRoot), `activity-${monthKey}.jsonl`);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function monthKeyFor(value) {
|
|
74
|
+
const date = value instanceof Date ? value : new Date(value ?? Date.now());
|
|
75
|
+
if (!Number.isFinite(date.getTime())) return '000000';
|
|
76
|
+
const year = date.getFullYear();
|
|
77
|
+
const month = String(date.getMonth() + 1).padStart(2, '0');
|
|
78
|
+
return `${year}${month}`;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function sha256Hex(value) {
|
|
82
|
+
return crypto.createHash('sha256').update(String(value ?? ''), 'utf8').digest('hex');
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function countOf(entry) {
|
|
86
|
+
const count = Number(entry?.count);
|
|
87
|
+
return Number.isInteger(count) && count > 0 ? count : 1;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function isObject(value) {
|
|
91
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Parse JSONL defensively; bad lines are counted, never thrown. */
|
|
95
|
+
export function parseActivityJsonl(text) {
|
|
96
|
+
const entries = [];
|
|
97
|
+
let bad = 0;
|
|
98
|
+
for (const line of String(text ?? '').replace(/^\uFEFF/, '').split(/\r?\n/)) {
|
|
99
|
+
if (line.trim() === '') continue;
|
|
100
|
+
let row = null;
|
|
101
|
+
try {
|
|
102
|
+
row = JSON.parse(line);
|
|
103
|
+
} catch {
|
|
104
|
+
bad += 1;
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
if (!isObject(row) || !Number.isInteger(row.seq) || typeof row.text !== 'string' || typeof row.sessionId !== 'string') {
|
|
108
|
+
bad += 1;
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
entries.push(row);
|
|
112
|
+
}
|
|
113
|
+
entries.sort((left, right) => left.seq - right.seq);
|
|
114
|
+
return { entries, bad };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function serializeActivityEntries(entries) {
|
|
118
|
+
return entries.map((entry) => JSON.stringify(entry)).join('\n') + (entries.length > 0 ? '\n' : '');
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function sleepSync(ms) {
|
|
122
|
+
try {
|
|
123
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
124
|
+
} catch {
|
|
125
|
+
/* fall through: a busy loop is still bounded by the deadline */
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function writeTextDurable(file, text) {
|
|
130
|
+
const dir = path.dirname(file);
|
|
131
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
132
|
+
const tmp = path.join(dir, `.${path.basename(file)}.${process.pid}.${crypto.randomBytes(4).toString('hex')}.tmp`);
|
|
133
|
+
const fd = fs.openSync(tmp, 'w');
|
|
134
|
+
try {
|
|
135
|
+
fs.writeFileSync(fd, text, 'utf8');
|
|
136
|
+
try {
|
|
137
|
+
fs.fsyncSync(fd);
|
|
138
|
+
} catch {
|
|
139
|
+
/* fsync is best effort on some filesystems */
|
|
140
|
+
}
|
|
141
|
+
} finally {
|
|
142
|
+
try {
|
|
143
|
+
fs.closeSync(fd);
|
|
144
|
+
} catch {
|
|
145
|
+
/* ignore */
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
let lastError = null;
|
|
149
|
+
for (let attempt = 0; attempt < 5; attempt += 1) {
|
|
150
|
+
try {
|
|
151
|
+
const readBack = fs.readFileSync(tmp, 'utf8');
|
|
152
|
+
if (readBack !== text) throw new Error('activity write read-back mismatch');
|
|
153
|
+
fs.renameSync(tmp, file);
|
|
154
|
+
return { ok: true };
|
|
155
|
+
} catch (error) {
|
|
156
|
+
lastError = error;
|
|
157
|
+
if (attempt < 4) sleepSync(10 * (attempt + 1));
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
try {
|
|
161
|
+
fs.rmSync(tmp, { force: true });
|
|
162
|
+
} catch {
|
|
163
|
+
/* ignore */
|
|
164
|
+
}
|
|
165
|
+
return { ok: false, error: lastError?.code ?? lastError?.message ?? 'E_WRITE' };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Atomic JSON write used by the per-session lastSeenSeq state. */
|
|
169
|
+
export function writeJsonAtomic(file, value) {
|
|
170
|
+
const text = `${JSON.stringify(value, null, 2)}\n`;
|
|
171
|
+
try {
|
|
172
|
+
const written = writeTextDurable(file, text);
|
|
173
|
+
return written.ok ? { ok: true, file, bytes: Buffer.byteLength(text, 'utf8') } : written;
|
|
174
|
+
} catch (error) {
|
|
175
|
+
return { ok: false, file, error: error?.code ?? error?.message ?? 'E_WRITE' };
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export function readJsonFile(file) {
|
|
180
|
+
try {
|
|
181
|
+
return { ok: true, value: JSON.parse(fs.readFileSync(file, 'utf8')) };
|
|
182
|
+
} catch (error) {
|
|
183
|
+
return { ok: false, error: error?.code ?? 'E_READ' };
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Write/read the whole recent board under `<board>/.activity.lock`. Returns
|
|
189
|
+
* `{ok:false,code:'E_LOCK_TIMEOUT'}` instead of blocking a turn forever.
|
|
190
|
+
*/
|
|
191
|
+
function withLock(memoryRoot, fn) {
|
|
192
|
+
const dir = activityBoardDir(memoryRoot);
|
|
193
|
+
try {
|
|
194
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
195
|
+
} catch (error) {
|
|
196
|
+
return { ok: false, code: 'E_MKDIR', message: error?.message ?? String(error) };
|
|
197
|
+
}
|
|
198
|
+
const lockFile = path.join(dir, '.activity.lock');
|
|
199
|
+
const started = Date.now();
|
|
200
|
+
let fd = null;
|
|
201
|
+
while (fd === null) {
|
|
202
|
+
try {
|
|
203
|
+
fd = fs.openSync(lockFile, 'wx');
|
|
204
|
+
} catch (error) {
|
|
205
|
+
if (error?.code !== 'EEXIST') {
|
|
206
|
+
return { ok: false, code: 'E_LOCK', message: error?.code ?? String(error) };
|
|
207
|
+
}
|
|
208
|
+
try {
|
|
209
|
+
const stat = fs.statSync(lockFile);
|
|
210
|
+
if (Date.now() - stat.mtimeMs > ACTIVITY_LOCK_STALE_MS) {
|
|
211
|
+
fs.rmSync(lockFile, { force: true });
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
} catch {
|
|
215
|
+
/* the other writer may have released it between open and stat */
|
|
216
|
+
}
|
|
217
|
+
if (Date.now() - started > ACTIVITY_LOCK_TIMEOUT_MS) {
|
|
218
|
+
return { ok: false, code: 'E_LOCK_TIMEOUT' };
|
|
219
|
+
}
|
|
220
|
+
sleepSync(10);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
try {
|
|
224
|
+
try {
|
|
225
|
+
fs.writeSync(fd, `${process.pid} ${Date.now()}\n`);
|
|
226
|
+
} catch {
|
|
227
|
+
/* the lock file's existence is what matters */
|
|
228
|
+
}
|
|
229
|
+
return fn();
|
|
230
|
+
} finally {
|
|
231
|
+
try {
|
|
232
|
+
fs.closeSync(fd);
|
|
233
|
+
} catch {
|
|
234
|
+
/* ignore */
|
|
235
|
+
}
|
|
236
|
+
try {
|
|
237
|
+
fs.rmSync(lockFile, { force: true });
|
|
238
|
+
} catch {
|
|
239
|
+
/* the stale lock expires on its own */
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function readEntries(memoryRoot) {
|
|
245
|
+
const file = activityBoardFile(memoryRoot);
|
|
246
|
+
let text = '';
|
|
247
|
+
try {
|
|
248
|
+
text = fs.readFileSync(file, 'utf8');
|
|
249
|
+
} catch (error) {
|
|
250
|
+
if (error?.code === 'ENOENT') return { ok: true, entries: [], bad: 0, exists: false };
|
|
251
|
+
return { ok: false, code: error?.code ?? 'E_READ', entries: [], bad: 0, exists: false };
|
|
252
|
+
}
|
|
253
|
+
const parsed = parseActivityJsonl(text);
|
|
254
|
+
return { ok: true, entries: parsed.entries, bad: parsed.bad, exists: true };
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function archivedMaxSeq(memoryRoot) {
|
|
258
|
+
let names = [];
|
|
259
|
+
try {
|
|
260
|
+
names = fs.readdirSync(activityBoardDir(memoryRoot));
|
|
261
|
+
} catch {
|
|
262
|
+
return 0;
|
|
263
|
+
}
|
|
264
|
+
const archives = names.filter((name) => ARCHIVE_RE.test(name)).sort().reverse();
|
|
265
|
+
for (const name of archives) {
|
|
266
|
+
const file = path.join(activityBoardDir(memoryRoot), name);
|
|
267
|
+
try {
|
|
268
|
+
const stat = fs.statSync(file);
|
|
269
|
+
const readBytes = Math.min(stat.size, 128 * 1024);
|
|
270
|
+
const fd = fs.openSync(file, 'r');
|
|
271
|
+
const buffer = Buffer.alloc(readBytes);
|
|
272
|
+
try {
|
|
273
|
+
fs.readSync(fd, buffer, 0, readBytes, stat.size - readBytes);
|
|
274
|
+
} finally {
|
|
275
|
+
fs.closeSync(fd);
|
|
276
|
+
}
|
|
277
|
+
let text = buffer.toString('utf8');
|
|
278
|
+
if (readBytes < stat.size) text = text.slice(text.indexOf('\n') + 1);
|
|
279
|
+
const lines = text.split(/\r?\n/).filter((line) => line.trim() !== '');
|
|
280
|
+
for (let index = lines.length - 1; index >= 0; index -= 1) {
|
|
281
|
+
try {
|
|
282
|
+
const row = JSON.parse(lines[index]);
|
|
283
|
+
if (Number.isInteger(row?.seq)) return row.seq;
|
|
284
|
+
} catch {
|
|
285
|
+
/* keep scanning backwards */
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
} catch {
|
|
289
|
+
/* try the previous archive month */
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
return 0;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function nextSeq(entries, memoryRoot, floorSeq = 0) {
|
|
296
|
+
let max = Number.isInteger(floorSeq) && floorSeq > 0 ? floorSeq : 0;
|
|
297
|
+
for (const entry of entries) {
|
|
298
|
+
if (Number.isInteger(entry.seq) && entry.seq > max) max = entry.seq;
|
|
299
|
+
}
|
|
300
|
+
if (max === 0) max = archivedMaxSeq(memoryRoot);
|
|
301
|
+
return max + 1;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/** Read the seq high-water sidecar; 0 when absent/corrupt (best effort). */
|
|
305
|
+
export function readSeqHighWater(memoryRoot) {
|
|
306
|
+
const read = readJsonFile(activitySeqHighWaterFile(memoryRoot));
|
|
307
|
+
const value = read.ok ? read.value : null;
|
|
308
|
+
return Number.isInteger(value?.seq) && value.seq > 0 ? value.seq : 0;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function persistSeqHighWater(memoryRoot, seq, at) {
|
|
312
|
+
const current = readSeqHighWater(memoryRoot);
|
|
313
|
+
if (seq <= current) return;
|
|
314
|
+
try {
|
|
315
|
+
writeJsonAtomic(activitySeqHighWaterFile(memoryRoot), { v: 1, seq, updatedAt: at });
|
|
316
|
+
} catch {
|
|
317
|
+
/* the board remains correct; only the reset protection degrades */
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function writeResetMarker(memoryRoot, marker) {
|
|
322
|
+
try {
|
|
323
|
+
writeJsonAtomic(activityResetMarkerFile(memoryRoot), marker);
|
|
324
|
+
} catch {
|
|
325
|
+
/* diagnostics only; the high-water sidecar is what prevents the stranding */
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Pick the row a new activity should merge into. Window = the last 10 live rows
|
|
331
|
+
* of this session; a distilled phase row is not a merge target (otherwise its
|
|
332
|
+
* count could never grow into a new phase). Similarity is checked even when the
|
|
333
|
+
* key differs, matching the spec's "key 相同或文本相似度≥0.8".
|
|
334
|
+
*/
|
|
335
|
+
export function selectDedupeTarget(entries, candidate, options = {}) {
|
|
336
|
+
const windowSize = Number.isInteger(options.window) ? options.window : DEDUPE_WINDOW;
|
|
337
|
+
const threshold = Number.isFinite(options.threshold) ? options.threshold : SIMILARITY_THRESHOLD;
|
|
338
|
+
const sessionId = String(candidate?.sessionId ?? '');
|
|
339
|
+
const key = normalizeKey(candidate?.key);
|
|
340
|
+
const text = oneLine(candidate?.text);
|
|
341
|
+
const rows = (Array.isArray(entries) ? entries : []).filter(
|
|
342
|
+
(entry) => entry && entry.sessionId === sessionId && entry.archived !== true,
|
|
343
|
+
);
|
|
344
|
+
const windowRows = rows.slice(-Math.max(1, windowSize));
|
|
345
|
+
let best = null;
|
|
346
|
+
for (const row of windowRows) {
|
|
347
|
+
if (normalizeKey(row.key) === key) return { row, reason: 'key', score: 1 };
|
|
348
|
+
const score = similarity(row.text, text);
|
|
349
|
+
if (score >= threshold && (best === null || score > best.score)) best = { row, reason: 'similarity', score };
|
|
350
|
+
}
|
|
351
|
+
return best;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* Distillation plan for one session+key. Trigger: the newest live row with
|
|
356
|
+
* that key was seen ≥3 times (count), or the key's active rows add up to ≥10.
|
|
357
|
+
* A lone phase row is never re-distilled (it is already the compressed form).
|
|
358
|
+
*/
|
|
359
|
+
export function planDistillation(entries, options = {}) {
|
|
360
|
+
const sessionId = String(options.sessionId ?? '');
|
|
361
|
+
const key = normalizeKey(options.key);
|
|
362
|
+
const active = (Array.isArray(entries) ? entries : []).filter(
|
|
363
|
+
(entry) => entry
|
|
364
|
+
&& entry.sessionId === sessionId
|
|
365
|
+
&& entry.archived !== true
|
|
366
|
+
&& normalizeKey(entry.key) === key,
|
|
367
|
+
);
|
|
368
|
+
const normal = active.filter((entry) => entry.phase !== true);
|
|
369
|
+
if (normal.length === 0) return null;
|
|
370
|
+
const total = active.reduce((sum, entry) => sum + countOf(entry), 0);
|
|
371
|
+
const newestNormalCount = Math.max(...normal.map(countOf));
|
|
372
|
+
if (newestNormalCount < DISTILL_MIN_COUNT && total < DISTILL_TOTAL_COUNT) return null;
|
|
373
|
+
const ordered = [...active].sort((left, right) => left.seq - right.seq);
|
|
374
|
+
const last = ordered[ordered.length - 1];
|
|
375
|
+
const kind = last.kind === 'blocked' || last.kind === 'done' ? last.kind : 'finding';
|
|
376
|
+
const detail = last.last_text ?? last.text;
|
|
377
|
+
const summary = {
|
|
378
|
+
schemaVersion: ACTIVITY_SCHEMA_VERSION,
|
|
379
|
+
seq: 0, // filled by append after nextSeq()
|
|
380
|
+
sessionId,
|
|
381
|
+
at: options.at ?? new Date().toISOString(),
|
|
382
|
+
last_at: options.at ?? new Date().toISOString(),
|
|
383
|
+
kind,
|
|
384
|
+
text: distillActivityText(key, total, detail),
|
|
385
|
+
last_text: clip(oneLine(detail), ACTIVITY_TEXT_MAX),
|
|
386
|
+
key,
|
|
387
|
+
norm: sha256Hex(normalizeText(detail)).slice(0, 16),
|
|
388
|
+
dupOf: null,
|
|
389
|
+
count: total,
|
|
390
|
+
phase: true,
|
|
391
|
+
};
|
|
392
|
+
return { rows: active, summary };
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* Keep the newest `recentMax` rows in activity.jsonl. Older rows are appended
|
|
397
|
+
* to their local-month archive first; if any archive append fails, nothing is
|
|
398
|
+
* dropped (a slightly long board is better than data loss).
|
|
399
|
+
*
|
|
400
|
+
* Recency is `seq` (a refresh bumps the merged row's seq). The physical array
|
|
401
|
+
* order is NOT trusted: the caller may hand us a board that grew past the cap
|
|
402
|
+
* (a previous archive append failed) where the just-refreshed row still sits at
|
|
403
|
+
* its old physical position. Slicing by array position would archive that row —
|
|
404
|
+
* and then a sibling's seq-only cursor could never see the newest action.
|
|
405
|
+
*/
|
|
406
|
+
function archiveOverflow(memoryRoot, entries, recentMax) {
|
|
407
|
+
if (entries.length <= recentMax) return entries;
|
|
408
|
+
const ordered = [...entries].sort((left, right) => (left?.seq ?? 0) - (right?.seq ?? 0));
|
|
409
|
+
const keep = ordered.slice(ordered.length - recentMax);
|
|
410
|
+
const drop = ordered.slice(0, ordered.length - recentMax);
|
|
411
|
+
const groups = new Map();
|
|
412
|
+
for (const row of drop) {
|
|
413
|
+
const month = monthKeyFor(row.at);
|
|
414
|
+
if (!groups.has(month)) groups.set(month, []);
|
|
415
|
+
groups.get(month).push(row);
|
|
416
|
+
}
|
|
417
|
+
try {
|
|
418
|
+
fs.mkdirSync(activityBoardDir(memoryRoot), { recursive: true });
|
|
419
|
+
for (const [month, rows] of groups) {
|
|
420
|
+
fs.appendFileSync(activityArchiveFile(memoryRoot, month), serializeActivityEntries(rows), 'utf8');
|
|
421
|
+
}
|
|
422
|
+
} catch {
|
|
423
|
+
return entries;
|
|
424
|
+
}
|
|
425
|
+
return keep;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/**
|
|
429
|
+
* Create the activity store.
|
|
430
|
+
*
|
|
431
|
+
* options: memoryRoot (required), now(), recentMax, privacyScan(text).
|
|
432
|
+
* append() is synchronous and serialized across processes by the lock.
|
|
433
|
+
*/
|
|
434
|
+
export function createActivityStore(options = {}) {
|
|
435
|
+
const memoryRoot = typeof options.memoryRoot === 'string' && options.memoryRoot.trim() !== ''
|
|
436
|
+
? path.resolve(options.memoryRoot)
|
|
437
|
+
: null;
|
|
438
|
+
const now = typeof options.now === 'function' ? options.now : () => new Date();
|
|
439
|
+
const privacyScan = typeof options.privacyScan === 'function' ? options.privacyScan : defaultPrivacyScan;
|
|
440
|
+
const recentMax = Number.isInteger(options.recentMax) && options.recentMax > 0
|
|
441
|
+
? options.recentMax
|
|
442
|
+
: ACTIVITY_RECENT_MAX;
|
|
443
|
+
|
|
444
|
+
function read() {
|
|
445
|
+
if (!memoryRoot) return { ok: false, code: 'E_NO_ROOT', entries: [], bad: 0, exists: false };
|
|
446
|
+
return readEntries(memoryRoot);
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/**
|
|
450
|
+
* Append or merge one activity row.
|
|
451
|
+
*
|
|
452
|
+
* options.touchSeq: a merge still carries new information (a tool call moved
|
|
453
|
+
* on, a turn concluded). The default merge keeps seq stable — that is the
|
|
454
|
+
* rc.1 dedupe contract and the injection cursor was seq-only, which meant a
|
|
455
|
+
* sibling session saw the first row of a long turn and then nothing. With
|
|
456
|
+
* `touchSeq` the merged row takes `nextSeq()`, so every real refresh is
|
|
457
|
+
* strictly newer for `seq > lastSeenSeq`.
|
|
458
|
+
* options.refreshText: replace the whole row text with the newest occurrence
|
|
459
|
+
* (event-level rows show the current action) instead of the default 「首条
|
|
460
|
+
* 正文保留」 rule.
|
|
461
|
+
*
|
|
462
|
+
* Returns {ok, action:'appended'|'merged'|'refreshed'|'distilled', entry, seq,
|
|
463
|
+
* mergedSeq, seqBumped, archivedCount, streamReset, code}.
|
|
464
|
+
*
|
|
465
|
+
* Sequel monotonicity (RE-R15.5): every successful write persists the max seq
|
|
466
|
+
* seen to `<board>/.activity.seq`, and new rows start above max(board, archive,
|
|
467
|
+
* high-water). Deleting/emptying the board therefore does NOT restart seq at 1,
|
|
468
|
+
* so a sibling's high `lastSeenSeq` cursor keeps working. If a reset is detected
|
|
469
|
+
* (board missing/empty but a previous stream is known), a `stream.reset.json`
|
|
470
|
+
* marker is written for diagnostics / consumer fallback.
|
|
471
|
+
*/
|
|
472
|
+
function append(input = {}, options = {}) {
|
|
473
|
+
if (!memoryRoot) return { ok: false, code: 'E_NO_ROOT' };
|
|
474
|
+
const touchSeq = options.touchSeq === true;
|
|
475
|
+
const refreshText = options.refreshText === true;
|
|
476
|
+
const sessionId = String(input.sessionId ?? '').trim().toLowerCase();
|
|
477
|
+
if (!ACTIVITY_SESSION_RE.test(sessionId)) return { ok: false, code: 'E_SESSION_ID' };
|
|
478
|
+
const text = clip(oneLine(input.text), ACTIVITY_TEXT_MAX);
|
|
479
|
+
if (text === '') return { ok: false, code: 'E_EMPTY_TEXT' };
|
|
480
|
+
const kind = ACTIVITY_KINDS.includes(input.kind) ? input.kind : 'doing';
|
|
481
|
+
const key = normalizeKey(input.key);
|
|
482
|
+
const atDate = new Date(input.at ?? now());
|
|
483
|
+
const at = Number.isFinite(atDate.getTime()) ? atDate.toISOString() : new Date().toISOString();
|
|
484
|
+
|
|
485
|
+
// Reuse the privacy gate before anything touches disk: a local_only verdict
|
|
486
|
+
// drops the row (board data is shared across sessions).
|
|
487
|
+
try {
|
|
488
|
+
const verdict = privacyScan(`${text}\n${key}`);
|
|
489
|
+
if (!verdict || verdict.verdict === 'local_only') {
|
|
490
|
+
return { ok: false, code: 'E_PRIVACY', skipped: true };
|
|
491
|
+
}
|
|
492
|
+
} catch {
|
|
493
|
+
return { ok: false, code: 'E_PRIVACY', skipped: true };
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
return withLock(memoryRoot, () => {
|
|
497
|
+
const current = readEntries(memoryRoot);
|
|
498
|
+
if (!current.ok) return { ok: false, code: current.code };
|
|
499
|
+
let entries = current.entries;
|
|
500
|
+
// Seq floor: never go below the persisted high-water mark (board deletion
|
|
501
|
+
// or compaction must not rewind seq for sibling cursors).
|
|
502
|
+
const highWater = readSeqHighWater(memoryRoot);
|
|
503
|
+
const seqFor = (list) => nextSeq(list, memoryRoot, highWater);
|
|
504
|
+
let streamReset = null;
|
|
505
|
+
if (entries.length === 0) {
|
|
506
|
+
const archivedMax = archivedMaxSeq(memoryRoot);
|
|
507
|
+
const previous = Math.max(highWater, archivedMax);
|
|
508
|
+
if (previous > 0) {
|
|
509
|
+
streamReset = {
|
|
510
|
+
v: 1,
|
|
511
|
+
at,
|
|
512
|
+
reason: current.exists === false ? 'board-missing' : 'board-empty',
|
|
513
|
+
previousHighWater: highWater,
|
|
514
|
+
archivedMax,
|
|
515
|
+
};
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
const target = selectDedupeTarget(entries, { sessionId, key, text });
|
|
519
|
+
let action = 'appended';
|
|
520
|
+
let entry = null;
|
|
521
|
+
let mergedSeq = null;
|
|
522
|
+
let seqBumped = false;
|
|
523
|
+
let archivedCount = 0;
|
|
524
|
+
|
|
525
|
+
if (target) {
|
|
526
|
+
const row = target.row;
|
|
527
|
+
const previousSeq = row.seq;
|
|
528
|
+
row.count = countOf(row) + 1;
|
|
529
|
+
row.last_at = at;
|
|
530
|
+
// The first text is kept for a normal row (spec). A phase row is the
|
|
531
|
+
// generated compression, so it is refreshed with the newest occurrence:
|
|
532
|
+
// 「阶段:…(N 次,最近 …)」 must show the current N and detail.
|
|
533
|
+
row.kind = kind;
|
|
534
|
+
if (row.phase === true) {
|
|
535
|
+
row.last_text = text;
|
|
536
|
+
row.text = distillActivityText(row.key, row.count, text);
|
|
537
|
+
row.norm = sha256Hex(normalizeText(text)).slice(0, 16);
|
|
538
|
+
} else if (refreshText) {
|
|
539
|
+
// Event-level rows track the current action instead of the first one.
|
|
540
|
+
row.text = text;
|
|
541
|
+
row.norm = sha256Hex(normalizeText(text)).slice(0, 16);
|
|
542
|
+
}
|
|
543
|
+
if (touchSeq) {
|
|
544
|
+
// A refresh must be newer than the reader's lastSeenSeq; bump before
|
|
545
|
+
// the distillation plan so the phase row still gets a larger seq.
|
|
546
|
+
row.seq = seqFor(entries);
|
|
547
|
+
seqBumped = true;
|
|
548
|
+
// Keep 「array order tracks recency」 for callers that slice the array
|
|
549
|
+
// directly (archive overflow) — the row was just refreshed, so it
|
|
550
|
+
// must not stay parked at its old physical position.
|
|
551
|
+
const parkedAt = entries.indexOf(row);
|
|
552
|
+
if (parkedAt >= 0 && parkedAt !== entries.length - 1) {
|
|
553
|
+
entries.splice(parkedAt, 1);
|
|
554
|
+
entries.push(row);
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
entry = row;
|
|
558
|
+
mergedSeq = previousSeq;
|
|
559
|
+
action = touchSeq ? 'refreshed' : 'merged';
|
|
560
|
+
} else {
|
|
561
|
+
entry = {
|
|
562
|
+
schemaVersion: ACTIVITY_SCHEMA_VERSION,
|
|
563
|
+
seq: seqFor(entries),
|
|
564
|
+
sessionId,
|
|
565
|
+
at,
|
|
566
|
+
last_at: at,
|
|
567
|
+
kind,
|
|
568
|
+
text,
|
|
569
|
+
key,
|
|
570
|
+
norm: sha256Hex(normalizeText(text)).slice(0, 16),
|
|
571
|
+
dupOf: null,
|
|
572
|
+
count: 1,
|
|
573
|
+
};
|
|
574
|
+
entries.push(entry);
|
|
575
|
+
action = 'appended';
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
const plan = entry?.phase === true ? null : planDistillation(entries, { sessionId, key: entry.key, at });
|
|
579
|
+
if (plan) {
|
|
580
|
+
for (const row of plan.rows) row.archived = true;
|
|
581
|
+
plan.summary.seq = seqFor(entries);
|
|
582
|
+
entries.push(plan.summary);
|
|
583
|
+
archivedCount = plan.rows.length;
|
|
584
|
+
entry = plan.summary;
|
|
585
|
+
action = 'distilled';
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
entries = archiveOverflow(memoryRoot, entries, recentMax);
|
|
589
|
+
const written = writeTextDurable(activityBoardFile(memoryRoot), serializeActivityEntries(entries));
|
|
590
|
+
if (!written.ok) return { ok: false, code: 'E_WRITE', message: written.error };
|
|
591
|
+
// High-water sidecar: keeps seq monotonic even if this board is deleted or
|
|
592
|
+
// truncated between now and the next write (RE-R15.5).
|
|
593
|
+
const boardMax = entries.reduce(
|
|
594
|
+
(max, row) => (Number.isInteger(row.seq) && row.seq > max ? row.seq : max),
|
|
595
|
+
Number.isInteger(entry?.seq) ? entry.seq : 0,
|
|
596
|
+
);
|
|
597
|
+
persistSeqHighWater(memoryRoot, boardMax, at);
|
|
598
|
+
if (streamReset) {
|
|
599
|
+
writeResetMarker(memoryRoot, { ...streamReset, boardMax, firstSeq: entry.seq });
|
|
600
|
+
}
|
|
601
|
+
return {
|
|
602
|
+
ok: true,
|
|
603
|
+
action,
|
|
604
|
+
entry,
|
|
605
|
+
seq: entry.seq,
|
|
606
|
+
mergedSeq,
|
|
607
|
+
seqBumped,
|
|
608
|
+
archivedCount,
|
|
609
|
+
streamReset: streamReset !== null,
|
|
610
|
+
recent: entries.length,
|
|
611
|
+
};
|
|
612
|
+
});
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
return {
|
|
616
|
+
memoryRoot,
|
|
617
|
+
boardFile: memoryRoot ? activityBoardFile(memoryRoot) : null,
|
|
618
|
+
recentMax,
|
|
619
|
+
read,
|
|
620
|
+
append,
|
|
621
|
+
highWater: () => readSeqHighWater(memoryRoot),
|
|
622
|
+
resetMarkerFile: memoryRoot ? activityResetMarkerFile(memoryRoot) : null,
|
|
623
|
+
nextSeq: (entries) => nextSeq(entries ?? read().entries ?? [], memoryRoot, readSeqHighWater(memoryRoot)),
|
|
624
|
+
};
|
|
625
|
+
}
|