@danceiny/gotry 0.0.1-rc.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (130) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +267 -0
  3. package/bin/gotry-inner.js +214 -0
  4. package/bin/gotry-stdio-ask.js +59 -0
  5. package/bin/gotry.js +28 -0
  6. package/cordis.gotry-patch.yml +86 -0
  7. package/data/flights_2026.json +218 -0
  8. package/data/golden_erhai.json +92 -0
  9. package/data/golden_trip_2026.json +75 -0
  10. package/data/hotels_2026.json +151 -0
  11. package/data/openflights-skeleton.json +895 -0
  12. package/data/time-slot-eval.json +384 -0
  13. package/data/yunnan-pack.json +202 -0
  14. package/dist/capabilities/agent-reach-bridge.py +90 -0
  15. package/dist/capabilities/agent-reach-deep.js +172 -0
  16. package/dist/capabilities/agent-reach.js +204 -0
  17. package/dist/capabilities/anything.js +139 -0
  18. package/dist/capabilities/flyai.js +131 -0
  19. package/dist/capabilities/hbcli.js +143 -0
  20. package/dist/capabilities/incident-log.js +100 -0
  21. package/dist/capabilities/opensky.js +74 -0
  22. package/dist/capabilities/session/action-cache.js +120 -0
  23. package/dist/capabilities/session/adapters/ctrip-flight.js +83 -0
  24. package/dist/capabilities/session/adapters/meituan-local.js +73 -0
  25. package/dist/capabilities/session/extract.js +40 -0
  26. package/dist/capabilities/session/read-guard.js +99 -0
  27. package/dist/capabilities/session/transport.js +101 -0
  28. package/dist/capabilities/session-search.js +93 -0
  29. package/dist/capabilities/weather.js +180 -0
  30. package/dist/scripts/action-cache-tests.js +107 -0
  31. package/dist/scripts/agent-reach-deep-tests.js +54 -0
  32. package/dist/scripts/agent-reach-tests.js +38 -0
  33. package/dist/scripts/agent-reach-wrapper-tests.js +79 -0
  34. package/dist/scripts/anything-tests.js +95 -0
  35. package/dist/scripts/async-collect.js +37 -0
  36. package/dist/scripts/companion-tests.js +112 -0
  37. package/dist/scripts/diff-test.js +39 -0
  38. package/dist/scripts/engine-run.js +14 -0
  39. package/dist/scripts/engine-tests.js +45 -0
  40. package/dist/scripts/hbcli-tests.js +101 -0
  41. package/dist/scripts/incident-tests.js +96 -0
  42. package/dist/scripts/journey-tests.js +45 -0
  43. package/dist/scripts/ledger-tests.js +365 -0
  44. package/dist/scripts/ledger-workflow-crash.js +42 -0
  45. package/dist/scripts/memory-capture-tests.js +77 -0
  46. package/dist/scripts/memory-decay-tests.js +83 -0
  47. package/dist/scripts/memory-metrics.js +24 -0
  48. package/dist/scripts/nudge-digest.js +83 -0
  49. package/dist/scripts/opensky-check.js +20 -0
  50. package/dist/scripts/opensky-tests.js +52 -0
  51. package/dist/scripts/probe-poi-tests.js +88 -0
  52. package/dist/scripts/publish-preverify.js +68 -0
  53. package/dist/scripts/replay-async.js +44 -0
  54. package/dist/scripts/replay-real.js +38 -0
  55. package/dist/scripts/replay.js +116 -0
  56. package/dist/scripts/session-attach-diagnose.js +27 -0
  57. package/dist/scripts/session-attach-poc.js +65 -0
  58. package/dist/scripts/session-extract-tests.js +81 -0
  59. package/dist/scripts/session-tests.js +162 -0
  60. package/dist/scripts/skeleton-check.js +37 -0
  61. package/dist/scripts/skeleton-integration-test.js +22 -0
  62. package/dist/scripts/skills-contract-tests.js +85 -0
  63. package/dist/scripts/smoke.js +328 -0
  64. package/dist/scripts/state-cli-tests.js +136 -0
  65. package/dist/scripts/state-cli.js +248 -0
  66. package/dist/scripts/time-eval-tests.js +318 -0
  67. package/dist/scripts/travel-timeline-tests.js +123 -0
  68. package/dist/scripts/unified-tests.js +49 -0
  69. package/dist/scripts/weather-tests.js +48 -0
  70. package/dist/src/bridge.js +34 -0
  71. package/dist/src/companions.js +112 -0
  72. package/dist/src/contracts.js +64 -0
  73. package/dist/src/dsh-llm.js +174 -0
  74. package/dist/src/engine.js +331 -0
  75. package/dist/src/index.js +1239 -0
  76. package/dist/src/journey.js +147 -0
  77. package/dist/src/loop.js +360 -0
  78. package/dist/src/memory-capture.js +40 -0
  79. package/dist/src/memory-decay.js +31 -0
  80. package/dist/src/memory-utility.js +55 -0
  81. package/dist/src/mock-llm.js +103 -0
  82. package/dist/src/model.js +134 -0
  83. package/dist/src/slot-spec.js +165 -0
  84. package/dist/src/state-ledger.js +901 -0
  85. package/dist/src/time-anchor.js +100 -0
  86. package/dist/src/tool-packet.js +12 -0
  87. package/dist/src/travel-slots.js +144 -0
  88. package/dist/src/travel-timeline.js +78 -0
  89. package/dist/src/unified.js +538 -0
  90. package/dist/src/wish-pool.js +27 -0
  91. package/package.json +84 -0
  92. package/ts/capabilities/agent-reach-deep.ts +114 -0
  93. package/ts/capabilities/agent-reach.ts +158 -0
  94. package/ts/capabilities/anything.ts +201 -0
  95. package/ts/capabilities/flyai.ts +151 -0
  96. package/ts/capabilities/hbcli.ts +152 -0
  97. package/ts/capabilities/incident-log.ts +148 -0
  98. package/ts/capabilities/opensky.ts +121 -0
  99. package/ts/capabilities/session/action-cache.ts +148 -0
  100. package/ts/capabilities/session/adapters/ctrip-flight.ts +107 -0
  101. package/ts/capabilities/session/adapters/meituan-local.ts +70 -0
  102. package/ts/capabilities/session/extract.ts +54 -0
  103. package/ts/capabilities/session/read-guard.ts +141 -0
  104. package/ts/capabilities/session/transport.ts +114 -0
  105. package/ts/capabilities/session-search.ts +129 -0
  106. package/ts/capabilities/weather.ts +176 -0
  107. package/ts/package.json +27 -0
  108. package/ts/scripts/async-collect.ts +50 -0
  109. package/ts/scripts/skeleton-check.ts +49 -0
  110. package/ts/scripts/skeleton-integration-test.ts +23 -0
  111. package/ts/scripts/state-cli.ts +201 -0
  112. package/ts/src/bridge.ts +44 -0
  113. package/ts/src/companions.ts +113 -0
  114. package/ts/src/contracts.ts +165 -0
  115. package/ts/src/dsh-llm.ts +155 -0
  116. package/ts/src/index.ts +968 -0
  117. package/ts/src/loop.ts +411 -0
  118. package/ts/src/memory-capture.ts +59 -0
  119. package/ts/src/memory-decay.ts +55 -0
  120. package/ts/src/memory-utility.ts +70 -0
  121. package/ts/src/mock-llm.ts +100 -0
  122. package/ts/src/model.ts +220 -0
  123. package/ts/src/slot-spec.ts +172 -0
  124. package/ts/src/state-ledger.ts +849 -0
  125. package/ts/src/time-anchor.ts +134 -0
  126. package/ts/src/tool-packet.ts +32 -0
  127. package/ts/src/travel-slots.ts +219 -0
  128. package/ts/src/travel-timeline.ts +83 -0
  129. package/ts/src/unified.ts +582 -0
  130. package/ts/src/wish-pool.ts +66 -0
