@jjh32456/dsh-longlive 1.6.8 → 1.8.0
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 +69 -1539
- package/LICENSE +1 -1
- package/QUICKSTART.md +33 -12
- package/README.en.md +17 -7
- package/README.md +29 -10
- package/SPEC.md +5 -5
- package/cordis.patch.yml +17 -12
- package/docs/ANSWER-GUARD.md +92 -0
- package/docs/ARCHITECTURE.md +3 -3
- package/docs/AUTO-RECALL-WIRING.md +1 -1
- package/docs/AUTO-RECALL.md +15 -9
- package/docs/CONFIG.md +143 -37
- package/docs/ERRORS.md +17 -5
- package/docs/FAQ.md +7 -7
- package/docs/FEATURES.md +66 -29
- package/docs/MIGRATION-1.6.0.md +2 -2
- package/docs/OPERATIONS.md +6 -6
- package/docs/PANEL-INTEGRATION.md +6 -0
- package/docs/PRIVACY.md +88 -0
- package/docs/QUICKSTART.md +5 -5
- package/docs/README.en.md +3 -2
- package/docs/RESIDENT-WATCHER.md +1 -1
- package/docs/SESSION-WATCH.md +6 -6
- package/docs/TROUBLESHOOTING.md +4 -4
- package/package.json +42 -117
- package/runbook.md +2 -2
- package/scripts/backfill-auto-cards.mjs +98 -0
- package/scripts/install-surgery-task.ps1 +5 -2
- package/scripts/lib/session-watch-fixture.mjs +1 -1
- package/scripts/longlive-doctor.mjs +6 -0
- package/scripts/migrate-180.mjs +163 -0
- package/scripts/recall-index-build.mjs +128 -0
- package/scripts/session-watch-drill.mjs +1 -1
- package/scripts/surgery-run.cmd +1 -1
- package/scripts/unattended-surgery.ps1 +5 -2
- package/scripts/verify-card-anchors.mjs +138 -0
- package/src/actions/audit.js +125 -0
- package/src/actions/evidence.js +122 -0
- package/src/actions/fs.js +162 -0
- package/src/activity/index.js +129 -18
- package/src/activity/model.js +11 -1
- package/src/activity/store.js +22 -3
- package/src/activity/thinking.js +176 -0
- package/src/adapters/constants.js +11 -0
- package/src/adapters/engram/data/aliases.json +51 -0
- package/src/adapters/engram/data/policy.json +8 -0
- package/src/adapters/engram/data/routing-rules.json +546 -0
- package/src/adapters/engram/index.js +235 -0
- package/src/adapters/registry.js +315 -0
- package/src/answer/audit.js +193 -0
- package/src/answer/evidence.js +30 -0
- package/src/answer/guard.js +1370 -0
- package/src/answer/index.js +1067 -0
- package/src/answer/query.js +111 -0
- package/src/answer/second-hop.mjs +101 -0
- package/src/auto-truncate.js +1 -1
- package/src/config.js +145 -48
- package/src/deps.js +6 -4
- package/src/features.js +158 -8
- package/src/identity/index.js +13 -5
- package/src/identity/inject-position.js +2 -2
- package/src/index.js +97 -7
- package/src/lifecycle/gap-clocks.js +328 -0
- package/src/lifecycle/index.js +269 -0
- package/src/memory/activity-capture.js +229 -0
- package/src/memory/anchors.js +194 -0
- package/src/memory/audit-records.js +222 -0
- package/src/memory/auto-cards.js +401 -0
- package/src/memory/capture.js +442 -0
- package/src/memory/card-anchors.js +203 -0
- package/src/memory/conflicts.js +106 -0
- package/src/memory/decay.js +311 -0
- package/src/memory/distill.js +471 -0
- package/src/memory/event-index.js +917 -0
- package/src/memory/evidence-chain.js +295 -0
- package/src/memory/evidence.js +145 -0
- package/src/memory/filter-runner.js +1 -1
- package/src/memory/filter.js +5 -1
- package/src/memory/forget-v2.js +410 -0
- package/src/memory/forget.js +71 -0
- package/src/memory/frames-activation.js +247 -0
- package/src/memory/fts-query.js +174 -0
- package/src/memory/governance.js +604 -0
- package/src/memory/index.js +1 -0
- package/src/memory/l2-gate.js +169 -0
- package/src/memory/lock.js +121 -0
- package/src/memory/migrate.js +139 -0
- package/src/memory/recall-v2.js +223 -0
- package/src/memory/recall.js +65 -2
- package/src/memory/records.js +425 -0
- package/src/memory/retention.js +128 -0
- package/src/memory/review.js +124 -0
- package/src/memory/trust.js +88 -0
- package/src/memory/version-chain.js +264 -0
- package/src/memory/wal.js +137 -0
- package/src/memory/working-frame.js +516 -0
- package/src/metrics/classify.mjs +5 -3
- package/src/metrics/index.mjs +21 -1
- package/src/metrics/prestep-latency.mjs +300 -0
- package/src/metrics/store.mjs +25 -0
- package/src/policy-profile.js +18 -3
- package/src/recall/anaphora.mjs +87 -0
- package/src/recall/cards.mjs +7 -0
- package/src/recall/embedding.mjs +8 -8
- package/src/recall/entities.mjs +112 -0
- package/src/recall/index.js +235 -19
- package/src/recall/inject-order.mjs +10 -8
- package/src/recall/privacy.mjs +1 -1
- package/src/recall/recall-core.mjs +116 -10
- package/src/recall/shared-visibility.js +302 -0
- package/src/recall/time-normalizer.mjs +196 -0
- package/src/role/activation.js +191 -0
- package/src/role/attention.js +318 -0
- package/src/role/base.js +36 -0
- package/src/role/channels.js +423 -0
- package/src/role/index.js +425 -0
- package/src/role/interfaces.js +115 -0
- package/src/role/personality.js +370 -0
- package/src/role/readers.js +151 -0
- package/src/role/relationships.js +477 -0
- package/src/role/store.js +202 -0
- package/src/role/writeprotect.js +373 -0
- package/src/router/aliases.json +176 -25
- package/src/router/background/agent-runner.mjs +1 -1
- package/src/router/background/anchor.mjs +1 -1
- package/src/router/background/audit.mjs +2 -2
- package/src/router/background/budget-ledger.mjs +2 -2
- package/src/router/background/host-sim.mjs +1 -1
- package/src/router/background/memory.mjs +4 -4
- package/src/router/background/queue.mjs +1 -1
- package/src/router/background/retry.mjs +1 -1
- package/src/router/background/router.mjs +1 -1
- package/src/router/background/stream.mjs +1 -1
- package/src/router/background/tool-policy.mjs +1 -1
- package/src/router/design-headings.json +5 -5
- package/src/router/face/config.mjs +1 -1
- package/src/router/face/identity.mjs +3 -3
- package/src/router/judge.mjs +8 -7
- package/src/router/policies/generic.json +21 -4
- package/src/router/route.js +109 -22
- package/src/router/router.mjs +10 -6
- package/src/router/routing-rules.json +260 -445
- package/src/router/run-sim.mjs +8 -8
- package/src/router/runtime.js +23 -12
- package/src/router/service/config.mjs +5 -5
- package/src/router/service/context.mjs +1 -1
- package/src/router/service/queue.mjs +1 -1
- package/src/router/shadow.mjs +4 -3
- package/src/surgery-recovery.js +2 -2
- package/src/tool-output.js +2 -2
- package/src/tools.js +39 -7
- package/src/truncate-visible.js +2 -2
- package/src/ui/actions.js +1312 -0
- package/src/ui/panel-api.js +367 -0
- package/src/ui/panel-auth.js +251 -0
- package/src/ui/panel-integrity.js +243 -0
- package/src/ui/routes-readonly.js +695 -0
- package/src/ui/routes.js +3 -0
- package/docs/DEV.md +0 -279
- package/docs/PRESENCE-BUS-WIRING.md +0 -491
- package/docs/PRESENCE-BUS.md +0 -694
- package/docs/SURGERY.md +0 -186
- package/scripts/mark-core-cards.mjs +0 -289
- package/scripts/pack.mjs +0 -392
- package/scripts/router-proposals.mjs +0 -386
- package/src/router/fixtures/M1.json +0 -16
- package/src/router/fixtures/M2.json +0 -16
- package/src/router/fixtures/M3.json +0 -20
- package/src/router/fixtures/M4.json +0 -20
- package/src/router/fixtures/M5.json +0 -45
- package/src/router/fixtures/M6.json +0 -53
- package/src/router/fixtures/M7.json +0 -46
- package/src/router/fixtures/S1.json +0 -20
- package/src/router/fixtures/S2.json +0 -23
- package/src/router/fixtures/S3.json +0 -20
- package/src/router/fixtures/S4.json +0 -34
- package/src/router/fixtures/S5.json +0 -43
- package/src/router/fixtures/S6.json +0 -47
- package/src/router/fixtures/T1.json +0 -23
- package/src/router/fixtures/T2.json +0 -16
- package/src/router/fixtures/T3.json +0 -19
- package/src/router/fixtures/T4.json +0 -16
- package/src/router/fixtures/X1.json +0 -17
- package/src/router/fixtures/X2.json +0 -20
- package/src/router/gen-design-headings.mjs +0 -107
- package/src/router/policies/owner-rulings-2026-11-03.json +0 -62
- package/test/activity-realtime.test.mjs +0 -959
- package/test/activity-stream.test.mjs +0 -512
- package/test/auto-recall.test.mjs +0 -474
- package/test/auto-truncate.test.mjs +0 -931
- package/test/autobind.test.mjs +0 -399
- package/test/bus.test.mjs +0 -1572
- package/test/cli-guard.test.mjs +0 -198
- package/test/conclusion.test.mjs +0 -408
- package/test/config-schema.test.mjs +0 -210
- package/test/d6-output.test.mjs +0 -194
- package/test/d6.test.mjs +0 -395
- package/test/deps.test.mjs +0 -342
- package/test/doctor-setup.test.mjs +0 -297
- package/test/embedding-recall.test.mjs +0 -347
- package/test/feature-flags.test.mjs +0 -585
- package/test/filter-shadow.test.mjs +0 -170
- package/test/fixtures/activity-append-child.mjs +0 -29
- package/test/fixtures/baseline-0.8.0-registration.json +0 -556
- package/test/fixtures/d6-header-tools.json +0 -1
- package/test/fixtures/lock-child.mjs +0 -67
- package/test/fixtures/pre-step-container.mjs +0 -100
- package/test/fixtures/privacy-memory/topics/pii-unmarked.md +0 -9
- package/test/fixtures/privacy-memory/topics/secret-local-only.md +0 -11
- package/test/fixtures/profile-session-longlive-config.json +0 -20
- package/test/fixtures/proposal-cas-child.mjs +0 -66
- package/test/host-engine-status.test.mjs +0 -307
- package/test/hot-truncate.test.mjs +0 -1349
- package/test/identity-card-marker.test.mjs +0 -307
- package/test/identity-router/run.mjs +0 -721
- package/test/integration-pre-step.test.mjs +0 -553
- package/test/live.test.mjs +0 -1023
- package/test/maintenance-schedule.test.mjs +0 -538
- package/test/memory.test.mjs +0 -2828
- package/test/metrics.test.mjs +0 -478
- package/test/orchestrator.test.mjs +0 -266
- package/test/p1_probe.mjs +0 -97
- package/test/packaging.test.mjs +0 -184
- package/test/portability.test.mjs +0 -343
- package/test/rc4-regressions.test.mjs +0 -562
- package/test/rc5-regressions.test.mjs +0 -197
- package/test/rc6-chinese.test.mjs +0 -394
- package/test/router-1100/apply-off.test.mjs +0 -175
- package/test/router-1100/budget-reconcile.test.mjs +0 -215
- package/test/router-1100/config-context.test.mjs +0 -211
- package/test/router-1100/helpers.mjs +0 -129
- package/test/router-1100/hint-envelope.test.mjs +0 -150
- package/test/router-1100/lock-crossproc.test.mjs +0 -206
- package/test/router-1100/proposal-cas-crossproc.test.mjs +0 -184
- package/test/router-1100/router-apply.test.mjs +0 -372
- package/test/router-1100/router-fixb.test.mjs +0 -602
- package/test/router-1100/router-proposals.test.mjs +0 -405
- package/test/router-1100/router-queue-lease-wal.test.mjs +0 -355
- package/test/router-1100/router-security-cr.test.mjs +0 -333
- package/test/router-1100/service-lifecycle.test.mjs +0 -220
- package/test/router-1100/tools-contract.test.mjs +0 -164
- package/test/router-1100/turn-reflect-tick.test.mjs +0 -89
- package/test/router-1100/v6-contract.test.mjs +0 -158
- package/test/router-1100/v6b-contract.test.mjs +0 -293
- package/test/router-1100/v6c-contract.test.mjs +0 -249
- package/test/router-1100/wal-rotation.test.mjs +0 -375
- package/test/router-1100/wp1-config.test.mjs +0 -170
- package/test/router-1100/wp1-default-off.test.mjs +0 -77
- package/test/router-1100/wp1-model-status.test.mjs +0 -90
- package/test/router-1100/wp1-status.test.mjs +0 -220
- package/test/router-1100/wp1-tools.test.mjs +0 -146
- package/test/router-1100/wp5-cli.test.mjs +0 -240
- package/test/router-1100/wp5-model-auto.test.mjs +0 -418
- package/test/router-1100/wp6-merge.test.mjs +0 -386
- package/test/router-1100/wp6-wake.test.mjs +0 -502
- package/test/router-background/audit.test.mjs +0 -37
- package/test/router-background/budget-ledger.test.mjs +0 -330
- package/test/router-background/cross-process.test.mjs +0 -222
- package/test/router-background/fixtures/cross-child.mjs +0 -85
- package/test/router-background/fixtures/real-shapes.mjs +0 -239
- package/test/router-background/router.test.mjs +0 -1076
- package/test/router-background/stream-retry.test.mjs +0 -358
- package/test/router-background/tool-agent.test.mjs +0 -384
- package/test/router-face/bridge.test.mjs +0 -169
- package/test/router-face/face.test.mjs +0 -316
- package/test/router-failclosed.test.mjs +0 -466
- package/test/router-identity.test.mjs +0 -384
- package/test/router-l1-l2.contract.test.mjs +0 -1075
- package/test/router-memory-tools.test.mjs +0 -262
- package/test/router-policy.test.mjs +0 -225
- package/test/router-runtime.test.mjs +0 -438
- package/test/router-shadow.test.mjs +0 -287
- package/test/router.test.mjs +0 -903
- package/test/run-rc.mjs +0 -315
- package/test/run.mjs +0 -2469
- package/test/safety-defaults.test.mjs +0 -181
- package/test/seeded-load.test.mjs +0 -436
- package/test/session-original.test.mjs +0 -338
- package/test/state-truth.test.mjs +0 -937
- package/test/surgery-recovery.test.mjs +0 -936
- package/test/t7_drive.mjs +0 -151
- package/test/tool-output.test.mjs +0 -393
- package/test/tool-schema.test.mjs +0 -177
- package/test/tools_contract.test.mjs +0 -716
- package/test/truncate-visible.test.mjs +0 -1150
- package/test/ui-flags.test.mjs +0 -477
- package/test/v4-hardening.test.mjs +0 -427
- package/test/version-consistency.test.mjs +0 -286
|
@@ -0,0 +1,917 @@
|
|
|
1
|
+
// src/memory/event-index.js — M2-A:统一 memory event 模型 + 事件索引(SQLite FTS5)
|
|
2
|
+
//
|
|
3
|
+
// 依据提案 §1.1 / §2.2 / §9.1(v2.6.3)与 FTS5-MEMGAP-EVIDENCE(任务):
|
|
4
|
+
// * L1 事件索引是 L0 会话日志的**派生**:可整体删除后仅凭 L0 重建(硬验收);
|
|
5
|
+
// * 主查询入口 = SQLite FTS5(node:sqlite 内置,本机 3.53.4 / FTS5=1);
|
|
6
|
+
// * 双表:`events_fts` = trigram(raw_text) 覆盖中文 ≥3 字子串/版本号/ID/日期;
|
|
7
|
+
// `events_terms` = unicode61(term_text 预分词 bigram + aliases) 补 2 字 CJK;
|
|
8
|
+
// * 单写管道:一条 EventRecord 在同一个事务里写 events + events_fts + events_terms,
|
|
9
|
+
// 并追加派生 JSONL 审计层 `events.audit.jsonl`(可导出/diff/迁移);
|
|
10
|
+
// * 幂等:同一 (id, digest) 重放不产生新行;会话副本按 visible-messages digest 去重。
|
|
11
|
+
//
|
|
12
|
+
// 本模块只读 L0(绝不写 .dsh/sessions),所有落盘都在 <memoryRoot>/.longlive/index/。
|
|
13
|
+
|
|
14
|
+
import { createRequire } from 'node:module';
|
|
15
|
+
import crypto from 'node:crypto';
|
|
16
|
+
import fs from 'node:fs';
|
|
17
|
+
import os from 'node:os';
|
|
18
|
+
import path from 'node:path';
|
|
19
|
+
|
|
20
|
+
import { decompressFrame, deriveMessagesDigest, parseSession, splitFrames, stableStringify } from '../codec.js';
|
|
21
|
+
import { extractTerms, termTextOf, aliasesOf, buildFtsCandidates } from './fts-query.js';
|
|
22
|
+
import { makeSessionTurnAnchor, turnDigest } from './anchors.js';
|
|
23
|
+
|
|
24
|
+
const require = createRequire(import.meta.url);
|
|
25
|
+
let sqliteModule = null;
|
|
26
|
+
|
|
27
|
+
/** Lazy node:sqlite loader: keeps the module loadable on hosts without it. */
|
|
28
|
+
function sqlite() {
|
|
29
|
+
if (sqliteModule === null) sqliteModule = require('node:sqlite');
|
|
30
|
+
return sqliteModule;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export const INDEX_DIRNAME = 'index';
|
|
34
|
+
export const EVENTS_DB = 'events.db';
|
|
35
|
+
export const EVENTS_AUDIT = 'events.audit.jsonl';
|
|
36
|
+
export const FRAMES_DIR = 'frames';
|
|
37
|
+
export const BUILD_STATE = 'build-state.json';
|
|
38
|
+
export const EVENT_SCHEMA = 'events/v1';
|
|
39
|
+
export const INDEX_SCHEMA_VERSION = 1;
|
|
40
|
+
|
|
41
|
+
const SCHEMA = `
|
|
42
|
+
CREATE TABLE IF NOT EXISTS meta (k TEXT PRIMARY KEY, v TEXT);
|
|
43
|
+
CREATE TABLE IF NOT EXISTS events (
|
|
44
|
+
id TEXT PRIMARY KEY,
|
|
45
|
+
session_id TEXT NOT NULL,
|
|
46
|
+
turn INTEGER NOT NULL,
|
|
47
|
+
seq_start INTEGER,
|
|
48
|
+
seq_end INTEGER,
|
|
49
|
+
time_start INTEGER,
|
|
50
|
+
time_end INTEGER,
|
|
51
|
+
project TEXT,
|
|
52
|
+
kind TEXT,
|
|
53
|
+
summary TEXT,
|
|
54
|
+
topics_json TEXT,
|
|
55
|
+
terms_json TEXT,
|
|
56
|
+
counts_json TEXT,
|
|
57
|
+
anchors_json TEXT,
|
|
58
|
+
digest TEXT NOT NULL,
|
|
59
|
+
built_at INTEGER,
|
|
60
|
+
source TEXT
|
|
61
|
+
);
|
|
62
|
+
CREATE INDEX IF NOT EXISTS idx_events_session_turn ON events(session_id, turn);
|
|
63
|
+
CREATE INDEX IF NOT EXISTS idx_events_kind ON events(kind);
|
|
64
|
+
CREATE INDEX IF NOT EXISTS idx_events_time ON events(time_end);
|
|
65
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS events_fts USING fts5(id UNINDEXED, raw_text, tokenize='trigram');
|
|
66
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS events_terms USING fts5(id UNINDEXED, term_text, aliases, tokenize='unicode61');
|
|
67
|
+
`;
|
|
68
|
+
|
|
69
|
+
export class IndexError extends Error {
|
|
70
|
+
constructor(code, message, detail = undefined) {
|
|
71
|
+
super(message);
|
|
72
|
+
this.name = 'IndexError';
|
|
73
|
+
this.code = code;
|
|
74
|
+
if (detail !== undefined) this.detail = detail;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function indexPaths(memoryRoot) {
|
|
79
|
+
const root = path.resolve(String(memoryRoot ?? ''));
|
|
80
|
+
const dir = path.join(root, '.longlive', INDEX_DIRNAME);
|
|
81
|
+
return {
|
|
82
|
+
root,
|
|
83
|
+
dir,
|
|
84
|
+
db: path.join(dir, EVENTS_DB),
|
|
85
|
+
audit: path.join(dir, EVENTS_AUDIT),
|
|
86
|
+
framesDir: path.join(dir, FRAMES_DIR),
|
|
87
|
+
buildState: path.join(dir, BUILD_STATE),
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function nowIso(now) {
|
|
92
|
+
const value = now();
|
|
93
|
+
return (value instanceof Date ? value : new Date(value)).toISOString();
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function sha256Hex(text) {
|
|
97
|
+
return crypto.createHash('sha256').update(text, 'utf8').digest('hex');
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function textOf(event) {
|
|
101
|
+
const parts = event?.data?.content;
|
|
102
|
+
if (!Array.isArray(parts)) return '';
|
|
103
|
+
return parts
|
|
104
|
+
.filter((block) => block?.type === 'text' && typeof block.text === 'string')
|
|
105
|
+
.map((block) => block.text)
|
|
106
|
+
.join('\n')
|
|
107
|
+
.trim();
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function classifyKind(corpus, counts) {
|
|
111
|
+
const text = String(corpus ?? '');
|
|
112
|
+
if (/(决定|拍板|裁定|结论|选型|方案|定稿|通过)/.test(text)) return 'conclusion';
|
|
113
|
+
if (/(事故|故障|报错|失败|回归|broken|error|crash)/i.test(text)) return 'incident';
|
|
114
|
+
if (/(计划|下一步|待办|排期|里程碑)/.test(text)) return 'plan';
|
|
115
|
+
if (counts.toolCalls > 0) return 'ops';
|
|
116
|
+
return 'chat';
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function rawTextFor(record) {
|
|
120
|
+
const parts = [
|
|
121
|
+
`[${record.kind}] ${record.summary}`,
|
|
122
|
+
Array.isArray(record.terms) ? record.terms.join(' ') : '',
|
|
123
|
+
Array.isArray(record.topics) ? record.topics.join(' ') : '',
|
|
124
|
+
record.project ?? '',
|
|
125
|
+
];
|
|
126
|
+
return parts.filter((part) => typeof part === 'string' && part !== '').join('\n').slice(0, 20000);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function parseRow(row) {
|
|
130
|
+
if (!row) return null;
|
|
131
|
+
return {
|
|
132
|
+
id: row.id,
|
|
133
|
+
sessionId: row.session_id,
|
|
134
|
+
turn: row.turn,
|
|
135
|
+
seqStart: row.seq_start,
|
|
136
|
+
seqEnd: row.seq_end,
|
|
137
|
+
timeStart: row.time_start,
|
|
138
|
+
timeEnd: row.time_end,
|
|
139
|
+
project: row.project,
|
|
140
|
+
kind: row.kind,
|
|
141
|
+
summary: row.summary,
|
|
142
|
+
topics: safeJson(row.topics_json, []),
|
|
143
|
+
terms: safeJson(row.terms_json, []),
|
|
144
|
+
counts: safeJson(row.counts_json, {}),
|
|
145
|
+
anchors: safeJson(row.anchors_json, []),
|
|
146
|
+
digest: row.digest,
|
|
147
|
+
builtAt: row.built_at,
|
|
148
|
+
source: row.source,
|
|
149
|
+
...(typeof row.rank === 'number' ? { rank: row.rank } : {}),
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function safeJson(text, fallback) {
|
|
154
|
+
try {
|
|
155
|
+
return JSON.parse(text);
|
|
156
|
+
} catch {
|
|
157
|
+
return fallback;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function validateRecord(record) {
|
|
162
|
+
if (!record || typeof record !== 'object') throw new IndexError('E_EVENT_INVALID', 'event must be an object');
|
|
163
|
+
if (typeof record.id !== 'string' || record.id === '') throw new IndexError('E_EVENT_ID', 'event.id required');
|
|
164
|
+
if (typeof record.sessionId !== 'string' || record.sessionId === '') {
|
|
165
|
+
throw new IndexError('E_EVENT_SESSION', 'event.sessionId required');
|
|
166
|
+
}
|
|
167
|
+
if (!Number.isInteger(record.turn) || record.turn < 0) throw new IndexError('E_EVENT_TURN', 'event.turn required');
|
|
168
|
+
if (typeof record.digest !== 'string' || !/^[0-9a-f]{64}$/.test(record.digest)) {
|
|
169
|
+
throw new IndexError('E_EVENT_DIGEST', 'event.digest required (sha256 hex)');
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Open (and lazily create) the event index.
|
|
175
|
+
*
|
|
176
|
+
* @param {object} options
|
|
177
|
+
* @param {string} options.memoryRoot
|
|
178
|
+
* @param {() => Date} [options.now]
|
|
179
|
+
* @param {string} [options.dbPath] override for tests
|
|
180
|
+
*/
|
|
181
|
+
export function openEventIndex(options = {}) {
|
|
182
|
+
const memoryRoot = path.resolve(String(options.memoryRoot ?? ''));
|
|
183
|
+
if (memoryRoot === '') throw new IndexError('E_MEMORY_ROOT', 'memoryRoot required');
|
|
184
|
+
const now = typeof options.now === 'function' ? options.now : () => new Date();
|
|
185
|
+
const paths = indexPaths(memoryRoot);
|
|
186
|
+
fs.mkdirSync(paths.dir, { recursive: true });
|
|
187
|
+
fs.mkdirSync(paths.framesDir, { recursive: true });
|
|
188
|
+
const dbPath = options.dbPath ? path.resolve(options.dbPath) : paths.db;
|
|
189
|
+
const { DatabaseSync } = sqlite();
|
|
190
|
+
const db = new DatabaseSync(dbPath);
|
|
191
|
+
db.exec('PRAGMA journal_mode = WAL');
|
|
192
|
+
db.exec('PRAGMA busy_timeout = 5000');
|
|
193
|
+
db.exec(SCHEMA);
|
|
194
|
+
let closed = false;
|
|
195
|
+
|
|
196
|
+
function getMeta(key) {
|
|
197
|
+
const row = db.prepare('SELECT v FROM meta WHERE k = ?').get(key);
|
|
198
|
+
return row ? row.v : null;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function setMeta(key, value) {
|
|
202
|
+
db.prepare('INSERT INTO meta(k, v) VALUES(?, ?) ON CONFLICT(k) DO UPDATE SET v = excluded.v')
|
|
203
|
+
.run(key, String(value));
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function appendAudit(record, action) {
|
|
207
|
+
const line = {
|
|
208
|
+
schema: 'events-audit/v1',
|
|
209
|
+
id: record.id,
|
|
210
|
+
sessionId: record.sessionId,
|
|
211
|
+
turn: record.turn,
|
|
212
|
+
digest: record.digest,
|
|
213
|
+
kind: record.kind ?? null,
|
|
214
|
+
anchors: record.anchors ?? [],
|
|
215
|
+
builtAt: record.builtAt ?? Date.parse(nowIso(now)),
|
|
216
|
+
source: record.source ?? 'rebuild',
|
|
217
|
+
action,
|
|
218
|
+
};
|
|
219
|
+
try {
|
|
220
|
+
fs.appendFileSync(paths.audit, `${JSON.stringify(line)}\n`, 'utf8');
|
|
221
|
+
} catch {
|
|
222
|
+
/* audit layer is derived; the DB row is the query source */
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function upsertEvent(record, options2 = {}) {
|
|
227
|
+
validateRecord(record);
|
|
228
|
+
const existing = db.prepare('SELECT digest FROM events WHERE id = ?').get(record.id);
|
|
229
|
+
if (existing && existing.digest === record.digest) {
|
|
230
|
+
return { inserted: false, duplicated: true, id: record.id };
|
|
231
|
+
}
|
|
232
|
+
if (existing && existing.digest !== record.digest) {
|
|
233
|
+
// Same id, different content: never overwrite silently. The conflict is
|
|
234
|
+
// audited and the caller must treat it as a rejected write.
|
|
235
|
+
appendAudit({ ...record, source: record.source ?? options2.source ?? 'upsert', anchors: record.anchors ?? [] }, 'id-digest-conflict');
|
|
236
|
+
return { inserted: false, conflict: true, code: 'E_ID_DIGEST_CONFLICT', id: record.id };
|
|
237
|
+
}
|
|
238
|
+
const builtAt = Number.isInteger(record.builtAt) ? record.builtAt : Date.parse(nowIso(now));
|
|
239
|
+
const source = record.source ?? options2.source ?? 'rebuild';
|
|
240
|
+
const rawText = rawTextFor(record);
|
|
241
|
+
const termText = termTextOf([record.summary, ...(record.topics ?? []), ...(record.terms ?? [])].join('\n')).slice(0, 12000);
|
|
242
|
+
const aliases = [...new Set([...(aliasesOf(record.summary ?? '')), ...(record.aliases ?? [])])].join(' ').slice(0, 4000);
|
|
243
|
+
const anchors = record.anchors ?? [];
|
|
244
|
+
db.exec('BEGIN IMMEDIATE');
|
|
245
|
+
try {
|
|
246
|
+
db.prepare(`
|
|
247
|
+
INSERT INTO events
|
|
248
|
+
(id, session_id, turn, seq_start, seq_end, time_start, time_end, project, kind,
|
|
249
|
+
summary, topics_json, terms_json, counts_json, anchors_json, digest, built_at, source)
|
|
250
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
251
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
252
|
+
session_id = excluded.session_id, turn = excluded.turn,
|
|
253
|
+
seq_start = excluded.seq_start, seq_end = excluded.seq_end,
|
|
254
|
+
time_start = excluded.time_start, time_end = excluded.time_end,
|
|
255
|
+
project = excluded.project, kind = excluded.kind, summary = excluded.summary,
|
|
256
|
+
topics_json = excluded.topics_json, terms_json = excluded.terms_json,
|
|
257
|
+
counts_json = excluded.counts_json, anchors_json = excluded.anchors_json,
|
|
258
|
+
digest = excluded.digest, built_at = excluded.built_at, source = excluded.source
|
|
259
|
+
`).run(
|
|
260
|
+
record.id, record.sessionId, record.turn,
|
|
261
|
+
record.seqStart ?? null, record.seqEnd ?? null,
|
|
262
|
+
record.timeStart ?? null, record.timeEnd ?? null,
|
|
263
|
+
record.project ?? null, record.kind ?? null,
|
|
264
|
+
record.summary ?? null,
|
|
265
|
+
JSON.stringify(record.topics ?? []),
|
|
266
|
+
JSON.stringify(record.terms ?? []),
|
|
267
|
+
JSON.stringify(record.counts ?? {}),
|
|
268
|
+
JSON.stringify(anchors),
|
|
269
|
+
record.digest, builtAt, source,
|
|
270
|
+
);
|
|
271
|
+
db.prepare('DELETE FROM events_fts WHERE id = ?').run(record.id);
|
|
272
|
+
db.prepare('DELETE FROM events_terms WHERE id = ?').run(record.id);
|
|
273
|
+
db.prepare('INSERT INTO events_fts(id, raw_text) VALUES(?, ?)').run(record.id, rawText);
|
|
274
|
+
db.prepare('INSERT INTO events_terms(id, term_text, aliases) VALUES(?, ?, ?)').run(record.id, termText, aliases);
|
|
275
|
+
db.exec('COMMIT');
|
|
276
|
+
} catch (error) {
|
|
277
|
+
db.exec('ROLLBACK');
|
|
278
|
+
throw new IndexError('E_INDEX_WRITE', `event index write failed: ${error.message}`, { id: record.id });
|
|
279
|
+
}
|
|
280
|
+
appendAudit({ ...record, builtAt, source, anchors }, existing ? 'update' : 'insert');
|
|
281
|
+
setMeta('last_write_at', nowIso(now));
|
|
282
|
+
setMeta('schema', `${EVENT_SCHEMA}/${INDEX_SCHEMA_VERSION}`);
|
|
283
|
+
return { inserted: true, id: record.id };
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function runCandidates(table, candidates, limit, kind) {
|
|
287
|
+
for (const candidate of candidates) {
|
|
288
|
+
let rows;
|
|
289
|
+
try {
|
|
290
|
+
const sql = kind
|
|
291
|
+
? `SELECT e.*, bm25(${table}) AS rank FROM ${table} JOIN events e ON e.id = ${table}.id WHERE ${table} MATCH ? AND e.kind = ? ORDER BY rank LIMIT ?`
|
|
292
|
+
: `SELECT e.*, bm25(${table}) AS rank FROM ${table} JOIN events e ON e.id = ${table}.id WHERE ${table} MATCH ? ORDER BY rank LIMIT ?`;
|
|
293
|
+
rows = kind
|
|
294
|
+
? db.prepare(sql).all(candidate.match, kind, limit)
|
|
295
|
+
: db.prepare(sql).all(candidate.match, limit);
|
|
296
|
+
} catch {
|
|
297
|
+
continue; // FTS syntax errors are expected for hostile input: try next candidate
|
|
298
|
+
}
|
|
299
|
+
if (rows.length > 0) return { rows: rows.map(parseRow), strategy: `${table}:${candidate.label}` };
|
|
300
|
+
}
|
|
301
|
+
return null;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Query the index. Never throws for hostile queries: candidate chain + try/catch,
|
|
306
|
+
*全失败返回 { ok:true, rows:[], code:'E_FTS_NO_MATCH' }。
|
|
307
|
+
*/
|
|
308
|
+
function search(query, opts = {}) {
|
|
309
|
+
const limit = Number.isInteger(opts.limit) ? Math.min(Math.max(opts.limit, 1), 100) : 10;
|
|
310
|
+
const kind = typeof opts.kind === 'string' && opts.kind !== '' ? opts.kind : undefined;
|
|
311
|
+
const candidates = buildFtsCandidates(query);
|
|
312
|
+
const tried = [];
|
|
313
|
+
if (candidates.normalized === '') {
|
|
314
|
+
return { ok: true, schema: EVENT_SCHEMA, query: '', normalized: '', rows: [], code: 'E_FTS_EMPTY_QUERY', strategy: null, tried: [] };
|
|
315
|
+
}
|
|
316
|
+
for (const candidate of candidates.raw) tried.push(`events_fts:${candidate.label}`);
|
|
317
|
+
const rawHit = runCandidates('events_fts', candidates.raw, limit, kind);
|
|
318
|
+
if (rawHit) {
|
|
319
|
+
return { ok: true, schema: EVENT_SCHEMA, query: String(query), normalized: candidates.normalized, rows: rawHit.rows, strategy: rawHit.strategy, tried };
|
|
320
|
+
}
|
|
321
|
+
for (const candidate of candidates.terms) tried.push(`events_terms:${candidate.label}`);
|
|
322
|
+
const termHit = runCandidates('events_terms', candidates.terms, limit, kind);
|
|
323
|
+
if (termHit) {
|
|
324
|
+
return { ok: true, schema: EVENT_SCHEMA, query: String(query), normalized: candidates.normalized, rows: termHit.rows, strategy: termHit.strategy, tried };
|
|
325
|
+
}
|
|
326
|
+
return { ok: true, schema: EVENT_SCHEMA, query: String(query), normalized: candidates.normalized, rows: [], code: 'E_FTS_NO_MATCH', strategy: null, tried };
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function listEvents(opts = {}) {
|
|
330
|
+
const limit = Number.isInteger(opts.limit) ? Math.min(Math.max(opts.limit, 1), 500) : 20;
|
|
331
|
+
const offset = Number.isInteger(opts.offset) && opts.offset > 0 ? opts.offset : 0;
|
|
332
|
+
const kind = typeof opts.kind === 'string' && opts.kind !== '' ? opts.kind : null;
|
|
333
|
+
const rows = kind
|
|
334
|
+
? db.prepare('SELECT * FROM events WHERE kind = ? ORDER BY time_end DESC, id ASC LIMIT ? OFFSET ?').all(kind, limit, offset)
|
|
335
|
+
: db.prepare('SELECT * FROM events ORDER BY time_end DESC, id ASC LIMIT ? OFFSET ?').all(limit, offset);
|
|
336
|
+
return rows.map(parseRow);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function count(opts = {}) {
|
|
340
|
+
const kind = typeof opts.kind === 'string' && opts.kind !== '' ? opts.kind : null;
|
|
341
|
+
const row = kind
|
|
342
|
+
? db.prepare('SELECT COUNT(*) AS n FROM events WHERE kind = ?').get(kind)
|
|
343
|
+
: db.prepare('SELECT COUNT(*) AS n FROM events').get();
|
|
344
|
+
return row.n;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/** Read one EventRecord by id (AutoCard source-of-truth check). */
|
|
348
|
+
function getEvent(id) {
|
|
349
|
+
if (typeof id !== 'string' || id === '') return null;
|
|
350
|
+
const row = db.prepare('SELECT * FROM events WHERE id = ?').get(id);
|
|
351
|
+
return row ? parseRow(row) : null;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function deleteSession(sessionId) {
|
|
355
|
+
const rows = db.prepare('SELECT id FROM events WHERE session_id = ?').all(sessionId);
|
|
356
|
+
db.exec('BEGIN IMMEDIATE');
|
|
357
|
+
try {
|
|
358
|
+
for (const row of rows) {
|
|
359
|
+
db.prepare('DELETE FROM events_fts WHERE id = ?').run(row.id);
|
|
360
|
+
db.prepare('DELETE FROM events_terms WHERE id = ?').run(row.id);
|
|
361
|
+
}
|
|
362
|
+
db.prepare('DELETE FROM events WHERE session_id = ?').run(sessionId);
|
|
363
|
+
db.exec('COMMIT');
|
|
364
|
+
} catch (error) {
|
|
365
|
+
db.exec('ROLLBACK');
|
|
366
|
+
throw new IndexError('E_INDEX_DELETE', `session delete failed: ${error.message}`, { sessionId });
|
|
367
|
+
}
|
|
368
|
+
return { ok: true, removed: rows.length };
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function stats() {
|
|
372
|
+
const sizeOf = (file) => {
|
|
373
|
+
try {
|
|
374
|
+
return fs.statSync(file).size;
|
|
375
|
+
} catch {
|
|
376
|
+
return 0;
|
|
377
|
+
}
|
|
378
|
+
};
|
|
379
|
+
return {
|
|
380
|
+
schema: EVENT_SCHEMA,
|
|
381
|
+
schemaVersion: INDEX_SCHEMA_VERSION,
|
|
382
|
+
dbPath,
|
|
383
|
+
// WAL means the main db file stays small until checkpoint: report the family.
|
|
384
|
+
dbBytes: sizeOf(dbPath) + sizeOf(`${dbPath}-wal`) + sizeOf(`${dbPath}-shm`),
|
|
385
|
+
events: count(),
|
|
386
|
+
ftsRows: db.prepare('SELECT COUNT(*) AS n FROM events_fts').get().n,
|
|
387
|
+
termRows: db.prepare('SELECT COUNT(*) AS n FROM events_terms').get().n,
|
|
388
|
+
sessions: (() => {
|
|
389
|
+
const row = db.prepare('SELECT COUNT(DISTINCT session_id) AS n FROM events').get();
|
|
390
|
+
return row.n;
|
|
391
|
+
})(),
|
|
392
|
+
kinds: Object.fromEntries(db.prepare('SELECT kind, COUNT(*) AS n FROM events GROUP BY kind').all().map((row) => [row.kind, row.n])),
|
|
393
|
+
lastWriteAt: getMeta('last_write_at'),
|
|
394
|
+
};
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function close() {
|
|
398
|
+
if (closed) return;
|
|
399
|
+
closed = true;
|
|
400
|
+
db.close();
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
return { memoryRoot, paths, dbPath, upsertEvent, deleteSession, search, listEvents, count, getEvent, stats, getMeta, setMeta, close };
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/** Extract per-turn EventRecords from a parsed session. */
|
|
407
|
+
export function extractTurns(header, events, frames = [], texts = []) {
|
|
408
|
+
const sessionId = typeof header?.id === 'string' && header.id !== ''
|
|
409
|
+
? header.id
|
|
410
|
+
: (typeof header?.sessionId === 'string' && header.sessionId !== '' ? header.sessionId : null);
|
|
411
|
+
if (sessionId === null) throw new IndexError('E_SESSION_ID', 'session header carries no id');
|
|
412
|
+
const project = typeof header?.cwd === 'string' ? header.cwd : null;
|
|
413
|
+
|
|
414
|
+
// Map seq -> frame index (frame 0 carries the header line).
|
|
415
|
+
const seqToFrame = new Map();
|
|
416
|
+
let lineNo = 0;
|
|
417
|
+
(Array.isArray(texts) ? texts : []).forEach((text, frameIndex) => {
|
|
418
|
+
for (const line of String(text).split('\n')) {
|
|
419
|
+
if (line === '') continue;
|
|
420
|
+
if (lineNo === 0) {
|
|
421
|
+
lineNo = 1;
|
|
422
|
+
continue;
|
|
423
|
+
}
|
|
424
|
+
seqToFrame.set(lineNo - 1, frameIndex);
|
|
425
|
+
lineNo += 1;
|
|
426
|
+
}
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
let currentTurn = 0;
|
|
430
|
+
const groups = new Map();
|
|
431
|
+
for (const event of events) {
|
|
432
|
+
const turn = Number(event?.data?.turn);
|
|
433
|
+
if (Number.isInteger(turn) && turn >= 0) currentTurn = turn;
|
|
434
|
+
if (!groups.has(currentTurn)) groups.set(currentTurn, []);
|
|
435
|
+
groups.get(currentTurn).push(event);
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
const out = [];
|
|
439
|
+
for (const [turn, list] of [...groups.entries()].sort((a, b) => a[0] - b[0])) {
|
|
440
|
+
const seqStart = list[0].seq;
|
|
441
|
+
const seqEnd = list[list.length - 1].seq;
|
|
442
|
+
const times = list.map((event) => Number(event?.time)).filter((value) => Number.isFinite(value));
|
|
443
|
+
const userTexts = [];
|
|
444
|
+
const assistantTexts = [];
|
|
445
|
+
let toolCalls = 0;
|
|
446
|
+
for (const event of list) {
|
|
447
|
+
if (event.type === 'user/message' && event?.data?.source?.kind === 'user') userTexts.push(textOf(event));
|
|
448
|
+
else if (event.type === 'assistant/message') assistantTexts.push(textOf(event));
|
|
449
|
+
else if (event.type === 'tool/call') toolCalls += 1;
|
|
450
|
+
}
|
|
451
|
+
const corpus = [...userTexts, ...assistantTexts].filter((text) => text !== '').join('\n');
|
|
452
|
+
const counts = { userMsgs: userTexts.length, assistantMsgs: assistantTexts.length, toolCalls };
|
|
453
|
+
const kind = classifyKind(corpus, counts);
|
|
454
|
+
const head = userTexts[0] ?? assistantTexts[0] ?? '';
|
|
455
|
+
const tail = assistantTexts[assistantTexts.length - 1] ?? '';
|
|
456
|
+
const summary = [head.slice(0, 160), tail === '' ? '' : `→ ${tail.slice(0, 160)}`].filter(Boolean).join(' ').slice(0, 400);
|
|
457
|
+
const terms = extractTerms(corpus, [sessionId]);
|
|
458
|
+
const topics = [...new Set(terms.filter((term) => /[\u3400-\u4dbf\u4e00-\u9fff]/.test(term)))].slice(0, 5);
|
|
459
|
+
const digest = turnDigest(events, seqStart, seqEnd);
|
|
460
|
+
const anchor = makeSessionTurnAnchor({ sessionId, turn, seqStart, seqEnd, digest, status: 'live' });
|
|
461
|
+
const frameIndexes = [...new Set(list.map((event) => seqToFrame.get(event.seq)).filter((value) => Number.isInteger(value)))];
|
|
462
|
+
out.push({
|
|
463
|
+
id: `evt:${sessionId}:${turn}`,
|
|
464
|
+
sessionId,
|
|
465
|
+
turn,
|
|
466
|
+
seqStart,
|
|
467
|
+
seqEnd,
|
|
468
|
+
timeStart: times.length > 0 ? Math.min(...times) : null,
|
|
469
|
+
timeEnd: times.length > 0 ? Math.max(...times) : null,
|
|
470
|
+
project,
|
|
471
|
+
kind,
|
|
472
|
+
topics,
|
|
473
|
+
terms,
|
|
474
|
+
summary,
|
|
475
|
+
counts,
|
|
476
|
+
anchors: [anchor],
|
|
477
|
+
digest,
|
|
478
|
+
frameIndexes,
|
|
479
|
+
rawText: rawTextFor({ kind, summary, terms, topics, project }),
|
|
480
|
+
source: 'rebuild',
|
|
481
|
+
});
|
|
482
|
+
}
|
|
483
|
+
return out;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
/** Recursively discover DSH session logs under the given roots. */
|
|
487
|
+
export function discoverSessionLogs(roots) {
|
|
488
|
+
const out = [];
|
|
489
|
+
const stack = (Array.isArray(roots) ? roots : [roots])
|
|
490
|
+
.filter((root) => typeof root === 'string' && root !== '')
|
|
491
|
+
.map((root) => path.resolve(root));
|
|
492
|
+
const seen = new Set();
|
|
493
|
+
while (stack.length > 0) {
|
|
494
|
+
const dir = stack.pop();
|
|
495
|
+
if (seen.has(dir)) continue;
|
|
496
|
+
seen.add(dir);
|
|
497
|
+
let entries = [];
|
|
498
|
+
try {
|
|
499
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
500
|
+
} catch {
|
|
501
|
+
continue;
|
|
502
|
+
}
|
|
503
|
+
for (const entry of entries) {
|
|
504
|
+
const full = path.join(dir, entry.name);
|
|
505
|
+
if (entry.isDirectory()) {
|
|
506
|
+
stack.push(full);
|
|
507
|
+
} else if (entry.isFile() && entry.name === 'session.v4.jsonl.zstd') {
|
|
508
|
+
out.push({ file: full, sessionId: path.basename(dir) });
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
return out.sort((a, b) => a.file.localeCompare(b.file));
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
function defaultSessionRoots() {
|
|
516
|
+
const dshHome = typeof process.env.DSH_HOME === 'string' && process.env.DSH_HOME.trim() !== ''
|
|
517
|
+
? path.resolve(process.env.DSH_HOME.trim())
|
|
518
|
+
: path.join(os.homedir(), '.dsh');
|
|
519
|
+
return [path.join(dshHome, 'sessions')];
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/** Remove every derived index artifact (used by the delete-rebuild acceptance). */
|
|
523
|
+
export function deleteIndex(memoryRoot) {
|
|
524
|
+
const paths = indexPaths(memoryRoot);
|
|
525
|
+
const removed = [];
|
|
526
|
+
for (const file of [paths.db, `${paths.db}-wal`, `${paths.db}-shm`, paths.audit, paths.buildState]) {
|
|
527
|
+
try {
|
|
528
|
+
fs.rmSync(file, { force: true });
|
|
529
|
+
removed.push(file);
|
|
530
|
+
} catch {
|
|
531
|
+
/* best effort */
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
try {
|
|
535
|
+
fs.rmSync(paths.framesDir, { recursive: true, force: true });
|
|
536
|
+
removed.push(paths.framesDir);
|
|
537
|
+
} catch {
|
|
538
|
+
/* best effort */
|
|
539
|
+
}
|
|
540
|
+
return { ok: true, removed };
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
/**
|
|
544
|
+
* Build (or incrementally extend) the event index from L0 session logs.
|
|
545
|
+
* L0 is opened read-only; every write stays under <memoryRoot>/.longlive/index/.
|
|
546
|
+
*/
|
|
547
|
+
export function buildIndexFromSessions(options = {}) {
|
|
548
|
+
const memoryRoot = path.resolve(String(options.memoryRoot ?? ''));
|
|
549
|
+
if (memoryRoot === '') throw new IndexError('E_MEMORY_ROOT', 'memoryRoot required');
|
|
550
|
+
const now = typeof options.now === 'function' ? options.now : () => new Date();
|
|
551
|
+
const started = Date.now();
|
|
552
|
+
const sessionRoots = Array.isArray(options.sessionRoots) && options.sessionRoots.length > 0
|
|
553
|
+
? options.sessionRoots
|
|
554
|
+
: defaultSessionRoots();
|
|
555
|
+
const limitSessions = Number.isInteger(options.limitSessions) && options.limitSessions > 0
|
|
556
|
+
? options.limitSessions
|
|
557
|
+
: Number.POSITIVE_INFINITY;
|
|
558
|
+
if (options.rebuild === true) deleteIndex(memoryRoot);
|
|
559
|
+
|
|
560
|
+
const paths = indexPaths(memoryRoot);
|
|
561
|
+
const index = openEventIndex({ memoryRoot, now, ...(options.dbPath ? { dbPath: options.dbPath } : {}) });
|
|
562
|
+
const logs = discoverSessionLogs(sessionRoots).slice(0, limitSessions);
|
|
563
|
+
const built = [];
|
|
564
|
+
const duplicates = [];
|
|
565
|
+
const errors = [];
|
|
566
|
+
let inserted = 0;
|
|
567
|
+
let skipped = 0;
|
|
568
|
+
let eventsIndexed = 0;
|
|
569
|
+
const seenSessionDigests = new Map();
|
|
570
|
+
|
|
571
|
+
for (const log of logs) {
|
|
572
|
+
try {
|
|
573
|
+
const buf = fs.readFileSync(log.file);
|
|
574
|
+
const parsed = parseSession(buf);
|
|
575
|
+
const stats = fs.statSync(log.file);
|
|
576
|
+
const sessionId = typeof parsed.header?.id === 'string' && parsed.header.id !== ''
|
|
577
|
+
? parsed.header.id
|
|
578
|
+
: log.sessionId;
|
|
579
|
+
const sessionDigest = deriveMessagesDigest(parsed.header, parsed.events).sha256;
|
|
580
|
+
const duplicateOf = seenSessionDigests.get(sessionDigest);
|
|
581
|
+
if (duplicateOf !== undefined) {
|
|
582
|
+
duplicates.push({ sessionId, duplicateOf, file: log.file });
|
|
583
|
+
continue;
|
|
584
|
+
}
|
|
585
|
+
seenSessionDigests.set(sessionDigest, sessionId);
|
|
586
|
+
const records = extractTurns(parsed.header, parsed.events, parsed.frames, parsed.texts);
|
|
587
|
+
let sessionInserted = 0;
|
|
588
|
+
for (const record of records) {
|
|
589
|
+
record.builtAt = now().getTime();
|
|
590
|
+
record.source = options.source ?? 'rebuild';
|
|
591
|
+
const result = index.upsertEvent(record, { source: record.source });
|
|
592
|
+
if (result.inserted) {
|
|
593
|
+
inserted += 1;
|
|
594
|
+
sessionInserted += 1;
|
|
595
|
+
} else {
|
|
596
|
+
skipped += 1;
|
|
597
|
+
}
|
|
598
|
+
eventsIndexed += 1;
|
|
599
|
+
}
|
|
600
|
+
fs.writeFileSync(path.join(paths.framesDir, `${sessionId}.json`), `${JSON.stringify({
|
|
601
|
+
schema: 'frames/v1',
|
|
602
|
+
sessionId,
|
|
603
|
+
sessionDigest,
|
|
604
|
+
logBytes: buf.length,
|
|
605
|
+
logMtimeMs: stats.mtimeMs,
|
|
606
|
+
logSha256: crypto.createHash('sha256').update(buf).digest('hex'),
|
|
607
|
+
frames: parsed.frames.length,
|
|
608
|
+
turns: records.map((record) => ({
|
|
609
|
+
turn: record.turn,
|
|
610
|
+
seqStart: record.seqStart,
|
|
611
|
+
seqEnd: record.seqEnd,
|
|
612
|
+
frames: record.frameIndexes,
|
|
613
|
+
})),
|
|
614
|
+
}, null, 2)}\n`, 'utf8');
|
|
615
|
+
built.push({
|
|
616
|
+
sessionId,
|
|
617
|
+
dirSessionId: log.sessionId,
|
|
618
|
+
headerId: sessionId,
|
|
619
|
+
turns: records.length,
|
|
620
|
+
inserted: sessionInserted,
|
|
621
|
+
bytes: buf.length,
|
|
622
|
+
logBytes: buf.length,
|
|
623
|
+
logMtimeMs: stats.mtimeMs,
|
|
624
|
+
lastSeq: parsed.events.length - 1,
|
|
625
|
+
lastTurn: records.length > 0 ? records[records.length - 1].turn : -1,
|
|
626
|
+
header: { id: parsed.header?.id ?? null, cwd: parsed.header?.cwd ?? null },
|
|
627
|
+
sessionDigest,
|
|
628
|
+
file: log.file,
|
|
629
|
+
});
|
|
630
|
+
} catch (error) {
|
|
631
|
+
errors.push({ file: log.file, code: error?.code ?? 'E_SESSION_PARSE', message: String(error?.message ?? error) });
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
const buildState = {
|
|
636
|
+
schema: 'events-build-state/v1',
|
|
637
|
+
builtAt: nowIso(now),
|
|
638
|
+
schemaVersion: INDEX_SCHEMA_VERSION,
|
|
639
|
+
memoryRoot,
|
|
640
|
+
sessions: built.map((entry) => ({
|
|
641
|
+
sessionId: entry.sessionId,
|
|
642
|
+
dirSessionId: entry.dirSessionId ?? entry.sessionId,
|
|
643
|
+
headerId: entry.headerId,
|
|
644
|
+
turns: entry.turns,
|
|
645
|
+
bytes: entry.bytes,
|
|
646
|
+
logBytes: entry.logBytes,
|
|
647
|
+
logMtimeMs: entry.logMtimeMs,
|
|
648
|
+
lastSeq: entry.lastSeq,
|
|
649
|
+
lastTurn: entry.lastTurn,
|
|
650
|
+
header: entry.header,
|
|
651
|
+
sessionDigest: entry.sessionDigest,
|
|
652
|
+
})),
|
|
653
|
+
duplicates,
|
|
654
|
+
errors,
|
|
655
|
+
stats: {
|
|
656
|
+
sessionsScanned: logs.length,
|
|
657
|
+
sessionsBuilt: built.length,
|
|
658
|
+
duplicateSessions: duplicates.length,
|
|
659
|
+
turns: eventsIndexed,
|
|
660
|
+
inserted,
|
|
661
|
+
skipped,
|
|
662
|
+
errors: errors.length,
|
|
663
|
+
durationMs: Date.now() - started,
|
|
664
|
+
},
|
|
665
|
+
generatedAt: nowIso(now),
|
|
666
|
+
};
|
|
667
|
+
fs.writeFileSync(paths.buildState, `${JSON.stringify(buildState, null, 2)}\n`, 'utf8');
|
|
668
|
+
index.setMeta('build_state_at', buildState.builtAt);
|
|
669
|
+
const finalStats = index.stats();
|
|
670
|
+
index.close();
|
|
671
|
+
return { ok: true, memoryRoot, paths, ...buildState.stats, duplicateSessions: duplicates.length, dbBytes: finalStats.dbBytes, built: built.length };
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
/** Panel read-only route #11 reader: list indexed events; undefined when no index. */
|
|
675
|
+
export function createEventsIndexReader(options = {}) {
|
|
676
|
+
const memoryRoot = path.resolve(String(options.memoryRoot ?? ''));
|
|
677
|
+
const now = typeof options.now === 'function' ? options.now : () => new Date();
|
|
678
|
+
const paths = indexPaths(memoryRoot);
|
|
679
|
+
if (!fs.existsSync(paths.db)) return undefined;
|
|
680
|
+
return function eventsReader(query = {}) {
|
|
681
|
+
const index = openEventIndex({ memoryRoot, now });
|
|
682
|
+
try {
|
|
683
|
+
const limit = Number.isInteger(query.limit) ? Math.min(Math.max(query.limit, 1), 200) : 20;
|
|
684
|
+
const offset = Number.isInteger(query.offset) && query.offset > 0 ? query.offset : 0;
|
|
685
|
+
const kind = typeof query.kind === 'string' && query.kind !== '' ? query.kind : undefined;
|
|
686
|
+
const rows = index.listEvents({ limit, offset, kind });
|
|
687
|
+
return {
|
|
688
|
+
schema: EVENT_SCHEMA,
|
|
689
|
+
events: rows,
|
|
690
|
+
total: index.count({ kind }),
|
|
691
|
+
limit,
|
|
692
|
+
offset,
|
|
693
|
+
generatedAt: now().toISOString(),
|
|
694
|
+
};
|
|
695
|
+
} finally {
|
|
696
|
+
index.close();
|
|
697
|
+
}
|
|
698
|
+
};
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
|
|
702
|
+
/**
|
|
703
|
+
* Parse appended tail frames (no session header line) starting at startSeq.
|
|
704
|
+
* Host appends whole zstd frames, so (previous logBytes → new EOF) is frame aligned.
|
|
705
|
+
*/
|
|
706
|
+
export function parseSessionTail(buf, options = {}) {
|
|
707
|
+
const startSeq = Number.isInteger(options.startSeq) ? options.startSeq : 0;
|
|
708
|
+
const frames = splitFrames(buf);
|
|
709
|
+
const texts = frames.map((frame) => decompressFrame(buf.subarray(frame.start, frame.end)));
|
|
710
|
+
const events = [];
|
|
711
|
+
for (const text of texts) {
|
|
712
|
+
for (const line of String(text).split('\n')) {
|
|
713
|
+
if (line === '') continue;
|
|
714
|
+
let event = null;
|
|
715
|
+
try {
|
|
716
|
+
event = JSON.parse(line);
|
|
717
|
+
} catch (error) {
|
|
718
|
+
throw new IndexError('E_TAIL_PARSE', 'tail line is not JSON: ' + error.message);
|
|
719
|
+
}
|
|
720
|
+
events.push(event);
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
events.forEach((event, index) => {
|
|
724
|
+
if (event.seq !== startSeq + index) {
|
|
725
|
+
throw new IndexError('E_TAIL_SEQ', 'tail seq ' + event.seq + ' (expected ' + (startSeq + index) + '); log rewritten, not appended');
|
|
726
|
+
}
|
|
727
|
+
});
|
|
728
|
+
return { frames, texts, events };
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
function readFromOffset(file, offset) {
|
|
732
|
+
const fd = fs.openSync(file, 'r');
|
|
733
|
+
try {
|
|
734
|
+
const size = fs.fstatSync(fd).size;
|
|
735
|
+
if (offset >= size) return Buffer.alloc(0);
|
|
736
|
+
const buf = Buffer.alloc(size - offset);
|
|
737
|
+
fs.readSync(fd, buf, 0, size - offset, offset);
|
|
738
|
+
return buf;
|
|
739
|
+
} finally {
|
|
740
|
+
fs.closeSync(fd);
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
/**
|
|
745
|
+
* Incremental build: unchanged logs are skipped; appended logs read ONLY the new
|
|
746
|
+
* tail frames (from build-state.logBytes) and index just the new turns. Any doubt
|
|
747
|
+
* (shrunk file, non-frame-aligned tail, tail starting mid-turn) falls back to a
|
|
748
|
+
* full re-parse of that session — digest skip in upsertEvent stays as the safety net.
|
|
749
|
+
*/
|
|
750
|
+
export function buildIndexIncremental(options = {}) {
|
|
751
|
+
const memoryRoot = path.resolve(String(options.memoryRoot ?? ''));
|
|
752
|
+
if (memoryRoot === '') throw new IndexError('E_MEMORY_ROOT', 'memoryRoot required');
|
|
753
|
+
const now = typeof options.now === 'function' ? options.now : () => new Date();
|
|
754
|
+
const started = Date.now();
|
|
755
|
+
const sessionRoots = Array.isArray(options.sessionRoots) && options.sessionRoots.length > 0
|
|
756
|
+
? options.sessionRoots
|
|
757
|
+
: defaultSessionRoots();
|
|
758
|
+
const paths = indexPaths(memoryRoot);
|
|
759
|
+
let previousState = null;
|
|
760
|
+
try {
|
|
761
|
+
previousState = JSON.parse(fs.readFileSync(paths.buildState, 'utf8'));
|
|
762
|
+
} catch {
|
|
763
|
+
previousState = null;
|
|
764
|
+
}
|
|
765
|
+
if (!previousState || !Array.isArray(previousState.sessions)) {
|
|
766
|
+
const full = buildIndexFromSessions({
|
|
767
|
+
memoryRoot,
|
|
768
|
+
sessionRoots,
|
|
769
|
+
now,
|
|
770
|
+
...(options.limitSessions ? { limitSessions: options.limitSessions } : {}),
|
|
771
|
+
});
|
|
772
|
+
return { ok: true, mode: 'full-fallback', ...full };
|
|
773
|
+
}
|
|
774
|
+
const index = openEventIndex({ memoryRoot, now, ...(options.dbPath ? { dbPath: options.dbPath } : {}) });
|
|
775
|
+
const logs = discoverSessionLogs(sessionRoots).slice(0, Number.isInteger(options.limitSessions) ? options.limitSessions : Number.POSITIVE_INFINITY);
|
|
776
|
+
const stateBySession = new Map(previousState.sessions.map((entry) => [entry.dirSessionId ?? entry.sessionId, { ...entry }]));
|
|
777
|
+
const duplicates = Array.isArray(previousState.duplicates) ? [...previousState.duplicates] : [];
|
|
778
|
+
const errors = [];
|
|
779
|
+
const stats = {
|
|
780
|
+
sessionsScanned: logs.length,
|
|
781
|
+
sessionsUnchanged: 0,
|
|
782
|
+
sessionsAppended: 0,
|
|
783
|
+
sessionsRebuilt: 0,
|
|
784
|
+
sessionsNew: 0,
|
|
785
|
+
turns: 0,
|
|
786
|
+
inserted: 0,
|
|
787
|
+
skipped: 0,
|
|
788
|
+
errors: 0,
|
|
789
|
+
};
|
|
790
|
+
|
|
791
|
+
const rebuildOne = (log, stat) => {
|
|
792
|
+
const buf = fs.readFileSync(log.file);
|
|
793
|
+
const parsed = parseSession(buf);
|
|
794
|
+
const headerId = typeof parsed.header?.id === 'string' && parsed.header.id !== '' ? parsed.header.id : log.sessionId;
|
|
795
|
+
index.deleteSession(headerId);
|
|
796
|
+
const records = extractTurns(parsed.header, parsed.events, parsed.frames, parsed.texts);
|
|
797
|
+
let inserted = 0;
|
|
798
|
+
for (const record of records) {
|
|
799
|
+
record.builtAt = now().getTime();
|
|
800
|
+
record.source = 'incremental-rebuild';
|
|
801
|
+
const result = index.upsertEvent(record, { source: record.source });
|
|
802
|
+
if (result.inserted) inserted += 1;
|
|
803
|
+
}
|
|
804
|
+
stateBySession.set(log.sessionId, {
|
|
805
|
+
sessionId: headerId,
|
|
806
|
+
dirSessionId: log.sessionId,
|
|
807
|
+
headerId,
|
|
808
|
+
turns: records.length,
|
|
809
|
+
bytes: buf.length,
|
|
810
|
+
logBytes: buf.length,
|
|
811
|
+
logMtimeMs: stat.mtimeMs,
|
|
812
|
+
lastSeq: parsed.events.length - 1,
|
|
813
|
+
lastTurn: records.length > 0 ? records[records.length - 1].turn : -1,
|
|
814
|
+
header: { id: parsed.header?.id ?? null, cwd: parsed.header?.cwd ?? null },
|
|
815
|
+
sessionDigest: deriveMessagesDigest(parsed.header, parsed.events).sha256,
|
|
816
|
+
});
|
|
817
|
+
stats.turns += records.length;
|
|
818
|
+
stats.inserted += inserted;
|
|
819
|
+
stats.skipped += records.length - inserted;
|
|
820
|
+
return { records, inserted };
|
|
821
|
+
};
|
|
822
|
+
|
|
823
|
+
for (const log of logs) {
|
|
824
|
+
let stat = null;
|
|
825
|
+
try {
|
|
826
|
+
stat = fs.statSync(log.file);
|
|
827
|
+
} catch {
|
|
828
|
+
continue;
|
|
829
|
+
}
|
|
830
|
+
const stateEntry = stateBySession.get(log.sessionId);
|
|
831
|
+
if (stateEntry && stateEntry.logBytes === stat.size && stateEntry.logMtimeMs === stat.mtimeMs) {
|
|
832
|
+
stats.sessionsUnchanged += 1;
|
|
833
|
+
continue;
|
|
834
|
+
}
|
|
835
|
+
if (!stateEntry) {
|
|
836
|
+
try {
|
|
837
|
+
rebuildOne(log, stat);
|
|
838
|
+
stats.sessionsNew += 1;
|
|
839
|
+
} catch (error) {
|
|
840
|
+
errors.push({ file: log.file, code: error?.code ?? 'E_INCREMENTAL', message: String(error?.message ?? error) });
|
|
841
|
+
}
|
|
842
|
+
continue;
|
|
843
|
+
}
|
|
844
|
+
if (stat.size < stateEntry.logBytes) {
|
|
845
|
+
try {
|
|
846
|
+
rebuildOne(log, stat);
|
|
847
|
+
stats.sessionsRebuilt += 1;
|
|
848
|
+
} catch (error) {
|
|
849
|
+
errors.push({ file: log.file, code: error?.code ?? 'E_INCREMENTAL', message: String(error?.message ?? error) });
|
|
850
|
+
}
|
|
851
|
+
continue;
|
|
852
|
+
}
|
|
853
|
+
try {
|
|
854
|
+
const tailBuf = readFromOffset(log.file, stateEntry.logBytes);
|
|
855
|
+
if (tailBuf.length === 0) {
|
|
856
|
+
stats.sessionsUnchanged += 1;
|
|
857
|
+
continue;
|
|
858
|
+
}
|
|
859
|
+
const tail = parseSessionTail(tailBuf, { startSeq: stateEntry.lastSeq + 1 });
|
|
860
|
+
const firstTurn = Number(tail.events[0]?.data?.turn);
|
|
861
|
+
if (!Number.isInteger(firstTurn) || firstTurn <= (stateEntry.lastTurn ?? -1)) {
|
|
862
|
+
rebuildOne(log, stat);
|
|
863
|
+
stats.sessionsRebuilt += 1;
|
|
864
|
+
continue;
|
|
865
|
+
}
|
|
866
|
+
const header = { id: stateEntry.headerId ?? log.sessionId, cwd: stateEntry.header?.cwd ?? null };
|
|
867
|
+
const records = extractTurns(header, tail.events, tail.frames, tail.texts);
|
|
868
|
+
let inserted = 0;
|
|
869
|
+
for (const record of records) {
|
|
870
|
+
record.builtAt = now().getTime();
|
|
871
|
+
record.source = 'incremental-tail';
|
|
872
|
+
const result = index.upsertEvent(record, { source: record.source });
|
|
873
|
+
if (result.inserted) inserted += 1;
|
|
874
|
+
}
|
|
875
|
+
stateBySession.set(log.sessionId, {
|
|
876
|
+
...stateEntry,
|
|
877
|
+
turns: (stateEntry.turns ?? 0) + records.length,
|
|
878
|
+
logBytes: stat.size,
|
|
879
|
+
logMtimeMs: stat.mtimeMs,
|
|
880
|
+
lastSeq: tail.events[tail.events.length - 1].seq,
|
|
881
|
+
lastTurn: records.length > 0 ? records[records.length - 1].turn : stateEntry.lastTurn,
|
|
882
|
+
});
|
|
883
|
+
stats.sessionsAppended += 1;
|
|
884
|
+
stats.turns += records.length;
|
|
885
|
+
stats.inserted += inserted;
|
|
886
|
+
stats.skipped += records.length - inserted;
|
|
887
|
+
} catch {
|
|
888
|
+
try {
|
|
889
|
+
rebuildOne(log, stat);
|
|
890
|
+
stats.sessionsRebuilt += 1;
|
|
891
|
+
} catch (error2) {
|
|
892
|
+
errors.push({ file: log.file, code: error2?.code ?? 'E_INCREMENTAL', message: String(error2?.message ?? error2) });
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
stats.errors = errors.length;
|
|
898
|
+
const buildState = {
|
|
899
|
+
schema: 'events-build-state/v1',
|
|
900
|
+
builtAt: nowIso(now),
|
|
901
|
+
schemaVersion: INDEX_SCHEMA_VERSION,
|
|
902
|
+
memoryRoot,
|
|
903
|
+
mode: 'incremental',
|
|
904
|
+
sessions: [...stateBySession.values()].sort((a, b) => a.sessionId.localeCompare(b.sessionId)),
|
|
905
|
+
duplicates,
|
|
906
|
+
errors,
|
|
907
|
+
stats,
|
|
908
|
+
durationMs: Date.now() - started,
|
|
909
|
+
};
|
|
910
|
+
fs.writeFileSync(paths.buildState, JSON.stringify(buildState, null, 2) + '\n', 'utf8');
|
|
911
|
+
index.setMeta('build_state_at', buildState.builtAt);
|
|
912
|
+
const finalStats = index.stats();
|
|
913
|
+
index.close();
|
|
914
|
+
return { ok: true, mode: 'incremental', memoryRoot, paths, ...stats, dbBytes: finalStats.dbBytes, durationMs: Date.now() - started };
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
export { stableStringify };
|