@@ -0,0 +1,849 @@
1
+ /**
2
+ * 事务化状态账本(ADR-15,docs/transactional-state-rfc.md):
3
+ * 「文件即权威」→「单文件 SQLite 账本即权威」。
4
+ *
5
+ * - events 表 = 唯一权威(append-only;语义幂等键物理化为 UNIQUE 索引)
6
+ * - 投影表(projection_docs/items)= 日志的确定性 fold,可随时 DROP 重建
7
+ * - 红线进事务:evidence/conditions 校验发生在写事务内,拒绝即回滚账本无痕
8
+ * - 一次写 = 单事务{INSERT event; UPDATE 投影},跨文件分叉在物理上不可能
9
+ * - 语义层零改造:mergeProfile/appendTrip/upsertCompanion/appendEvent 纯函数
10
+ * 原样复用为写路径守门与 fold 处理器(memory-design §1.6「账本化只换存储面」)
11
+ *
12
+ * 迁移纪律(D2 one-shot):旧 JSON/JSONL 文件在首次写时自动导入为 events
13
+ * (单事务 + 导入前快照到 gotry-state/pre-ledger-backup/);此后文件降级为
14
+ * 导出视图(state-cli export),永不再回流。读路径带文件回退(未迁移 root
15
+ * 仍可读旧文件),写路径必经账本——工具外无写入路径。
16
+ *
17
+ * 引擎:better-sqlite3(复用矩阵 open-source import 通道),WAL + NORMAL,
18
+ * 同步 API 与仓内 readFileSync 风格一致;busy_timeout 容忍跨进程
19
+ * (dsh 运行时 / async-collect / state-cli tick 并存)。
20
+ */
21
+
22
+ import Database from 'better-sqlite3'
23
+ import { copyFileSync, existsSync, mkdirSync, readFileSync } from 'node:fs'
24
+ import { createHash } from 'node:crypto'
25
+ import { dirname, isAbsolute, join } from 'node:path'
26
+ import { mergeProfile, type MergedProfile, type ProfilePatch } from './memory-capture.ts'
27
+ import { appendEvent as gateUtilityEvent, projectUtility, type MemoryUtilityEvent, type UtilityStatus } from './memory-utility.ts'
28
+ import { appendTrip as gateTrip, type TimelineEvent } from './travel-timeline.ts'
29
+ import { upsertCompanion, type CompanionProfile, type CompanionConstraints } from './companions.ts'
30
+ import type { WishPoolEntry } from './wish-pool.ts'
31
+
32
+ const SCHEMA_VERSION = '1'
33
+
34
+ export interface LedgerEventRow {
35
+ seq: number
36
+ ts: string
37
+ actor: string
38
+ kind: string
39
+ subject_id: string
40
+ payload: string
41
+ idem_key: string | null
42
+ run_id: string | null
43
+ }
44
+
45
+ /** 稳定 wish 主键:名称语义派生(重放/同名幂等更新不再依赖墙钟;RFC §1.1 id 不稳定缺口的修复) */
46
+ export function makeWishId(name: string): string {
47
+ return 'w-' + createHash('sha256').update(String(name).trim()).digest('hex').slice(0, 10)
48
+ }
49
+
50
+ function sha(s: string): string {
51
+ return createHash('sha256').update(s).digest('hex')
52
+ }
53
+
54
+ /** stateRoot 可相对可绝对(与 incident-log.resolveIncidentsPath 同款解析) */
55
+ function stateDirOf(stateRoot: string): string {
56
+ const root = isAbsolute(stateRoot) ? stateRoot : join(process.cwd(), stateRoot)
57
+ return join(root, 'gotry-state')
58
+ }
59
+
60
+ export function ledgerDbPath(stateRoot: string): string {
61
+ return join(stateDirOf(stateRoot), 'gotry-state.db')
62
+ }
63
+
64
+ export function ledgerExists(stateRoot: string): boolean {
65
+ return existsSync(ledgerDbPath(stateRoot))
66
+ }
67
+
68
+ const LEGACY_FILES = ['motivation-profile.json', 'wish-pool.json', 'memory-utility.jsonl', 'trips.jsonl', 'companions.json'] as const
69
+
70
+ function legacyFilesPresent(stateRoot: string): boolean {
71
+ const dir = stateDirOf(stateRoot)
72
+ return LEGACY_FILES.some(f => existsSync(join(dir, f)))
73
+ }
74
+
75
+ function isUniqueViolation(e: unknown): boolean {
76
+ return e instanceof Error && (e as { code?: string }).code === 'SQLITE_CONSTRAINT_UNIQUE'
77
+ }
78
+
79
+ const SCHEMA = `
80
+ CREATE TABLE IF NOT EXISTS events (
81
+ seq INTEGER PRIMARY KEY AUTOINCREMENT,
82
+ tenant_id TEXT NOT NULL DEFAULT 'local',
83
+ ts TEXT NOT NULL,
84
+ actor TEXT NOT NULL,
85
+ kind TEXT NOT NULL,
86
+ subject_id TEXT NOT NULL DEFAULT '',
87
+ payload TEXT NOT NULL,
88
+ idem_key TEXT,
89
+ run_id TEXT
90
+ );
91
+ CREATE UNIQUE INDEX IF NOT EXISTS events_idem ON events(tenant_id, idem_key) WHERE idem_key IS NOT NULL;
92
+
93
+ CREATE TABLE IF NOT EXISTS projection_docs (
94
+ tenant_id TEXT NOT NULL DEFAULT 'local',
95
+ subject TEXT NOT NULL,
96
+ doc TEXT NOT NULL,
97
+ PRIMARY KEY (tenant_id, subject)
98
+ );
99
+ CREATE TABLE IF NOT EXISTS projection_items (
100
+ tenant_id TEXT NOT NULL DEFAULT 'local',
101
+ subject TEXT NOT NULL,
102
+ item_id TEXT NOT NULL,
103
+ doc TEXT NOT NULL,
104
+ ord INTEGER NOT NULL DEFAULT 0,
105
+ PRIMARY KEY (tenant_id, subject, item_id)
106
+ );
107
+
108
+ CREATE TABLE IF NOT EXISTS workflow_runs (
109
+ tenant_id TEXT NOT NULL DEFAULT 'local',
110
+ id TEXT NOT NULL,
111
+ goal TEXT NOT NULL,
112
+ status TEXT NOT NULL CHECK (status IN ('pending','settled','failed')),
113
+ ticket_json TEXT NOT NULL,
114
+ state_json TEXT NOT NULL,
115
+ deliverable TEXT,
116
+ created TEXT NOT NULL,
117
+ updated TEXT NOT NULL,
118
+ PRIMARY KEY (tenant_id, id)
119
+ );
120
+ CREATE TABLE IF NOT EXISTS workflow_steps (
121
+ tenant_id TEXT NOT NULL DEFAULT 'local',
122
+ run_id TEXT NOT NULL,
123
+ name TEXT NOT NULL,
124
+ status TEXT NOT NULL CHECK (status IN ('intent','done','failed')),
125
+ result TEXT,
126
+ intent_ts TEXT NOT NULL,
127
+ done_ts TEXT,
128
+ PRIMARY KEY (tenant_id, run_id, name)
129
+ );
130
+
131
+ CREATE TABLE IF NOT EXISTS pending_writes (
132
+ tenant_id TEXT NOT NULL DEFAULT 'local',
133
+ idem_key TEXT NOT NULL,
134
+ seam TEXT NOT NULL,
135
+ payload TEXT NOT NULL,
136
+ status TEXT NOT NULL CHECK (status IN ('pending','confirmed','compensated')),
137
+ receipt TEXT,
138
+ created TEXT NOT NULL,
139
+ updated TEXT NOT NULL,
140
+ PRIMARY KEY (tenant_id, idem_key)
141
+ );
142
+
143
+ CREATE TABLE IF NOT EXISTS kv (k TEXT PRIMARY KEY, v TEXT NOT NULL);
144
+ `
145
+
146
+ export interface WorkflowRunRow {
147
+ id: string
148
+ goal: string
149
+ status: string
150
+ ticket_json: string
151
+ state_json: string
152
+ deliverable: string | null
153
+ created: string
154
+ updated: string
155
+ }
156
+
157
+ export class StateLedger {
158
+ readonly db: Database.Database
159
+ readonly stateRoot: string
160
+ readonly tenant: string
161
+ readonly dbPath: string
162
+
163
+ constructor(db: Database.Database, stateRoot: string, tenant = 'local') {
164
+ this.db = db
165
+ this.stateRoot = stateRoot
166
+ this.tenant = tenant
167
+ this.dbPath = ledgerDbPath(stateRoot)
168
+ }
169
+
170
+ // ---- 基础事件面 ------------------------------------------------------------
171
+
172
+ /** 事件插入(idem 命中 → 返回 null,不抛;红线校验异常向上抛 = 事务回滚) */
173
+ insertEvent(ev: {
174
+ actor: string
175
+ kind: string
176
+ subjectId?: string
177
+ payload: unknown
178
+ idemKey?: string
179
+ runId?: string
180
+ ts?: string
181
+ }): number | null {
182
+ const stmt = this.db.prepare(
183
+ `INSERT INTO events (ts, actor, kind, subject_id, payload, idem_key, run_id)
184
+ VALUES (?, ?, ?, ?, ?, ?, ?)`,
185
+ )
186
+ try {
187
+ const info = stmt.run(
188
+ ev.ts ?? new Date().toISOString(),
189
+ ev.actor,
190
+ ev.kind,
191
+ ev.subjectId ?? '',
192
+ JSON.stringify(ev.payload),
193
+ ev.idemKey ?? null,
194
+ ev.runId ?? null,
195
+ )
196
+ return Number(info.lastInsertRowid)
197
+ } catch (e) {
198
+ if (isUniqueViolation(e)) return null
199
+ throw e
200
+ }
201
+ }
202
+
203
+ readEvents(kind?: string, limit = 100): LedgerEventRow[] {
204
+ const where = kind ? 'WHERE kind = ?' : ''
205
+ const rows = this.db.prepare(
206
+ `SELECT seq, ts, actor, kind, subject_id, payload, idem_key, run_id
207
+ FROM events ${where} ORDER BY seq DESC LIMIT ?`,
208
+ ).all(...(kind ? [kind, limit] : [limit])) as LedgerEventRow[]
209
+ return rows
210
+ }
211
+
212
+ countEvents(): number {
213
+ return (this.db.prepare('SELECT COUNT(*) AS n FROM events WHERE tenant_id = ?').get(this.tenant) as { n: number }).n
214
+ }
215
+
216
+ // ---- 读:投影与日志类事件 ----------------------------------------------------
217
+
218
+ readMotivation(): (MergedProfile & { updated_at?: string }) | null {
219
+ const row = this.db.prepare(`SELECT doc FROM projection_docs WHERE subject = 'motivation' AND tenant_id = ?`).get(this.tenant) as { doc: string } | undefined
220
+ return row ? JSON.parse(row.doc) : null
221
+ }
222
+
223
+ readWishPool(): WishPoolEntry[] {
224
+ const rows = this.db.prepare(
225
+ `SELECT doc FROM projection_items WHERE subject = 'wish_pool' AND tenant_id = ? ORDER BY ord, rowid`,
226
+ ).all(this.tenant) as Array<{ doc: string }>
227
+ return rows.map(r => JSON.parse(r.doc) as WishPoolEntry)
228
+ }
229
+
230
+ readCompanions(): CompanionProfile[] {
231
+ const rows = this.db.prepare(
232
+ `SELECT doc FROM projection_items WHERE subject = 'companions' AND tenant_id = ? ORDER BY ord, rowid`,
233
+ ).all(this.tenant) as Array<{ doc: string }>
234
+ return rows.map(r => JSON.parse(r.doc) as CompanionProfile)
235
+ }
236
+
237
+ readUtilityEvents(): MemoryUtilityEvent[] {
238
+ const rows = this.db.prepare(
239
+ `SELECT payload FROM events WHERE kind = 'memory_utility.event' AND tenant_id = ? ORDER BY seq`,
240
+ ).all(this.tenant) as Array<{ payload: string }>
241
+ return rows.map(r => (JSON.parse(r.payload) as { event: MemoryUtilityEvent }).event)
242
+ }
243
+
244
+ readTrips(): TimelineEvent[] {
245
+ const rows = this.db.prepare(
246
+ `SELECT payload FROM events WHERE kind = 'trip.logged' AND tenant_id = ? ORDER BY seq`,
247
+ ).all(this.tenant) as Array<{ payload: string }>
248
+ return rows.map(r => (JSON.parse(r.payload) as { trip: TimelineEvent }).trip)
249
+ }
250
+
251
+ /** 效用投影(与文件版 projectUtility 同源同构) */
252
+ projectUtilityNow(): Record<string, { wish_id: string; status: UtilityStatus; recalled: number; applied: number; verified: number; last_event_ts?: string }> {
253
+ return projectUtility(this.readUtilityEvents())
254
+ }
255
+
256
+ // ---- 写:单事务{INSERT event; UPDATE 投影},守门纯函数原样复用 -----------------
257
+ // 红线进事务:evidence 缺失/conditions 空 → mergeProfile/校验拒绝 → 事务回滚,账本无痕。
258
+
259
+ /** 动机画像增量补丁(守门 = mergeProfile:追加不删史/幂等/权重变更须伴新证据) */
260
+ appendMotivationPatch(patch: ProfilePatch, actor = 'tool:gotry_motivation_save'): {
261
+ saved: boolean
262
+ profile: MergedProfile & { updated_at?: string }
263
+ } {
264
+ const ts = new Date().toISOString()
265
+ const run = this.db.transaction((): { saved: boolean; profile: MergedProfile & { updated_at?: string } } => {
266
+ const current = this.readMotivation()
267
+ const merged = mergeProfile(current, patch)
268
+ if (!merged) {
269
+ const unchanged = { ...(current ?? { weights: {}, evidence: [], hard: {} }), updated_at: current?.updated_at ?? ts } as MergedProfile & { updated_at?: string }
270
+ return { saved: false, profile: unchanged }
271
+ }
272
+ const doc = { ...merged, updated_at: ts }
273
+ this.insertEvent({ actor, kind: 'motivation.patch', subjectId: 'motivation', payload: { patch }, ts })
274
+ this.db.prepare(
275
+ `INSERT INTO projection_docs (tenant_id, subject, doc) VALUES (?, 'motivation', ?)
276
+ ON CONFLICT(tenant_id, subject) DO UPDATE SET doc = excluded.doc`,
277
+ ).run(this.tenant, JSON.stringify(doc))
278
+ return { saved: true, profile: doc }
279
+ })
280
+ return run()
281
+ }
282
+
283
+ /** 愿望入池/同名幂等更新(红线:conditions 必填非空) */
284
+ appendWish(entry: {
285
+ name?: unknown
286
+ reason?: unknown
287
+ conditions?: unknown
288
+ muted?: unknown
289
+ }, actor = 'tool:gotry_wish_pool_add'): { added: boolean; wish_id: string; total: number } {
290
+ const name = String(entry.name ?? '').trim()
291
+ const conditions = entry.conditions as Record<string, unknown> | undefined
292
+ if (!name || !conditions || typeof conditions !== 'object' || Object.keys(conditions).length === 0) {
293
+ throw new Error('wish pool entry requires name and conditions (fulfilment conditions are the whole point)')
294
+ }
295
+ const ts = new Date().toISOString()
296
+ const run = this.db.transaction((): { added: boolean; wish_id: string; total: number } => {
297
+ const pool = this.readWishPool()
298
+ const existing = pool.find(w => String(w.name ?? '') === name)
299
+ if (existing) {
300
+ const id = typeof existing.wish_id === 'string' && existing.wish_id ? existing.wish_id : makeWishId(name)
301
+ const next = {
302
+ ...existing,
303
+ wish_id: id,
304
+ reason: entry.reason !== undefined ? entry.reason : existing.reason,
305
+ conditions,
306
+ ...(entry.muted !== undefined ? { muted: Boolean(entry.muted) } : {}),
307
+ } as WishPoolEntry
308
+ this.insertEvent({ actor, kind: 'wish.updated', subjectId: id, payload: { wish_id: id, patch: { reason: next.reason, conditions, ...(entry.muted !== undefined ? { muted: Boolean(entry.muted) } : {}) } }, ts })
309
+ this.upsertItem('wish_pool', id, next)
310
+ return { added: false, wish_id: id, total: pool.length }
311
+ }
312
+ const id = makeWishId(name)
313
+ const doc = {
314
+ wish_id: id,
315
+ name,
316
+ reason: entry.reason ?? '',
317
+ conditions,
318
+ ...(entry.muted !== undefined ? { muted: Boolean(entry.muted) } : {}),
319
+ added_at: ts,
320
+ } as WishPoolEntry
321
+ const seq = this.insertEvent({ actor, kind: 'wish.added', subjectId: id, payload: { wish: doc }, ts })
322
+ this.insertItem('wish_pool', id, doc, seq ?? 0)
323
+ return { added: true, wish_id: id, total: pool.length + 1 }
324
+ })
325
+ return run()
326
+ }
327
+
328
+ /** 效用事件(幂等键 = 语义 event_id;verified 无归因自动降级 applied——纯函数同款纪律) */
329
+ appendUtilityEvent(ev: Omit<MemoryUtilityEvent, 'schema' | 'event_id'>, actor = 'tool:gotry_wish_pool_list'): { appended: boolean; events: MemoryUtilityEvent[] } {
330
+ const full = gateUtilityEvent([], ev).events[0]
331
+ if (!full) return { appended: false, events: this.readUtilityEvents() }
332
+ const run = this.db.transaction((): boolean => {
333
+ return this.insertEvent({ actor, kind: 'memory_utility.event', subjectId: full.wish_id, payload: { event: full }, idemKey: `mu:${full.event_id}` }) !== null
334
+ })
335
+ return { appended: run(), events: this.readUtilityEvents() }
336
+ }
337
+
338
+ /** 时间线行程(守门 = appendTrip:必填/绝对日期/重叠冲突即停;幂等键 = trip_id) */
339
+ appendTripEvent(ev: Omit<TimelineEvent, 'schema' | 'trip_id' | 'ts'>, actor = 'tool:gotry_trip_log'): { appended: boolean; tripId?: string; reason?: string; total: number } {
340
+ const run = this.db.transaction((): { appended: boolean; tripId?: string; reason?: string; total: number } => {
341
+ const existing = this.readTrips()
342
+ const gate = gateTrip(existing, ev)
343
+ if (!gate.appended) return { appended: false, tripId: gate.tripId, reason: gate.reason, total: existing.length }
344
+ const full = gate.events[gate.events.length - 1] as TimelineEvent
345
+ this.insertEvent({ actor, kind: 'trip.logged', subjectId: full.trip_id, payload: { trip: full }, idemKey: `trip:${full.trip_id}` })
346
+ return { appended: true, tripId: full.trip_id, total: gate.events.length }
347
+ })
348
+ return run()
349
+ }
350
+
351
+ /** 同行人档案(守门 = upsertCompanion:负面清单拒收/追加不删史/幂等) */
352
+ appendCompanion(patch: { label: string; constraints: CompanionConstraints; evidence: string }, actor = 'tool:gotry_companion_save'): { appended: boolean; companionId: string; reason?: string; total: number } {
353
+ const run = this.db.transaction((): { appended: boolean; companionId: string; reason?: string; total: number } => {
354
+ const profiles = this.readCompanions()
355
+ const res = upsertCompanion(profiles, patch)
356
+ if (!res.appended) return { appended: false, companionId: res.companionId, reason: res.reason, total: profiles.length }
357
+ this.insertEvent({ actor, kind: 'companion.saved', subjectId: res.companionId, payload: { patch } })
358
+ this.replaceItems('companions', res.profiles.map(p => [p.companion_id, p]))
359
+ return { appended: true, companionId: res.companionId, total: res.profiles.length }
360
+ })
361
+ return run()
362
+ }
363
+
364
+ /**
365
+ * 成行确认(原跨两文件写 → 单事务):verified_outcome 效用事件 + 可选时间线行程,
366
+ * 同事务提交,崩溃后要么全有要么全无(RFC §1.1 partial-writes 缺口的修复)。
367
+ */
368
+ confirmOutcome(input: {
369
+ wishId: string
370
+ attribution: 'helpful' | 'harmful' | 'neutral'
371
+ detail?: string
372
+ trip?: Omit<TimelineEvent, 'schema' | 'trip_id' | 'ts'>
373
+ }, actor = 'tool:gotry_wish_pool_list'): {
374
+ recorded: boolean
375
+ trip?: { appended: boolean; tripId?: string; reason?: string }
376
+ } {
377
+ const run = this.db.transaction((): { recorded: boolean; trip?: { appended: boolean; tripId?: string; reason?: string } } => {
378
+ const now = new Date().toISOString()
379
+ const full = gateUtilityEvent([], {
380
+ wish_id: input.wishId, kind: 'verified_outcome', ts: now,
381
+ ctx: 'gotry_wish_pool_list.confirm', detail: input.detail, attribution: input.attribution,
382
+ }).events[0]
383
+ const recorded = full
384
+ ? this.insertEvent({ actor, kind: 'memory_utility.event', subjectId: full.wish_id, payload: { event: full }, idemKey: `mu:${full.event_id}` }) !== null
385
+ : false
386
+ let trip: { appended: boolean; tripId?: string; reason?: string } | undefined
387
+ if (input.trip) {
388
+ const existing = this.readTrips()
389
+ const gate = gateTrip(existing, input.trip)
390
+ if (gate.appended) {
391
+ const fullTrip = gate.events[gate.events.length - 1] as TimelineEvent
392
+ this.insertEvent({ actor, kind: 'trip.logged', subjectId: fullTrip.trip_id, payload: { trip: fullTrip }, idemKey: `trip:${fullTrip.trip_id}` })
393
+ }
394
+ trip = { appended: gate.appended, tripId: gate.tripId, reason: gate.reason }
395
+ }
396
+ return { recorded, trip }
397
+ })
398
+ return run()
399
+ }
400
+
401
+ // ---- 投影维护 ---------------------------------------------------------------
402
+
403
+ private upsertItem(subject: string, itemId: string, doc: unknown): void {
404
+ const ord = (this.db.prepare(
405
+ `SELECT ord FROM projection_items WHERE subject = ? AND item_id = ? AND tenant_id = ?`,
406
+ ).get(subject, itemId, this.tenant) as { ord: number } | undefined)?.ord ?? this.nextOrd(subject)
407
+ this.db.prepare(
408
+ `INSERT INTO projection_items (tenant_id, subject, item_id, doc, ord) VALUES (?, ?, ?, ?, ?)
409
+ ON CONFLICT(tenant_id, subject, item_id) DO UPDATE SET doc = excluded.doc`,
410
+ ).run(this.tenant, subject, itemId, JSON.stringify(doc), ord)
411
+ }
412
+
413
+ private insertItem(subject: string, itemId: string, doc: unknown, ord: number): void {
414
+ this.db.prepare(
415
+ `INSERT INTO projection_items (tenant_id, subject, item_id, doc, ord) VALUES (?, ?, ?, ?, ?)
416
+ ON CONFLICT(tenant_id, subject, item_id) DO UPDATE SET doc = excluded.doc`,
417
+ ).run(this.tenant, subject, itemId, JSON.stringify(doc), ord)
418
+ }
419
+
420
+ private replaceItems(subject: string, items: Array<[string, unknown]>): void {
421
+ this.db.prepare('DELETE FROM projection_items WHERE subject = ? AND tenant_id = ?').run(subject, this.tenant)
422
+ const ins = this.db.prepare('INSERT INTO projection_items (tenant_id, subject, item_id, doc, ord) VALUES (?, ?, ?, ?, ?)')
423
+ items.forEach(([id, doc], i) => ins.run(this.tenant, subject, id, JSON.stringify(doc), i))
424
+ }
425
+
426
+ private nextOrd(subject: string): number {
427
+ return ((this.db.prepare(
428
+ 'SELECT COALESCE(MAX(ord), -1) AS m FROM projection_items WHERE subject = ? AND tenant_id = ?',
429
+ ).get(subject, this.tenant) as { m: number }).m ?? -1) + 1
430
+ }
431
+
432
+ /**
433
+ * fold 重建:DROP 全部投影 → 重放 events(可截到 toSeq)→ 重建投影。
434
+ * 与写路径共用同一套纯函数 → 直读投影与 fold 结果一致(账本/投影永不分叉的可验证性)。
435
+ */
436
+ rebuildProjections(toSeq?: number): { events: number; wishes: number; companions: number } {
437
+ const run = this.db.transaction((): { events: number; wishes: number; companions: number } => {
438
+ this.db.prepare('DELETE FROM projection_docs WHERE tenant_id = ?').run(this.tenant)
439
+ this.db.prepare('DELETE FROM projection_items WHERE tenant_id = ?').run(this.tenant)
440
+ const rows = (toSeq === undefined
441
+ ? this.db.prepare('SELECT seq, ts, kind, subject_id, payload FROM events WHERE tenant_id = ? ORDER BY seq')
442
+ : this.db.prepare('SELECT seq, ts, kind, subject_id, payload FROM events WHERE seq <= ? AND tenant_id = ? ORDER BY seq')
443
+ ).all(...(toSeq === undefined ? [this.tenant] : [toSeq, this.tenant])) as Array<{ seq: number; ts: string; kind: string; subject_id: string; payload: string }>
444
+ for (const row of rows) this.foldEvent(row)
445
+ const wishes = (this.db.prepare(`SELECT COUNT(*) AS n FROM projection_items WHERE subject = 'wish_pool' AND tenant_id = ?`).get(this.tenant) as { n: number }).n
446
+ const companions = (this.db.prepare(`SELECT COUNT(*) AS n FROM projection_items WHERE subject = 'companions' AND tenant_id = ?`).get(this.tenant) as { n: number }).n
447
+ return { events: rows.length, wishes, companions }
448
+ })
449
+ return run()
450
+ }
451
+
452
+ private foldEvent(row: { seq: number; ts: string; kind: string; subject_id: string; payload: string }): void {
453
+ const p = JSON.parse(row.payload) as Record<string, unknown>
454
+ switch (row.kind) {
455
+ case 'motivation.imported': {
456
+ const doc = p['profile'] as MergedProfile & { updated_at?: string }
457
+ this.db.prepare(
458
+ `INSERT INTO projection_docs (tenant_id, subject, doc) VALUES (?, 'motivation', ?)
459
+ ON CONFLICT(tenant_id, subject) DO UPDATE SET doc = excluded.doc`,
460
+ ).run(this.tenant, JSON.stringify(doc))
461
+ break
462
+ }
463
+ case 'motivation.patch': {
464
+ const current = this.readMotivation()
465
+ const merged = mergeProfile(current, p['patch'] as ProfilePatch)
466
+ if (merged) {
467
+ const doc = { ...merged, updated_at: row.ts }
468
+ this.db.prepare(
469
+ `INSERT INTO projection_docs (tenant_id, subject, doc) VALUES (?, 'motivation', ?)
470
+ ON CONFLICT(tenant_id, subject) DO UPDATE SET doc = excluded.doc`,
471
+ ).run(this.tenant, JSON.stringify(doc))
472
+ }
473
+ break
474
+ }
475
+ case 'wish.imported':
476
+ case 'wish.added': {
477
+ const wish = (p['wish'] ?? p['profile']) as WishPoolEntry
478
+ this.insertItem('wish_pool', String(wish.wish_id ?? row.subject_id), wish, row.seq)
479
+ break
480
+ }
481
+ case 'wish.updated': {
482
+ const id = String(p['wish_id'])
483
+ const patch = (p['patch'] ?? {}) as Record<string, unknown>
484
+ const rows = this.db.prepare(`SELECT doc FROM projection_items WHERE subject = 'wish_pool' AND item_id = ?`).get(id) as { doc: string } | undefined
485
+ if (rows) {
486
+ const doc = { ...JSON.parse(rows.doc), ...patch } as WishPoolEntry
487
+ this.upsertItem('wish_pool', id, doc)
488
+ }
489
+ break
490
+ }
491
+ case 'companion.imported': {
492
+ const profile = p['profile'] as CompanionProfile
493
+ this.insertItem('companions', profile.companion_id, profile, row.seq)
494
+ break
495
+ }
496
+ case 'companion.saved': {
497
+ const patch = p['patch'] as { label: string; constraints: CompanionConstraints; evidence: string }
498
+ const res = upsertCompanion(this.readCompanions(), patch)
499
+ this.replaceItems('companions', res.profiles.map(c => [c.companion_id, c]))
500
+ break
501
+ }
502
+ default:
503
+ break // 日志类事件(memory_utility/trip/async/write/forget)无投影
504
+ }
505
+ }
506
+
507
+ // ---- durable 工单(TS-3) -----------------------------------------------------
508
+
509
+ createWorkflowRun(input: { id: string; goal: string; ticket: unknown; state: unknown }, actor = 'system:async-request'): void {
510
+ const ts = new Date().toISOString()
511
+ const run = this.db.transaction(() => {
512
+ this.db.prepare(
513
+ `INSERT INTO workflow_runs (tenant_id, id, goal, status, ticket_json, state_json, deliverable, created, updated)
514
+ VALUES (?, ?, ?, 'pending', ?, ?, NULL, ?, ?)
515
+ ON CONFLICT(tenant_id, id) DO UPDATE SET updated = excluded.updated`,
516
+ ).run(this.tenant, input.id, input.goal, JSON.stringify(input.ticket), JSON.stringify(input.state), ts, ts)
517
+ this.insertEvent({ actor, kind: 'async.run_created', subjectId: input.id, payload: { ticket: input.ticket }, runId: input.id, ts })
518
+ })
519
+ run()
520
+ }
521
+
522
+ getWorkflowRun(id: string): WorkflowRunRow | undefined {
523
+ return this.db.prepare('SELECT * FROM workflow_runs WHERE id = ? AND tenant_id = ?').get(id, this.tenant) as WorkflowRunRow | undefined
524
+ }
525
+
526
+ pendingWorkflowRuns(): Array<{ id: string; goal: string }> {
527
+ return this.db.prepare(`SELECT id, goal FROM workflow_runs WHERE status = 'pending' AND tenant_id = ? ORDER BY created`).all(this.tenant) as Array<{ id: string; goal: string }>
528
+ }
529
+
530
+ getWorkflowStep(runId: string, name: string): { status: string; result: string | null } | undefined {
531
+ return this.db.prepare('SELECT status, result FROM workflow_steps WHERE run_id = ? AND name = ? AND tenant_id = ?').get(runId, name, this.tenant) as { status: string; result: string | null } | undefined
532
+ }
533
+
534
+ markStepIntent(runId: string, name: string): void {
535
+ this.db.prepare(
536
+ `INSERT INTO workflow_steps (tenant_id, run_id, name, status, intent_ts) VALUES (?, ?, ?, 'intent', ?)
537
+ ON CONFLICT(tenant_id, run_id, name) DO NOTHING`,
538
+ ).run(this.tenant, runId, name, new Date().toISOString())
539
+ }
540
+
541
+ markStepDone(runId: string, name: string, result: unknown): void {
542
+ this.db.prepare(
543
+ `UPDATE workflow_steps SET status = 'done', result = ?, done_ts = ? WHERE run_id = ? AND name = ? AND tenant_id = ?`,
544
+ ).run(JSON.stringify(result), new Date().toISOString(), runId, name, this.tenant)
545
+ }
546
+
547
+ settleWorkflowRun(id: string, deliverable: string, actor = 'system:async-collect'): void {
548
+ const run = this.db.transaction(() => {
549
+ this.db.prepare(`UPDATE workflow_runs SET status = 'settled', deliverable = ?, updated = ? WHERE id = ? AND tenant_id = ?`)
550
+ .run(deliverable, new Date().toISOString(), id, this.tenant)
551
+ this.insertEvent({ actor, kind: 'async.settled', subjectId: id, payload: { bytes: deliverable.length }, runId: id })
552
+ })
553
+ run()
554
+ }
555
+
556
+ // ---- WriteGate saga 基座(TS-4, RFC S4 L2/L3 物理预备) -----------------------
557
+
558
+ /** 登记一笔待确认外部写(幂等键去重:同一确认不可能登记两次)。L2 = 只登记不执行。 */
559
+ requestPendingWrite(input: { idemKey: string; seam: string; payload: unknown }, actor = 'system:writegate'): { created: boolean; status: string } {
560
+ const ts = new Date().toISOString()
561
+ const run = this.db.transaction((): { created: boolean; status: string } => {
562
+ const info = this.db.prepare(
563
+ `INSERT INTO pending_writes (tenant_id, idem_key, seam, payload, status, created, updated)
564
+ VALUES (?, ?, ?, ?, 'pending', ?, ?) ON CONFLICT(tenant_id, idem_key) DO NOTHING`,
565
+ ).run(this.tenant, input.idemKey, input.seam, JSON.stringify(input.payload ?? {}), ts, ts)
566
+ if (info.changes === 0) {
567
+ const row = this.db.prepare('SELECT status FROM pending_writes WHERE idem_key = ? AND tenant_id = ?').get(input.idemKey, this.tenant) as { status: string }
568
+ return { created: false, status: row.status }
569
+ }
570
+ this.insertEvent({ actor, kind: 'write.pending', subjectId: input.idemKey, payload: { seam: input.seam, payload: input.payload }, idemKey: `pw:${sha(input.idemKey)}:pending` })
571
+ return { created: true, status: 'pending' }
572
+ })
573
+ return run()
574
+ }
575
+
576
+ /** L3:具名 seam 确认——pending → confirmed,携 receipt(外部世界回执) */
577
+ confirmPendingWrite(idemKey: string, receipt: string, actor = 'system:writegate'): { ok: boolean; status: string } {
578
+ const run = this.db.transaction((): { ok: boolean; status: string } => {
579
+ const info = this.db.prepare(
580
+ `UPDATE pending_writes SET status = 'confirmed', receipt = ?, updated = ? WHERE idem_key = ? AND status = 'pending' AND tenant_id = ?`,
581
+ ).run(receipt, new Date().toISOString(), idemKey, this.tenant)
582
+ if (info.changes === 0) {
583
+ const row = this.db.prepare('SELECT status FROM pending_writes WHERE idem_key = ? AND tenant_id = ?').get(idemKey, this.tenant) as { status: string } | undefined
584
+ return { ok: false, status: row?.status ?? 'missing' }
585
+ }
586
+ this.insertEvent({ actor, kind: 'write.confirmed', subjectId: idemKey, payload: { receipt } })
587
+ return { ok: true, status: 'confirmed' }
588
+ })
589
+ return run()
590
+ }
591
+
592
+ /** saga 补偿:pending/confirmed → compensated(SagaLLM 式补偿语义) */
593
+ compensatePendingWrite(idemKey: string, note: string, actor = 'system:writegate'): { ok: boolean; status: string } {
594
+ const run = this.db.transaction((): { ok: boolean; status: string } => {
595
+ const info = this.db.prepare(
596
+ `UPDATE pending_writes SET status = 'compensated', receipt = COALESCE(receipt, ?), updated = ? WHERE idem_key = ? AND status != 'compensated' AND tenant_id = ?`,
597
+ ).run(note, new Date().toISOString(), idemKey, this.tenant)
598
+ if (info.changes === 0) {
599
+ const row = this.db.prepare('SELECT status FROM pending_writes WHERE idem_key = ? AND tenant_id = ?').get(idemKey, this.tenant) as { status: string } | undefined
600
+ return { ok: false, status: row?.status ?? 'missing' }
601
+ }
602
+ this.insertEvent({ actor, kind: 'write.compensated', subjectId: idemKey, payload: { note } })
603
+ return { ok: true, status: 'compensated' }
604
+ })
605
+ return run()
606
+ }
607
+
608
+ listPendingWrites(): Array<{ idem_key: string; seam: string; status: string; receipt: string | null; created: string }> {
609
+ return this.db.prepare('SELECT idem_key, seam, status, receipt, created FROM pending_writes WHERE tenant_id = ? ORDER BY created').all(this.tenant) as Array<{ idem_key: string; seam: string; status: string; receipt: string | null; created: string }>
610
+ }
611
+
612
+ /** what-if 分叉:VACUUM INTO 副本,预演不触正本(LoopX「先只读投影后执行」的物理化) */
613
+ forkWhatIf(destPath: string): string {
614
+ this.db.prepare('VACUUM INTO ?').run(destPath)
615
+ return destPath
616
+ }
617
+
618
+ // ---- forget(D5:物理硬删,审计只留一行) ---------------------------------------
619
+
620
+ /** 物理删除某主体的全部事件并重建投影;删除本身留一行审计事件(红线 6「可删除」)。 */
621
+ forgetSubject(subjects: Array<{ kinds: string[]; subjectId: string }>, actor = 'system:state-cli'): { deleted: number } {
622
+ const run = this.db.transaction((): { deleted: number } => {
623
+ let deleted = 0
624
+ for (const s of subjects) {
625
+ const placeholders = s.kinds.map(() => '?').join(',')
626
+ const info = this.db.prepare(
627
+ `DELETE FROM events WHERE subject_id = ? AND tenant_id = ? AND kind IN (${placeholders})`,
628
+ ).run(s.subjectId, this.tenant, ...s.kinds)
629
+ deleted += info.changes
630
+ }
631
+ this.insertEvent({ actor, kind: 'forget.executed', payload: { subjects } })
632
+ this.rebuildProjections()
633
+ return { deleted }
634
+ })
635
+ return run()
636
+ }
637
+
638
+ close(): void {
639
+ this.db.close()
640
+ }
641
+ }
642
+
643
+ // ---- 打开/迁移(one-shot D2;模块级缓存避免每次读开新句柄) ----------------------
644
+
645
+ const openLedgers = new Map<string, StateLedger>()
646
+
647
+ export function openDb(stateRoot: string, tenant = 'local'): StateLedger {
648
+ const path = ledgerDbPath(stateRoot)
649
+ const cacheKey = `${path}::${tenant}`
650
+ const cached = openLedgers.get(cacheKey)
651
+ if (cached) return cached
652
+ mkdirSync(dirname(path), { recursive: true })
653
+ const db = new Database(path)
654
+ db.pragma('journal_mode = WAL')
655
+ db.pragma('synchronous = NORMAL')
656
+ db.pragma('busy_timeout = 5000')
657
+ // schema v1→v2 迁移:v1 无 tenant_id 列(user_version 未设置过,实际为 0)
658
+ const version = db.pragma('user_version', { simple: true }) as number
659
+ if (version < 2) {
660
+ // 检测是否真有 v1 数据(有 events 表但无 tenant_id 列)
661
+ const hasEventsTable = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='events'").get() !== undefined
662
+ const eventsHasTenant = hasEventsTable && (db.prepare("PRAGMA table_info(events)").all() as Array<{ name: string }>).some(c => c.name === 'tenant_id')
663
+ const isV1 = hasEventsTable && !eventsHasTenant
664
+
665
+ db.transaction(() => {
666
+ if (isV1) {
667
+ // v1 → v2:events 加 tenant_id,回填 'local';投影/工单/pending_writes 强制重建(旧表无 tenant_id)
668
+ db.exec(`
669
+ ALTER TABLE events ADD COLUMN tenant_id TEXT NOT NULL DEFAULT 'local';
670
+ DROP INDEX IF EXISTS events_idem;
671
+ CREATE UNIQUE INDEX events_idem ON events(tenant_id, idem_key) WHERE idem_key IS NOT NULL;
672
+ DROP TABLE IF EXISTS projection_docs;
673
+ DROP TABLE IF EXISTS projection_items;
674
+ DROP TABLE IF EXISTS workflow_runs;
675
+ DROP TABLE IF EXISTS workflow_steps;
676
+ DROP TABLE IF EXISTS pending_writes;
677
+ `)
678
+ } else {
679
+ // v2 幂等:检查 workflow_runs 是否缺 tenant_id(修一次跑坏的迁移)
680
+ const wrHasTenant = (db.prepare("PRAGMA table_info(workflow_runs)").all() as Array<{ name: string }>).some(c => c.name === 'tenant_id')
681
+ if (!wrHasTenant && db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='workflow_runs'").get()) {
682
+ db.exec(`
683
+ DROP TABLE IF EXISTS workflow_runs;
684
+ DROP TABLE IF EXISTS workflow_steps;
685
+ DROP TABLE IF EXISTS pending_writes;
686
+ DROP TABLE IF EXISTS projection_docs;
687
+ DROP TABLE IF EXISTS projection_items;
688
+ `)
689
+ }
690
+ }
691
+ db.exec(SCHEMA)
692
+ db.pragma('user_version = 2')
693
+ db.prepare('INSERT INTO kv (k, v) VALUES (?, ?) ON CONFLICT(k) DO UPDATE SET v = excluded.v').run('schema_version', '2')
694
+ })()
695
+ // v1 升级后:从 events 重建投影(v1 投影表已 DROP)
696
+ if (isV1) {
697
+ const ledger = new StateLedger(db, stateRoot, tenant)
698
+ ledger.rebuildProjections()
699
+ }
700
+ } else {
701
+ db.exec(SCHEMA) // 幂等建表(IF NOT EXISTS)
702
+ db.prepare('INSERT INTO kv (k, v) VALUES (?, ?) ON CONFLICT(k) DO UPDATE SET v = excluded.v').run('schema_version', '2')
703
+ }
704
+ const ledger = new StateLedger(db, stateRoot, tenant)
705
+ openLedgers.set(cacheKey, ledger)
706
+ return ledger
707
+ }
708
+
709
+ /** 只读场景:账本不存在返回 null(调用方走文件回退) */
710
+ export function openLedgerIfExists(stateRoot: string, tenant = 'local'): StateLedger | null {
711
+ if (!ledgerExists(stateRoot)) return null
712
+ return openDb(stateRoot, tenant)
713
+ }
714
+
715
+ export interface MigrationReport {
716
+ migrated: boolean
717
+ imported: { motivation: boolean; wishes: number; utilityEvents: number; trips: number; companions: number }
718
+ backupDir: string | null
719
+ }
720
+
721
+ /**
722
+ * 确保账本可用(写路径必经):
723
+ * - DB 已存在 → 直接用
724
+ * - 无 DB 无旧文件 → 建空账本(fresh root,smoke/测试形态)
725
+ * - 无 DB 有旧文件 → 快照 → 建账本 → 旧数据单事务导入为 events(one-shot;
726
+ * 导入幂等键 + kv 旗标双保险,并发 ensure 不重复导入)
727
+ */
728
+ export function ensureLedger(stateRoot: string, tenant = 'local'): StateLedger {
729
+ if (ledgerExists(stateRoot)) return openDb(stateRoot, tenant)
730
+ const dir = stateDirOf(stateRoot)
731
+ const hasLegacy = legacyFilesPresent(stateRoot)
732
+ let backupDir: string | null = null
733
+ if (hasLegacy) {
734
+ backupDir = join(dir, 'pre-ledger-backup')
735
+ mkdirSync(backupDir, { recursive: true })
736
+ for (const f of LEGACY_FILES) {
737
+ if (existsSync(join(dir, f))) copyFileSync(join(dir, f), join(backupDir, f))
738
+ }
739
+ }
740
+ const ledger = openDb(stateRoot, tenant)
741
+ if (hasLegacy) importLegacyInto(ledger, dir)
742
+ return ledger
743
+ }
744
+
745
+ function importLegacyInto(ledger: StateLedger, dir: string): MigrationReport['imported'] {
746
+ const imported = { motivation: false, wishes: 0, utilityEvents: 0, trips: 0, companions: 0 }
747
+ const run = ledger.db.transaction(() => {
748
+ const flag = ledger.db.prepare(`SELECT v FROM kv WHERE k = 'legacy_imported_v1'`).get() as { v: string } | undefined
749
+ if (flag) return
750
+ const readJsonSafe = (p: string): unknown | null => {
751
+ try { return JSON.parse(readFileSync(p, 'utf-8')) } catch { return null }
752
+ }
753
+ // 动机画像:整档快照事件(fold 直接落投影)
754
+ const profile = readJsonSafe(join(dir, 'motivation-profile.json')) as Record<string, unknown> | null
755
+ if (profile) {
756
+ if (ledger.insertEvent({ actor: 'system:migrate', kind: 'motivation.imported', subjectId: 'motivation', payload: { profile }, idemKey: 'import:motivation' }) !== null) imported.motivation = true
757
+ }
758
+ // 愿望池:逐条导入(保留原 wish_id)
759
+ const pool = readJsonSafe(join(dir, 'wish-pool.json')) as Array<Record<string, unknown>> | null
760
+ if (Array.isArray(pool)) {
761
+ for (const w of pool) {
762
+ const id = String(w['wish_id'] ?? makeWishId(String(w['name'] ?? '')))
763
+ const wish = { ...w, wish_id: id }
764
+ if (ledger.insertEvent({ actor: 'system:migrate', kind: 'wish.imported', subjectId: id, payload: { wish }, idemKey: `import:wish:${id}` }) !== null) imported.wishes++
765
+ }
766
+ }
767
+ // 效用流:逐行导入(语义 event_id 即幂等键)
768
+ try {
769
+ const lines = readFileSync(join(dir, 'memory-utility.jsonl'), 'utf-8').split('\n').filter(Boolean)
770
+ for (const line of lines) {
771
+ try {
772
+ const ev = JSON.parse(line) as MemoryUtilityEvent
773
+ if (ledger.insertEvent({ actor: 'system:migrate', kind: 'memory_utility.event', subjectId: ev.wish_id, payload: { event: ev }, idemKey: `mu:${ev.event_id}` }) !== null) imported.utilityEvents++
774
+ } catch { /* 脏行跳过:导入永不因单行失败而中断 */ }
775
+ }
776
+ } catch { /* 文件不存在 */ }
777
+ // 时间线:逐行导入(trip_id 幂等)
778
+ try {
779
+ const lines = readFileSync(join(dir, 'trips.jsonl'), 'utf-8').split('\n').filter(Boolean)
780
+ for (const line of lines) {
781
+ try {
782
+ const trip = JSON.parse(line) as TimelineEvent
783
+ if (ledger.insertEvent({ actor: 'system:migrate', kind: 'trip.logged', subjectId: trip.trip_id, payload: { trip }, idemKey: `trip:${trip.trip_id}` }) !== null) imported.trips++
784
+ } catch { /* 脏行跳过 */ }
785
+ }
786
+ } catch { /* 文件不存在 */ }
787
+ // 同行人:逐条导入(保留 companion_id)
788
+ const companions = readJsonSafe(join(dir, 'companions.json')) as Array<Record<string, unknown>> | null
789
+ if (Array.isArray(companions)) {
790
+ for (const c of companions) {
791
+ const id = String(c['companion_id'] ?? '')
792
+ if (!id) continue
793
+ if (ledger.insertEvent({ actor: 'system:migrate', kind: 'companion.imported', subjectId: id, payload: { profile: c }, idemKey: `import:companion:${id}` }) !== null) imported.companions++
794
+ }
795
+ }
796
+ ledger.rebuildProjections()
797
+ ledger.db.prepare(`INSERT INTO kv (k, v) VALUES ('legacy_imported_v1', 'done') ON CONFLICT(k) DO UPDATE SET v = excluded.v`).run()
798
+ })
799
+ run()
800
+ return imported
801
+ }
802
+
803
+ // ---- 读路径统一出口:账本优先,未迁移 root 回退旧文件(只读,绝不反向写) ---------
804
+
805
+ function readLegacyJson<T>(stateRoot: string, name: string, fallback: T): T {
806
+ try {
807
+ return JSON.parse(readFileSync(join(stateDirOf(stateRoot), name), 'utf-8')) as T
808
+ } catch {
809
+ return fallback
810
+ }
811
+ }
812
+
813
+ function readLegacyJsonl<T>(stateRoot: string, name: string): T[] {
814
+ try {
815
+ return readFileSync(join(stateDirOf(stateRoot), name), 'utf-8').split('\n').filter(Boolean).map(l => JSON.parse(l) as T)
816
+ } catch {
817
+ return []
818
+ }
819
+ }
820
+
821
+ export function readMotivationWithFallback(stateRoot: string): (MergedProfile & { updated_at?: string }) | null {
822
+ const ledger = openLedgerIfExists(stateRoot)
823
+ if (ledger) return ledger.readMotivation()
824
+ return readLegacyJson<(MergedProfile & { updated_at?: string }) | null>(stateRoot, 'motivation-profile.json', null)
825
+ }
826
+
827
+ export function readWishPoolWithFallback(stateRoot: string): WishPoolEntry[] {
828
+ const ledger = openLedgerIfExists(stateRoot)
829
+ if (ledger) return ledger.readWishPool()
830
+ return readLegacyJson<WishPoolEntry[]>(stateRoot, 'wish-pool.json', [])
831
+ }
832
+
833
+ export function readUtilityEventsWithFallback(stateRoot: string): MemoryUtilityEvent[] {
834
+ const ledger = openLedgerIfExists(stateRoot)
835
+ if (ledger) return ledger.readUtilityEvents()
836
+ return readLegacyJsonl<MemoryUtilityEvent>(stateRoot, 'memory-utility.jsonl')
837
+ }
838
+
839
+ export function readTripsWithFallback(stateRoot: string): TimelineEvent[] {
840
+ const ledger = openLedgerIfExists(stateRoot)
841
+ if (ledger) return ledger.readTrips()
842
+ return readLegacyJsonl<TimelineEvent>(stateRoot, 'trips.jsonl')
843
+ }
844
+
845
+ export function readCompanionsWithFallback(stateRoot: string): CompanionProfile[] {
846
+ const ledger = openLedgerIfExists(stateRoot)
847
+ if (ledger) return ledger.readCompanions()
848
+ return readLegacyJson<CompanionProfile[]>(stateRoot, 'companions.json', [])
849
+ }