dsh-daoing-memory 0.1.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.
Files changed (80) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +91 -0
  3. package/README.zh-CN.md +91 -0
  4. package/cordis.patch.yml +22 -0
  5. package/lib/client.js +10339 -0
  6. package/lib/client.js.map +1 -0
  7. package/lib/index.js +3138 -0
  8. package/lib/invariant.js +33 -0
  9. package/lib/tools.js +1472 -0
  10. package/lib/typert.host.d.ts +3 -0
  11. package/lib/typert.host.js +4058 -0
  12. package/lib/typert.remote-client.d.ts +149 -0
  13. package/lib/typert.remote-client.d.ts.map +1 -0
  14. package/lib/typert.remote-client.js +3167 -0
  15. package/lib/types/client/ExperiencePage.d.ts +15 -0
  16. package/lib/types/client/ExperiencePage.d.ts.map +1 -0
  17. package/lib/types/client/ExperiencePage.js +117 -0
  18. package/lib/types/client/ExperiencePage.js.map +1 -0
  19. package/lib/types/client/FactDiaryPage.d.ts +17 -0
  20. package/lib/types/client/FactDiaryPage.d.ts.map +1 -0
  21. package/lib/types/client/FactDiaryPage.js +155 -0
  22. package/lib/types/client/FactDiaryPage.js.map +1 -0
  23. package/lib/types/client/HumanOpsPage.d.ts +16 -0
  24. package/lib/types/client/HumanOpsPage.d.ts.map +1 -0
  25. package/lib/types/client/HumanOpsPage.js +208 -0
  26. package/lib/types/client/HumanOpsPage.js.map +1 -0
  27. package/lib/types/client/LedgerPage.d.ts +13 -0
  28. package/lib/types/client/LedgerPage.d.ts.map +1 -0
  29. package/lib/types/client/LedgerPage.js +113 -0
  30. package/lib/types/client/LedgerPage.js.map +1 -0
  31. package/lib/types/client/MemoryNavSection.d.ts +20 -0
  32. package/lib/types/client/MemoryNavSection.d.ts.map +1 -0
  33. package/lib/types/client/MemoryNavSection.js +23 -0
  34. package/lib/types/client/MemoryNavSection.js.map +1 -0
  35. package/lib/types/client/Workbench.d.ts +23 -0
  36. package/lib/types/client/Workbench.d.ts.map +1 -0
  37. package/lib/types/client/Workbench.js +140 -0
  38. package/lib/types/client/Workbench.js.map +1 -0
  39. package/lib/types/client/actions.d.ts +165 -0
  40. package/lib/types/client/actions.d.ts.map +1 -0
  41. package/lib/types/client/actions.js +51 -0
  42. package/lib/types/client/actions.js.map +1 -0
  43. package/lib/types/client/index.d.ts +24 -0
  44. package/lib/types/client/index.d.ts.map +1 -0
  45. package/lib/types/client/index.js +103 -0
  46. package/lib/types/client/index.js.map +1 -0
  47. package/lib/types/client/navStore.d.ts +45 -0
  48. package/lib/types/client/navStore.d.ts.map +1 -0
  49. package/lib/types/client/navStore.js +36 -0
  50. package/lib/types/client/navStore.js.map +1 -0
  51. package/lib/types/core.d.ts +195 -0
  52. package/lib/types/core.d.ts.map +1 -0
  53. package/lib/types/core.js +1304 -0
  54. package/lib/types/core.js.map +1 -0
  55. package/lib/types/index.d.ts +71 -0
  56. package/lib/types/index.d.ts.map +1 -0
  57. package/lib/types/index.js +97 -0
  58. package/lib/types/index.js.map +1 -0
  59. package/lib/types/invariant.d.ts +21 -0
  60. package/lib/types/invariant.d.ts.map +1 -0
  61. package/lib/types/invariant.js +33 -0
  62. package/lib/types/invariant.js.map +1 -0
  63. package/lib/types/service.d.ts +130 -0
  64. package/lib/types/service.d.ts.map +1 -0
  65. package/lib/types/service.js +417 -0
  66. package/lib/types/service.js.map +1 -0
  67. package/lib/types/store.d.ts +244 -0
  68. package/lib/types/store.d.ts.map +1 -0
  69. package/lib/types/store.js +864 -0
  70. package/lib/types/store.js.map +1 -0
  71. package/lib/types/tools.d.ts +25 -0
  72. package/lib/types/tools.d.ts.map +1 -0
  73. package/lib/types/tools.js +1144 -0
  74. package/lib/types/tools.js.map +1 -0
  75. package/lib/types/types.d.ts +836 -0
  76. package/lib/types/types.d.ts.map +1 -0
  77. package/lib/types/types.js +8 -0
  78. package/lib/types/types.js.map +1 -0
  79. package/package.json +106 -0
  80. package/skill/memory-extraction.md +62 -0
@@ -0,0 +1,1304 @@
1
+ /**
2
+ * Memory core: the 生·用·修·记 mechanisms over the durable store. Pure and
3
+ * ctx-free. Implements 005's P0/P1 semantics:
4
+ *
5
+ * - 生 refine: complexity gate + information-gain gate + mandatory evidence
6
+ * pointers; positive and negative candidates symmetric.
7
+ * - 用 recall: recall → adjudication (limits vs situation conflicts,
8
+ * direct/reference/clue/not-applicable) → injection budget → explicit
9
+ * negative channel.
10
+ * - 用·验 report: Beta posterior (alpha+1)/(alpha+beta+2) with recency
11
+ * weighting; four-way attribution with objective-evidence priority and
12
+ * no-count on insufficient signal; idempotent reports.
13
+ * - 修 revise: challenged quarantine (immediately out of recall), draft
14
+ * proposal, adoption only after one successful use or shadow replay,
15
+ * superseded read-only index with rollback.
16
+ * - 记 diary/facts: append-only diary, cadence-driven extraction with source
17
+ * pointers, bi-temporal facts with conflict arbitration and human gates.
18
+ * - Ledger: append-only hash-chained event sourcing for every mutation,
19
+ * including every human operation.
20
+ *
21
+ * @module dsh-daoing-memory/core
22
+ */
23
+ import { randomUUID } from 'node:crypto';
24
+ import { blockHash, estimateTokens, tokenize } from "./store.js";
25
+ /** The mechanism defaults (005 mapping documented in the README). */
26
+ export const DEFAULT_CORE_CONFIG = {
27
+ diaryExtractEvery: 8,
28
+ diaryExtractIntervalHours: 12,
29
+ recallTopK: 6,
30
+ injectionBudgetTokens: 1200,
31
+ challengeConsecutiveFails: 2,
32
+ challengeWindow: 6,
33
+ challengeWindowFailRate: 0.6,
34
+ familyLiveCap: 12,
35
+ complexityTokenGate: 4000,
36
+ complexityStepGate: 6,
37
+ duplicateOverlapGate: 0.85,
38
+ recallFloorScore: 0.1,
39
+ shadowPassRate: 0.8,
40
+ humanFloorAlpha: 5,
41
+ humanFloorBeta: 2,
42
+ pinnedTrustFloor: 0.67,
43
+ candidateTrialTopK: 3,
44
+ candidateTrialFloorScore: 0.18,
45
+ consolidateEveryNew: 6,
46
+ consolidateIntervalHours: 24,
47
+ };
48
+ /** Objective-evidence markers overriding a claimed experience-attributed failure. */
49
+ const ENVIRONMENT_EVIDENCE_PATTERN = /network|timeout|timed out|EPERM|EACCES|ECONNREFUSED|ENOTFOUND|429|503|quota|rate.?limit|unauthorized|dns|proxy|网络|超时|断网|权限|配额|限流|服务不可用|服务繁忙/i;
50
+ /**
51
+ * Source-authority priors for the ingest channel (006 §1.3): a vetted skill or
52
+ * document starts a candidate with more trust than an unreviewed note. The prior
53
+ * only affects trust once the candidate is verified — candidates never recall.
54
+ */
55
+ const INGEST_SOURCE_PRIOR = {
56
+ skill: { alpha: 5, beta: 2 },
57
+ document: { alpha: 4, beta: 2 },
58
+ book: { alpha: 4, beta: 2 },
59
+ conversation: { alpha: 2, beta: 2 },
60
+ note: { alpha: 1, beta: 2 },
61
+ other: { alpha: 1, beta: 2 },
62
+ };
63
+ /** Cosine-ish overlap of two token sets (for intra-batch dedup, 007). */
64
+ function tokenOverlap(a, b) {
65
+ if (a.size === 0 || b.size === 0)
66
+ return 0;
67
+ let hits = 0;
68
+ for (const t of a)
69
+ if (b.has(t))
70
+ hits += 1;
71
+ return hits / Math.sqrt(a.size * b.size);
72
+ }
73
+ /** The memory core: stateless over the injected store. */
74
+ export class MemoryCore {
75
+ store;
76
+ config;
77
+ /** @param store - the durable SQLite store. */
78
+ /** @param config - resolved mechanism parameters. */
79
+ constructor(store, config) {
80
+ this.store = store;
81
+ this.config = config;
82
+ }
83
+ // ── ledger helper ─────────────────────────────────────────────────────────
84
+ ledger(op, objectType, objectId, actor, payload, reason) {
85
+ this.store.appendLedger({
86
+ ts: Date.now(),
87
+ op,
88
+ objectType,
89
+ objectId,
90
+ actor,
91
+ payload: JSON.stringify(payload),
92
+ prevHash: this.store.ledgerHead(),
93
+ ...(reason === undefined ? {} : { reason }),
94
+ });
95
+ }
96
+ // ── 生: refine ────────────────────────────────────────────────────────────
97
+ /** 生: refine one completed trajectory into a candidate (dual gate). */
98
+ refine(request, actor) {
99
+ const evidenceOk = (request.evidence.traceRef ?? '').trim() !== ''
100
+ || (request.evidence.sessionRef ?? '').trim() !== ''
101
+ || (request.evidence.note ?? '').trim() !== '';
102
+ if (!evidenceOk) {
103
+ return { accepted: false, reason: 'rejected-evidence: every assertion needs an episodic evidence pointer (traceRef/sessionRef/note)' };
104
+ }
105
+ if (request.kind === 'negative' && (request.failureReason ?? '').trim() === '') {
106
+ return { accepted: false, reason: 'rejected-schema: negative experiences must carry the confirmed failureReason' };
107
+ }
108
+ const c = request.complexity;
109
+ const complex = request.humanMarked === true
110
+ || c.hadFailure === true
111
+ || (c.tokens ?? 0) >= this.config.complexityTokenGate
112
+ || (c.steps ?? 0) >= this.config.complexityStepGate;
113
+ if (!complex) {
114
+ return { accepted: false, reason: `rejected-complexity: trajectory below both gates (tokens < ${String(this.config.complexityTokenGate)}, steps < ${String(this.config.complexityStepGate)}, no failure, not human-marked)` };
115
+ }
116
+ // Information-gain gate (007 flow-log fix): match on the lesson identity
117
+ // (gist + situation) across the whole library; a near-duplicate corroborates
118
+ // instead of inflating the library into a running log.
119
+ const dedupTokens = new Set(tokenize([request.gist, ...request.situation].join(' ')));
120
+ const near = this.store.findNearDuplicate(dedupTokens, ['candidate', 'live', 'challenged', 'archived', 'cold']);
121
+ if (near !== undefined && near.score >= this.config.duplicateOverlapGate) {
122
+ this.ledger('corroborate', 'experience', near.snapshot.id, actor, {
123
+ family: request.family,
124
+ gist: request.gist,
125
+ score: near.score,
126
+ });
127
+ return { accepted: false, reason: 'rejected-information-gain: near-duplicate of an existing experience; corroborated it instead', corroboratedId: near.snapshot.id };
128
+ }
129
+ const now = Date.now();
130
+ const snapshot = {
131
+ id: randomUUID(),
132
+ revision: 1,
133
+ kind: request.kind,
134
+ source: 'agent',
135
+ family: request.family,
136
+ gist: request.gist,
137
+ situation: [...request.situation],
138
+ path: [...request.path],
139
+ reasoning: request.reasoning,
140
+ limits: [...request.limits],
141
+ status: 'candidate',
142
+ alpha: 0,
143
+ beta: 0,
144
+ samples: 0,
145
+ trust: 0.5,
146
+ weightedTrust: 0.5,
147
+ pinned: false,
148
+ tokensSaved: 0,
149
+ tokensSpent: (request.complexity.tokens ?? 0) / 10,
150
+ context: request.context ?? '',
151
+ verifiedCount: 0,
152
+ rejectCount: 0,
153
+ globalFlag: false,
154
+ evidence: request.evidence,
155
+ createdAt: now,
156
+ updatedAt: now,
157
+ };
158
+ if (request.failureReason !== undefined)
159
+ snapshot.failureReason = request.failureReason;
160
+ this.store.upsertExperience(snapshot);
161
+ this.ledger('refine', 'experience', snapshot.id, actor, {
162
+ kind: snapshot.kind,
163
+ family: snapshot.family,
164
+ revision: 1,
165
+ });
166
+ return { accepted: true, experience: snapshot };
167
+ }
168
+ // ── 摄取归一: ingest (006 §1) ──────────────────────────────────────────────
169
+ /**
170
+ * 摄取归一: source-agnostic intake. Every extracted draft becomes an earned
171
+ * candidate carrying provenance (sourceType + sourceRef), the source-authority
172
+ * prior, and the declared context scope. Candidates never recall until verified.
173
+ */
174
+ ingest(request, actor) {
175
+ if ((request.sourceRef ?? '').trim() === '') {
176
+ throw new Error('memory: ingest requires a non-empty sourceRef provenance');
177
+ }
178
+ const prior = INGEST_SOURCE_PRIOR[request.sourceType] ?? INGEST_SOURCE_PRIOR.other;
179
+ const context = request.context ?? '';
180
+ const accepted = [];
181
+ const rejected = [];
182
+ const acceptedDedup = [];
183
+ for (const item of request.experiences) {
184
+ if (item.kind === 'negative' && (item.failureReason ?? '').trim() === '') {
185
+ rejected.push({ gist: item.gist, reason: 'rejected-schema: negative experiences must carry the confirmed failureReason' });
186
+ continue;
187
+ }
188
+ // Information-gain gate (007 flow-log fix): dedup on lesson identity
189
+ // (gist + situation), first across the whole library, then against items
190
+ // already accepted in this same batch. A near-duplicate corroborates
191
+ // instead of inflating the library into a running log.
192
+ const dedupTokens = new Set(tokenize([item.gist, ...item.situation].join(' ')));
193
+ const cross = this.store.findNearDuplicate(dedupTokens, ['candidate', 'live', 'challenged', 'archived', 'cold']);
194
+ if (cross !== undefined && cross.score >= this.config.duplicateOverlapGate) {
195
+ this.ledger('corroborate', 'experience', cross.snapshot.id, actor, {
196
+ family: item.family, gist: item.gist, score: cross.score, via: 'ingest',
197
+ });
198
+ rejected.push({ gist: item.gist, reason: 'rejected-information-gain: near-duplicate of an existing experience; corroborated it instead' });
199
+ continue;
200
+ }
201
+ if (acceptedDedup.some(prev => tokenOverlap(dedupTokens, prev) >= this.config.duplicateOverlapGate)) {
202
+ rejected.push({ gist: item.gist, reason: 'rejected-information-gain: near-duplicate of another item in this same ingest batch' });
203
+ continue;
204
+ }
205
+ const now = Date.now();
206
+ const snapshot = {
207
+ id: randomUUID(),
208
+ revision: 1,
209
+ kind: item.kind,
210
+ source: 'agent',
211
+ family: item.family,
212
+ gist: item.gist,
213
+ situation: [...item.situation],
214
+ path: [...item.path],
215
+ reasoning: item.reasoning,
216
+ limits: [...item.limits],
217
+ status: 'candidate',
218
+ alpha: prior.alpha,
219
+ beta: prior.beta,
220
+ samples: prior.alpha + prior.beta,
221
+ trust: (prior.alpha + 1) / (prior.alpha + prior.beta + 2),
222
+ weightedTrust: (prior.alpha + 1) / (prior.alpha + prior.beta + 2),
223
+ pinned: false,
224
+ tokensSaved: 0,
225
+ tokensSpent: 0,
226
+ context,
227
+ verifiedCount: 0,
228
+ rejectCount: 0,
229
+ globalFlag: false,
230
+ evidence: { note: `${request.sourceType}:${request.sourceRef}` },
231
+ createdAt: now,
232
+ updatedAt: now,
233
+ };
234
+ if (item.failureReason !== undefined)
235
+ snapshot.failureReason = item.failureReason;
236
+ this.store.upsertExperience(snapshot);
237
+ this.ledger('ingest', 'experience', snapshot.id, actor, {
238
+ kind: snapshot.kind,
239
+ family: snapshot.family,
240
+ sourceType: request.sourceType,
241
+ sourceRef: request.sourceRef,
242
+ ...(context === '' ? {} : { context }),
243
+ ...(request.note === undefined ? {} : { note: request.note }),
244
+ });
245
+ accepted.push(snapshot);
246
+ acceptedDedup.push(dedupTokens);
247
+ }
248
+ return { accepted, rejected, sourcePrior: prior };
249
+ }
250
+ // ── 用: recall + adjudication ─────────────────────────────────────────────
251
+ /** 用: recall → scope → adjudicate → budget → inject, plus the candidate probe channel. */
252
+ recall(request, actor) {
253
+ void actor;
254
+ const topK = request.topK ?? this.config.recallTopK;
255
+ const budget = request.budgetTokens ?? this.config.injectionBudgetTokens;
256
+ const context = request.context ?? '';
257
+ const queryTokens = new Set(tokenize(request.situation));
258
+ // Candidate probe channel (006 §3.1): matching unverified candidates offered
259
+ // for a one-shot trial, never injected as trusted knowledge.
260
+ let candidateTrials = [];
261
+ if (request.includeTrials !== false && queryTokens.size > 0) {
262
+ const matches = this.store.recallCandidates(queryTokens, this.config.candidateTrialTopK * 2, {
263
+ statuses: ['candidate'],
264
+ context,
265
+ });
266
+ candidateTrials = matches
267
+ .filter(m => m.score >= this.config.candidateTrialFloorScore)
268
+ .slice(0, this.config.candidateTrialTopK)
269
+ .map(m => ({ experience: m.snapshot, score: m.score }));
270
+ }
271
+ // Trusted channel: live (or archived when deep) revisions, context-scoped.
272
+ const candidates = queryTokens.size === 0
273
+ ? []
274
+ : this.store.recallCandidates(queryTokens, topK * 3, {
275
+ statuses: request.deep === true ? ['live', 'archived'] : ['live'],
276
+ context,
277
+ });
278
+ const adjudicated = [];
279
+ for (const { snapshot, score } of candidates) {
280
+ if (score < this.config.recallFloorScore)
281
+ continue;
282
+ const conflicts = [];
283
+ for (const limit of snapshot.limits) {
284
+ const limitTokens = tokenize(limit);
285
+ if (limitTokens.length === 0)
286
+ continue;
287
+ const hits = limitTokens.filter(token => queryTokens.has(token)).length;
288
+ if (hits / limitTokens.length >= 0.5)
289
+ conflicts.push(limit);
290
+ }
291
+ const verdict = conflicts.length > 0 ? 'reference' : score >= 0.45 ? 'direct' : score >= 0.22 ? 'reference' : 'clue';
292
+ adjudicated.push({ experience: snapshot, score, verdict, conflicts });
293
+ }
294
+ if (adjudicated.length === 0) {
295
+ const none = {
296
+ items: [],
297
+ none: true,
298
+ reason: 'no relevant experience in the library for this situation',
299
+ omitted: 0,
300
+ estimatedTokens: 0,
301
+ candidateTrials,
302
+ consolidationDue: this.consolidationDue().due,
303
+ };
304
+ this.store.insertRecallEvent({ id: randomUUID(), ts: Date.now(), situation: request.situation, injectedIds: [], none: true, context });
305
+ return none;
306
+ }
307
+ // Injection budget: best expected value first (relevance x trust).
308
+ const ranked = [...adjudicated].sort((a, b) => {
309
+ const value = (item) => {
310
+ const trust = item.experience.pinned
311
+ ? Math.max(item.experience.weightedTrust, this.config.pinnedTrustFloor)
312
+ : item.experience.weightedTrust;
313
+ return item.score * trust;
314
+ };
315
+ return value(b) - value(a);
316
+ });
317
+ const items = [];
318
+ let usedTokens = 0;
319
+ let omitted = 0;
320
+ for (const item of ranked) {
321
+ const cost = estimateTokens(JSON.stringify(item.experience));
322
+ if (usedTokens + cost > budget && items.length > 0) {
323
+ omitted += 1;
324
+ continue;
325
+ }
326
+ items.push(item);
327
+ usedTokens += cost;
328
+ }
329
+ this.store.insertRecallEvent({
330
+ id: randomUUID(),
331
+ ts: Date.now(),
332
+ situation: request.situation,
333
+ injectedIds: items.map(item => `${item.experience.id}@${String(item.experience.revision)}`),
334
+ none: false,
335
+ context,
336
+ });
337
+ return { items, none: false, omitted, estimatedTokens: usedTokens, candidateTrials, consolidationDue: this.consolidationDue().due };
338
+ }
339
+ // ── 用·验: report with attribution ────────────────────────────────────────
340
+ /** 用·验: one use outcome → Beta update, attribution, quarantine, gates. */
341
+ report(request, actor) {
342
+ const target = request.revision === undefined
343
+ ? this.store.getActiveRevision(request.id)
344
+ : this.store.getExperience(request.id, request.revision);
345
+ if (target === undefined)
346
+ throw new Error(`memory: experience not found: ${request.id}`);
347
+ const base = {
348
+ snapshot: target,
349
+ counted: 'none',
350
+ attributionApplied: 'unknown',
351
+ challenged: false,
352
+ promoted: false,
353
+ adopted: false,
354
+ };
355
+ // Attribution: objective evidence takes priority over the claim;
356
+ // insufficient signal never counts.
357
+ let attribution;
358
+ let overrideNote;
359
+ if (request.outcome === 'success') {
360
+ attribution = 'experience';
361
+ }
362
+ else {
363
+ const claimed = request.attribution ?? 'unknown';
364
+ const evidenceNote = request.evidence?.note ?? '';
365
+ if (evidenceNote !== '' && ENVIRONMENT_EVIDENCE_PATTERN.test(evidenceNote)) {
366
+ attribution = 'environment';
367
+ if (claimed !== 'environment')
368
+ overrideNote = 'objective evidence indicates an environment failure; claim overridden';
369
+ }
370
+ else {
371
+ attribution = claimed;
372
+ }
373
+ if (attribution === 'experience' && evidenceNote.trim() === '') {
374
+ // For an unverified candidate the trial failure itself is the signal —
375
+ // do not demand extra evidence to record a trial-fail and cold it (006 §3.2).
376
+ if (target.status !== 'candidate') {
377
+ attribution = 'unknown';
378
+ overrideNote = 'experience-attributed failure without evidence note: insufficient signal, not counted';
379
+ }
380
+ }
381
+ }
382
+ const counted = request.outcome === 'success'
383
+ ? 'alpha'
384
+ : attribution === 'experience' ? 'beta' : 'none';
385
+ // Idempotency: a repeated (family, revision, dedupeKey) never counts twice.
386
+ const inserted = this.store.insertReport({
387
+ id: randomUUID(),
388
+ experienceId: target.id,
389
+ revision: target.revision,
390
+ outcome: request.outcome,
391
+ attribution,
392
+ counted,
393
+ evidence: request.evidence,
394
+ dedupeKey: request.dedupeKey,
395
+ ts: Date.now(),
396
+ });
397
+ if (!inserted) {
398
+ if (overrideNote !== undefined)
399
+ base.overrideNote = overrideNote;
400
+ return base;
401
+ }
402
+ let next = { ...target, updatedAt: Date.now() };
403
+ if (counted === 'alpha') {
404
+ next = { ...next, alpha: next.alpha + 1, verifiedCount: next.verifiedCount + 1, lastVerifiedAt: Date.now() };
405
+ }
406
+ else if (counted === 'beta') {
407
+ const isCandidateTrial = next.status === 'candidate';
408
+ next = {
409
+ ...next,
410
+ beta: next.beta + 1,
411
+ rejectCount: isCandidateTrial ? next.rejectCount + 1 : next.rejectCount,
412
+ lastVerifiedAt: Date.now(),
413
+ };
414
+ }
415
+ next = {
416
+ ...next,
417
+ tokensSaved: next.tokensSaved + (request.tokensSaved ?? 0),
418
+ tokensSpent: next.tokensSpent + (request.tokensUsed ?? 0),
419
+ samples: next.alpha + next.beta,
420
+ trust: (next.alpha + 1) / (next.alpha + next.beta + 2),
421
+ };
422
+ next = { ...next, weightedTrust: this.store.weightedTrust(next.id, next.revision, Date.now()) };
423
+ // Verification gates.
424
+ if (request.outcome === 'success' && attribution === 'experience') {
425
+ if (next.status === 'candidate' && next.parentRevision !== undefined) {
426
+ // Revised draft passed its one successful use: adopt it.
427
+ const adopted = { ...next, status: 'live' };
428
+ delete adopted.challengeReason;
429
+ next = adopted;
430
+ this.store.upsertExperience(next);
431
+ this.supersedeParent(next, actor);
432
+ this.ledger('adopt', 'experience', next.id, actor, { revision: next.revision, via: 'use' });
433
+ this.enforceFamilyCap(next.family, actor);
434
+ base.adopted = true;
435
+ base.promoted = true;
436
+ }
437
+ else if (next.status === 'candidate') {
438
+ next = { ...next, status: 'live' };
439
+ this.ledger('promote', 'experience', next.id, actor, { revision: next.revision, via: 'use' });
440
+ base.promoted = true;
441
+ }
442
+ else if (next.status === 'archived') {
443
+ // Found via deep recall and used successfully once: back to live.
444
+ next = { ...next, status: 'live' };
445
+ this.ledger('restore', 'experience', next.id, actor, { revision: next.revision });
446
+ base.promoted = true;
447
+ }
448
+ }
449
+ // Cold palace (006 §3.2): a candidate that fails its trial is quarantined to
450
+ // `cold`; it stops being probed and only a human can re-release or delete it.
451
+ if (next.status === 'candidate' && counted === 'beta') {
452
+ const reason = 'candidate trial failed; human re-release required';
453
+ next = { ...next, status: 'cold', challengeReason: reason };
454
+ this.ledger('trial-fail', 'experience', next.id, actor, { revision: next.revision }, reason);
455
+ base.cooled = true;
456
+ }
457
+ // Quarantine: windowed failure pressure challenges a live experience.
458
+ if (next.status === 'live' && counted === 'beta' && this.shouldChallenge(next)) {
459
+ const reason = 'posterior pressure: repeated experience-attributed failures';
460
+ next = { ...next, status: 'challenged', challengeReason: reason };
461
+ this.ledger('challenge', 'experience', next.id, actor, { revision: next.revision }, reason);
462
+ base.challenged = true;
463
+ }
464
+ this.store.upsertExperience(next);
465
+ this.ledger('use', 'experience', next.id, actor, {
466
+ revision: next.revision,
467
+ outcome: request.outcome,
468
+ attribution,
469
+ counted,
470
+ ...(request.dedupeKey === undefined ? {} : { dedupeKey: request.dedupeKey }),
471
+ });
472
+ base.snapshot = next;
473
+ base.counted = counted;
474
+ base.attributionApplied = attribution;
475
+ if (overrideNote !== undefined)
476
+ base.overrideNote = overrideNote;
477
+ return base;
478
+ }
479
+ /** Windowed challenge rule: consecutive fails or window failure rate. */
480
+ shouldChallenge(exp) {
481
+ const reports = this.store.reportsFor(exp.id, exp.revision, this.config.challengeWindow);
482
+ let consecutive = 0;
483
+ for (const report of reports) {
484
+ if (report.outcome === 'fail' && report.attribution === 'experience')
485
+ consecutive += 1;
486
+ else
487
+ break;
488
+ }
489
+ if (consecutive >= this.config.challengeConsecutiveFails)
490
+ return true;
491
+ if (reports.length >= 3) {
492
+ const fails = reports.filter(r => r.outcome === 'fail' && r.attribution === 'experience').length;
493
+ if (fails / reports.length >= this.config.challengeWindowFailRate)
494
+ return true;
495
+ }
496
+ return false;
497
+ }
498
+ /** Close the parent revision as superseded after a draft adoption. */
499
+ supersedeParent(draft, actor) {
500
+ if (draft.parentRevision === undefined)
501
+ return;
502
+ const parent = this.store.getExperience(draft.id, draft.parentRevision);
503
+ if (parent === undefined)
504
+ return;
505
+ this.store.upsertExperience({ ...parent, status: 'superseded', updatedAt: Date.now() });
506
+ this.ledger('supersede', 'experience', parent.id, actor, {
507
+ revision: parent.revision,
508
+ by: draft.revision,
509
+ });
510
+ void this.enforceFamilyCap(draft.family, actor);
511
+ }
512
+ /** Capacity budget: archive the lowest economic value when a family overflows. */
513
+ enforceFamilyCap(familyTag, actor) {
514
+ const overflow = this.store.countFamilyActive(familyTag) - this.config.familyLiveCap;
515
+ if (overflow <= 0)
516
+ return;
517
+ const live = this.store.listExperiences({ status: 'live', family: familyTag })
518
+ .filter(exp => !exp.pinned)
519
+ .sort((a, b) => (a.tokensSaved - a.tokensSpent) - (b.tokensSaved - b.tokensSpent));
520
+ for (const exp of live.slice(0, overflow)) {
521
+ this.store.upsertExperience({ ...exp, status: 'archived', updatedAt: Date.now() });
522
+ this.ledger('archive', 'experience', exp.id, actor, {
523
+ revision: exp.revision,
524
+ reason: 'family capacity budget',
525
+ });
526
+ }
527
+ }
528
+ // ── 修: revise / shadow / rollback ────────────────────────────────────────
529
+ /** 修: propose a revised draft for a challenged experience. */
530
+ revise(request, actor) {
531
+ const current = this.store.getActiveRevision(request.id);
532
+ if (current === undefined)
533
+ throw new Error(`memory: experience not found: ${request.id}`);
534
+ if (current.status !== 'challenged') {
535
+ throw new Error(`memory: only challenged experiences accept revisions (status=${current.status})`);
536
+ }
537
+ const now = Date.now();
538
+ const draft = {
539
+ ...current,
540
+ revision: current.revision + 1,
541
+ status: 'candidate',
542
+ gist: request.gist ?? current.gist,
543
+ situation: request.situation ?? current.situation,
544
+ path: request.path ?? current.path,
545
+ reasoning: request.reasoning ?? current.reasoning,
546
+ limits: request.limits ?? current.limits,
547
+ alpha: 0,
548
+ beta: 0,
549
+ samples: 0,
550
+ trust: 0.5,
551
+ weightedTrust: 0.5,
552
+ verifiedCount: 0,
553
+ rejectCount: 0,
554
+ parentRevision: current.revision,
555
+ createdAt: now,
556
+ updatedAt: now,
557
+ };
558
+ // A fresh draft carries no verification history and no quarantine reason.
559
+ delete draft.lastVerifiedAt;
560
+ delete draft.challengeReason;
561
+ this.store.upsertExperience(draft);
562
+ this.ledger('propose', 'experience', draft.id, actor, { revision: draft.revision }, request.reason);
563
+ return draft;
564
+ }
565
+ /** V1 controlled re-enactment: replay historical samples against a draft. */
566
+ verifyShadow(request, actor) {
567
+ const draft = this.store.getExperience(request.id, request.revision);
568
+ if (draft === undefined)
569
+ throw new Error(`memory: draft not found: ${request.id} v${String(request.revision)}`);
570
+ if (draft.status !== 'candidate') {
571
+ throw new Error(`memory: shadow replay verifies candidate drafts (status=${draft.status})`);
572
+ }
573
+ if (request.samples.length === 0)
574
+ return { passed: false, agreement: 0, reason: 'no samples supplied' };
575
+ const draftTokens = new Set(tokenize([draft.gist, ...draft.situation, ...draft.limits].join(' ')));
576
+ let matched = 0;
577
+ for (const sample of request.samples) {
578
+ const sampleTokens = new Set(tokenize(sample.situation));
579
+ let hits = 0;
580
+ for (const token of sampleTokens)
581
+ if (draftTokens.has(token))
582
+ hits += 1;
583
+ const relevance = sampleTokens.size === 0 ? 0 : hits / sampleTokens.size;
584
+ const predictsSuccess = relevance >= this.config.recallFloorScore * 2;
585
+ if (predictsSuccess === (sample.expected === 'success'))
586
+ matched += 1;
587
+ }
588
+ const agreement = matched / request.samples.length;
589
+ if (agreement < this.config.shadowPassRate) {
590
+ this.ledger('shadow-fail', 'experience', draft.id, actor, { revision: draft.revision, agreement });
591
+ return { passed: false, agreement, reason: `agreement ${agreement.toFixed(2)} below pass rate ${String(this.config.shadowPassRate)}` };
592
+ }
593
+ const adopted = { ...draft, status: 'live', lastVerifiedAt: Date.now(), updatedAt: Date.now() };
594
+ this.store.upsertExperience(adopted);
595
+ this.supersedeParent(adopted, actor);
596
+ this.ledger('shadow-pass', 'experience', adopted.id, actor, { revision: adopted.revision, agreement });
597
+ return { passed: true, agreement, snapshot: adopted };
598
+ }
599
+ /** Roll the family back to a superseded revision. */
600
+ rollback(request, actor) {
601
+ const target = this.store.getExperience(request.id, request.toRevision);
602
+ if (target === undefined)
603
+ throw new Error(`memory: revision not found: ${request.id} v${String(request.toRevision)}`);
604
+ if (target.status !== 'superseded') {
605
+ throw new Error(`memory: only superseded revisions can be restored (status=${target.status})`);
606
+ }
607
+ const current = this.store.getActiveRevision(request.id);
608
+ if (current !== undefined) {
609
+ this.store.upsertExperience({ ...current, status: 'superseded', updatedAt: Date.now() });
610
+ this.ledger('supersede', 'experience', current.id, actor, {
611
+ revision: current.revision,
612
+ by: request.toRevision,
613
+ });
614
+ }
615
+ const restored = { ...target, status: 'live', updatedAt: Date.now() };
616
+ this.store.upsertExperience(restored);
617
+ this.ledger('rollback', 'experience', restored.id, actor, { revision: restored.revision }, request.reason);
618
+ return restored;
619
+ }
620
+ // ── queries ───────────────────────────────────────────────────────────────
621
+ /** One experience revision. */
622
+ get(id, revision) {
623
+ return revision === undefined ? this.store.getActiveRevision(id) : this.store.getExperience(id, revision);
624
+ }
625
+ /** Experience revisions by filter. */
626
+ list(filter) {
627
+ return this.store.listExperiences(filter);
628
+ }
629
+ /** Every revision of one family (the rollback picker's data). */
630
+ family(id) {
631
+ return this.store.getFamily(id);
632
+ }
633
+ // ── 记: diary + extraction ────────────────────────────────────────────────
634
+ /** 记: append one diary entry; signals when the extraction cadence is due. */
635
+ appendDiary(request, actor) {
636
+ const entry = {
637
+ id: randomUUID(),
638
+ ts: Date.now(),
639
+ kind: request.kind,
640
+ content: request.content,
641
+ tags: request.tags ?? [],
642
+ extracted: false,
643
+ };
644
+ if (request.sessionRef !== undefined)
645
+ entry.sessionRef = request.sessionRef;
646
+ this.store.insertDiary(entry);
647
+ this.ledger('diary', 'diary', entry.id, actor, { kind: entry.kind });
648
+ const pending = this.store.unextractedDiary();
649
+ const intervalOk = Date.now() - this.store.lastExtractionTs()
650
+ >= this.config.diaryExtractIntervalHours * 60 * 60 * 1000;
651
+ const extractionDue = pending.length >= this.config.diaryExtractEvery && intervalOk;
652
+ if (!extractionDue)
653
+ return { entry, extractionDue: false };
654
+ return { entry, extractionDue: true, pendingDiary: pending };
655
+ }
656
+ /** 上升通道: apply extracted facts over the pending diary window. */
657
+ extract(request, actor, trigger = 'manual') {
658
+ const result = { applied: [], conflicts: [], rejected: [], appliedConcerns: 0 };
659
+ const now = Date.now();
660
+ const consumedDiary = new Set();
661
+ for (const proposal of request.proposals) {
662
+ const rejection = this.validateProposal(proposal);
663
+ if (rejection !== undefined) {
664
+ result.rejected.push({ proposal, reason: rejection });
665
+ continue;
666
+ }
667
+ for (const id of proposal.sourceDiaryIds)
668
+ consumedDiary.add(id);
669
+ const current = this.store.currentFact(proposal.category, proposal.factKey);
670
+ if (current !== undefined && current.value === proposal.value) {
671
+ // Same fact stays ONE entry; the new diary pointers join as extra citations.
672
+ const merged = [...new Set([...current.sourceDiaryIds, ...proposal.sourceDiaryIds])];
673
+ const corroborated = { ...current, corroboration: current.corroboration + 1, sourceDiaryIds: merged };
674
+ this.store.updateFact(corroborated);
675
+ this.ledger('fact-corroborate', 'fact', corroborated.id, actor, {
676
+ category: proposal.category,
677
+ factKey: proposal.factKey,
678
+ });
679
+ result.applied.push(corroborated);
680
+ continue;
681
+ }
682
+ if (current !== undefined && current.locked) {
683
+ // A locked fact is never auto-superseded: park the conflict for a human.
684
+ const conflict = {
685
+ id: randomUUID(),
686
+ category: proposal.category,
687
+ factKey: proposal.factKey,
688
+ value: proposal.value,
689
+ origin: 'extraction',
690
+ sourceDiaryIds: [...proposal.sourceDiaryIds],
691
+ corroboration: 1,
692
+ validFrom: now,
693
+ validTo: now,
694
+ recordedAt: now,
695
+ locked: false,
696
+ deleted: false,
697
+ conflictPending: true,
698
+ };
699
+ this.store.insertFact(conflict);
700
+ this.ledger('fact-conflict', 'fact', conflict.id, actor, {
701
+ category: proposal.category,
702
+ factKey: proposal.factKey,
703
+ against: current.id,
704
+ });
705
+ result.conflicts.push(conflict);
706
+ continue;
707
+ }
708
+ const version = {
709
+ id: randomUUID(),
710
+ category: proposal.category,
711
+ factKey: proposal.factKey,
712
+ value: proposal.value,
713
+ origin: current === undefined ? 'extraction' : 'supersede',
714
+ sourceDiaryIds: [...proposal.sourceDiaryIds],
715
+ corroboration: 1,
716
+ validFrom: now,
717
+ recordedAt: now,
718
+ locked: false,
719
+ deleted: false,
720
+ conflictPending: false,
721
+ };
722
+ if (current !== undefined) {
723
+ this.store.updateFact({ ...current, validTo: now, supersededBy: version.id });
724
+ this.ledger('fact-supersede', 'fact', current.id, actor, {
725
+ category: proposal.category,
726
+ factKey: proposal.factKey,
727
+ by: version.id,
728
+ });
729
+ }
730
+ this.store.insertFact(version);
731
+ this.ledger('fact-extract', 'fact', version.id, actor, {
732
+ category: proposal.category,
733
+ factKey: proposal.factKey,
734
+ });
735
+ result.applied.push(version);
736
+ }
737
+ // 关心事项 (007 §2.3): new top-level / discussion mention / lifecycle change.
738
+ for (const cp of request.concerns ?? []) {
739
+ for (const id of cp.sourceDiaryIds)
740
+ consumedDiary.add(id);
741
+ if (cp.action === 'new') {
742
+ if ((cp.title ?? '').trim() === '')
743
+ continue;
744
+ const top = {
745
+ id: randomUUID(),
746
+ title: (cp.title ?? '').trim(),
747
+ ...(cp.background === undefined || cp.background.trim() === '' ? {} : { background: cp.background.trim() }),
748
+ kind: cp.kind ?? 'other',
749
+ status: 'ongoing',
750
+ ts: now,
751
+ sourceDiaryIds: [...cp.sourceDiaryIds],
752
+ ...(cp.context === undefined ? {} : { context: cp.context }),
753
+ deleted: false,
754
+ };
755
+ this.store.insertConcern(top);
756
+ this.ledger('concern-new', 'concern', top.id, actor, { kind: top.kind }, top.title);
757
+ result.appliedConcerns += 1;
758
+ }
759
+ else if (cp.action === 'mention') {
760
+ if (cp.concernId === undefined || (cp.title ?? '').trim() === '')
761
+ continue;
762
+ const mention = {
763
+ id: randomUUID(),
764
+ parentId: cp.concernId,
765
+ title: (cp.title ?? '').trim(),
766
+ ts: now,
767
+ sourceDiaryIds: [...cp.sourceDiaryIds],
768
+ deleted: false,
769
+ };
770
+ this.store.insertConcern(mention);
771
+ this.ledger('concern-mention', 'concern', cp.concernId, actor, {}, mention.title);
772
+ result.appliedConcerns += 1;
773
+ }
774
+ else if (cp.action === 'status') {
775
+ if (cp.concernId === undefined || cp.status === undefined)
776
+ continue;
777
+ this.store.setConcernStatus(cp.concernId, cp.status);
778
+ this.ledger('concern-status', 'concern', cp.concernId, actor, { status: cp.status });
779
+ result.appliedConcerns += 1;
780
+ }
781
+ }
782
+ if (consumedDiary.size > 0)
783
+ this.store.markDiaryExtracted([...consumedDiary]);
784
+ const producedIds = [...result.applied, ...result.conflicts].map(fact => fact.id);
785
+ this.store.insertExtraction({
786
+ id: randomUUID(),
787
+ ts: now,
788
+ trigger,
789
+ summary: request.summary,
790
+ producedFactIds: producedIds,
791
+ diaryCount: consumedDiary.size,
792
+ });
793
+ this.ledger('extract', 'library', 'diary-window', actor, {
794
+ proposals: request.proposals.length,
795
+ applied: result.applied.length,
796
+ conflicts: result.conflicts.length,
797
+ rejected: result.rejected.length,
798
+ }, request.summary);
799
+ return result;
800
+ }
801
+ /** Validate one proposal's shape and source pointers. */
802
+ validateProposal(proposal) {
803
+ if (proposal.category.trim() === '' || proposal.factKey.trim() === '' || proposal.value.trim() === '') {
804
+ return 'category, factKey and value are required';
805
+ }
806
+ if (proposal.sourceDiaryIds.length === 0)
807
+ return 'sourceDiaryIds must point to at least one diary entry';
808
+ for (const id of proposal.sourceDiaryIds) {
809
+ const entry = this.store.getDiary(id);
810
+ if (entry === undefined)
811
+ return `unknown diary entry: ${id}`;
812
+ if (entry.extracted)
813
+ return `diary entry already extracted: ${id}`;
814
+ }
815
+ return undefined;
816
+ }
817
+ /** Diary entries for the workbench timeline. */
818
+ listDiary(limit, offset, onlyUnextracted) {
819
+ return this.store.listDiary(limit, offset, onlyUnextracted);
820
+ }
821
+ /** Several diary entries by id (008 Path A: fact→diary provenance). */
822
+ getDiaryByIds(ids) {
823
+ return this.store.getDiaryByIds(ids);
824
+ }
825
+ /** Fact versions for the workbench (008 §3: server-side pagination). */
826
+ listFacts(category, includeHistory, limit, offset) {
827
+ return this.store.listFacts(category === undefined ? { includeHistory } : { category, includeHistory }, limit, offset);
828
+ }
829
+ /** Count of facts matching the workbench filter (008 §3: pagination total). */
830
+ listFactsCount(category, includeHistory) {
831
+ return this.store.factFilteredCount(category === undefined ? { includeHistory } : { category, includeHistory });
832
+ }
833
+ /** 关心事项 (007 §2 / 010 §D): top-level concerns + loop, kind/status filter + pagination. */
834
+ listConcerns(kind, status, limit, offset) {
835
+ const filter = {};
836
+ if (kind !== undefined)
837
+ filter.kind = kind;
838
+ if (status !== undefined)
839
+ filter.status = status;
840
+ return this.store.listConcernTrees(filter, limit, offset);
841
+ }
842
+ /** Count of top-level concerns matching the workbench filter (010 §D: pagination total). */
843
+ listConcernsCount(kind, status) {
844
+ const filter = {};
845
+ if (kind !== undefined)
846
+ filter.kind = kind;
847
+ if (status !== undefined)
848
+ filter.status = status;
849
+ return this.store.listConcernsCount(filter);
850
+ }
851
+ /**
852
+ * 010 §F: a compact runtime snapshot for the AI's context — the profile (the
853
+ * AI's perception of the user) plus still-open concern memos it may remind
854
+ * the user about. Empty string when there is nothing yet.
855
+ */
856
+ profileSnapshot() {
857
+ const facts = this.store.listFacts({ includeHistory: false }, 60, 0);
858
+ const openConcerns = this.store.listConcernTrees({ status: 'ongoing' }, 12, 0);
859
+ if (facts.length === 0 && openConcerns.length === 0)
860
+ return '';
861
+ const lines = [];
862
+ if (facts.length > 0) {
863
+ lines.push('User profile (your perception of this user — collaborate accordingly):');
864
+ for (const f of facts)
865
+ lines.push(`- [${f.category}] ${f.factKey}: ${f.value}`);
866
+ }
867
+ if (openConcerns.length > 0) {
868
+ lines.push('Open concern memos (the user\'s unclosed loops — remind when relevant, never fabricate closure):');
869
+ for (const t of openConcerns)
870
+ lines.push(`- (${t.concern.kind ?? 'other'}) ${t.concern.title}`);
871
+ }
872
+ return lines.join('\n');
873
+ }
874
+ /** Extraction runs, newest first (008 §3: server-side pagination). */
875
+ extractionLog(limit, offset) {
876
+ return this.store.listExtractions(limit, offset);
877
+ }
878
+ /** Total extraction runs (008 §3: pagination total). */
879
+ extractionLogCount() {
880
+ return this.store.extractionCount();
881
+ }
882
+ // ── periodic consolidation (008 §1, interval-based cadence) ───────────────
883
+ /**
884
+ * Whether a consolidation run is due. The period is measured as a DURATION
885
+ * since the last consolidation (interval), not a fixed clock time: a run is
886
+ * due once (a) enough NEW experiences have accumulated since the last run
887
+ * AND (b) enough hours have elapsed since it.
888
+ */
889
+ consolidationDue() {
890
+ const lastTs = this.store.lastConsolidationTs();
891
+ const newSince = this.store.countExperiencesSince(lastTs);
892
+ const hoursSince = lastTs === 0 ? Number.POSITIVE_INFINITY : (Date.now() - lastTs) / 3600e3;
893
+ const enoughNew = newSince >= this.config.consolidateEveryNew;
894
+ const intervalPassed = hoursSince >= this.config.consolidateIntervalHours;
895
+ return { due: enoughNew && intervalPassed, lastTs, newSince, hoursSince };
896
+ }
897
+ /**
898
+ * Apply a consolidation run: for each merge, create one consolidated
899
+ * experience and archive the sources (they leave recall but stay recoverable
900
+ * — consolidation never hard-deletes). Every step is ledgered.
901
+ */
902
+ consolidate(request, actor) {
903
+ const now = Date.now();
904
+ const result = { consolidated: 0, archived: 0, skipped: [] };
905
+ for (const merge of request.merges) {
906
+ const sources = merge.sourceIds
907
+ .map(id => this.store.getActiveRevision(id))
908
+ .filter((s) => s !== undefined);
909
+ if (sources.length < 2) {
910
+ result.skipped.push({ sourceIds: merge.sourceIds, reason: '至少需要 2 条仍有效的来源经验' });
911
+ continue;
912
+ }
913
+ if (merge.gist.trim() === '' || merge.reasoning.trim() === '') {
914
+ result.skipped.push({ sourceIds: merge.sourceIds, reason: '合并后的 gist/reasoning 不能为空' });
915
+ continue;
916
+ }
917
+ // Preserve earned trust: carry forward the Beta posteriors and counters.
918
+ const allLive = sources.every(s => s.status === 'live');
919
+ const alpha = sources.reduce((sum, s) => sum + s.alpha, 0);
920
+ const beta = sources.reduce((sum, s) => sum + s.beta, 0);
921
+ const verifiedCount = sources.reduce((sum, s) => sum + (s.verifiedCount ?? 0), 0);
922
+ const rejectCount = sources.reduce((sum, s) => sum + (s.rejectCount ?? 0), 0);
923
+ const lastVerifiedAt = sources.reduce((max, s) => (s.lastVerifiedAt !== undefined && (max === undefined || s.lastVerifiedAt > max) ? s.lastVerifiedAt : max), undefined);
924
+ const consolidated = {
925
+ id: randomUUID(),
926
+ revision: 1,
927
+ kind: merge.kind,
928
+ source: 'agent',
929
+ family: merge.family,
930
+ gist: merge.gist.trim(),
931
+ situation: merge.situation,
932
+ path: merge.path.map((action, index) => ({ action, order: index + 1 })),
933
+ reasoning: merge.reasoning.trim(),
934
+ limits: merge.limits,
935
+ status: allLive ? 'live' : 'candidate',
936
+ alpha,
937
+ beta,
938
+ samples: alpha + beta,
939
+ trust: (alpha + 1) / (alpha + beta + 2),
940
+ weightedTrust: (alpha + 1) / (alpha + beta + 2),
941
+ ...(lastVerifiedAt === undefined ? {} : { lastVerifiedAt }),
942
+ pinned: false,
943
+ tokensSaved: 0,
944
+ tokensSpent: 0,
945
+ evidence: { note: `consolidated from ${String(sources.length)} experiences: ${sources.map(s => s.id.slice(0, 8)).join(',')}` },
946
+ context: '',
947
+ verifiedCount,
948
+ rejectCount,
949
+ globalFlag: false,
950
+ createdAt: now,
951
+ updatedAt: now,
952
+ };
953
+ this.store.upsertExperience(consolidated);
954
+ this.store.archiveExperienceIds(merge.sourceIds, now);
955
+ this.store.recordConsolidation({
956
+ id: randomUUID(),
957
+ ts: now,
958
+ mergedIds: merge.sourceIds,
959
+ producedId: consolidated.id,
960
+ note: merge.note ?? request.note ?? '',
961
+ });
962
+ this.ledger('consolidate', 'experience', consolidated.id, actor, {
963
+ mergedFrom: merge.sourceIds,
964
+ gist: merge.gist.trim(),
965
+ status: consolidated.status,
966
+ });
967
+ result.consolidated += 1;
968
+ result.archived += sources.length;
969
+ }
970
+ return result;
971
+ }
972
+ // ── human operations (all ledgered) ───────────────────────────────────────
973
+ /** Human pin/unpin: pinned cards keep the trust floor and escape budgets. */
974
+ humanPin(request, actor) {
975
+ const current = this.store.getActiveRevision(request.id);
976
+ if (current === undefined)
977
+ throw new Error(`memory: experience not found: ${request.id}`);
978
+ const next = { ...current, pinned: request.pinned, updatedAt: Date.now() };
979
+ this.store.upsertExperience(next);
980
+ this.ledger(request.pinned ? 'pin' : 'unpin', 'experience', next.id, actor, { revision: next.revision }, request.reason);
981
+ return next;
982
+ }
983
+ /** Human delete: tombstone the family; the ledger keeps the fingerprint. */
984
+ humanDeleteExperience(request, actor) {
985
+ const familyRows = this.store.getFamily(request.id);
986
+ if (familyRows.length === 0)
987
+ throw new Error(`memory: experience not found: ${request.id}`);
988
+ const head = familyRows[familyRows.length - 1];
989
+ this.store.deleteFamily(request.id);
990
+ this.ledger('delete', 'experience', request.id, actor, {
991
+ gist: head.gist,
992
+ family: head.family,
993
+ revisions: familyRows.length,
994
+ }, request.reason);
995
+ }
996
+ /** Human edit: rewrite fields of the active revision in place. */
997
+ humanEditExperience(request, actor) {
998
+ const current = this.store.getActiveRevision(request.id);
999
+ if (current === undefined)
1000
+ throw new Error(`memory: experience not found: ${request.id}`);
1001
+ const next = {
1002
+ ...current,
1003
+ gist: request.gist ?? current.gist,
1004
+ situation: request.situation ?? current.situation,
1005
+ path: request.path ?? current.path,
1006
+ reasoning: request.reasoning ?? current.reasoning,
1007
+ limits: request.limits ?? current.limits,
1008
+ family: request.family ?? current.family,
1009
+ context: request.context ?? current.context,
1010
+ globalFlag: request.globalFlag ?? current.globalFlag,
1011
+ updatedAt: Date.now(),
1012
+ };
1013
+ this.store.upsertExperience(next);
1014
+ this.ledger('edit', 'experience', next.id, actor, { revision: next.revision }, request.reason);
1015
+ return next;
1016
+ }
1017
+ /** Human injection: fixed format, source=human, trust floor, directly live. */
1018
+ humanAddExperience(request, actor) {
1019
+ const now = Date.now();
1020
+ const snapshot = {
1021
+ id: randomUUID(),
1022
+ revision: 1,
1023
+ kind: request.kind,
1024
+ source: 'human',
1025
+ family: request.family,
1026
+ gist: request.gist,
1027
+ situation: [...request.situation],
1028
+ path: [...request.path],
1029
+ reasoning: request.reasoning,
1030
+ limits: [...request.limits],
1031
+ status: 'live',
1032
+ alpha: this.config.humanFloorAlpha,
1033
+ beta: this.config.humanFloorBeta,
1034
+ samples: this.config.humanFloorAlpha + this.config.humanFloorBeta,
1035
+ trust: (this.config.humanFloorAlpha + 1) / (this.config.humanFloorAlpha + this.config.humanFloorBeta + 2),
1036
+ weightedTrust: (this.config.humanFloorAlpha + 1) / (this.config.humanFloorAlpha + this.config.humanFloorBeta + 2),
1037
+ lastVerifiedAt: now,
1038
+ pinned: false,
1039
+ tokensSaved: 0,
1040
+ tokensSpent: 0,
1041
+ context: request.context ?? '',
1042
+ verifiedCount: 0,
1043
+ rejectCount: 0,
1044
+ globalFlag: false,
1045
+ createdAt: now,
1046
+ updatedAt: now,
1047
+ };
1048
+ if (request.failureReason !== undefined)
1049
+ snapshot.failureReason = request.failureReason;
1050
+ this.store.upsertExperience(snapshot);
1051
+ this.ledger('add', 'experience', snapshot.id, actor, {
1052
+ kind: snapshot.kind,
1053
+ family: snapshot.family,
1054
+ trustFloor: snapshot.trust,
1055
+ }, request.reason);
1056
+ return snapshot;
1057
+ }
1058
+ /** Human authority (V2): promote a candidate straight to live. */
1059
+ humanPromote(id, reason, actor) {
1060
+ const current = this.store.getActiveRevision(id);
1061
+ if (current === undefined)
1062
+ throw new Error(`memory: experience not found: ${id}`);
1063
+ if (current.status !== 'candidate') {
1064
+ throw new Error(`memory: only candidates accept human promotion (status=${current.status})`);
1065
+ }
1066
+ const next = { ...current, status: 'live', lastVerifiedAt: Date.now(), updatedAt: Date.now() };
1067
+ this.store.upsertExperience(next);
1068
+ if (next.parentRevision !== undefined)
1069
+ this.supersedeParent(next, actor);
1070
+ this.ledger('human-promote', 'experience', next.id, actor, { revision: next.revision }, reason);
1071
+ return next;
1072
+ }
1073
+ /** Human re-release (006 §3.2): move a cold-palace revision back to candidate. */
1074
+ humanReleaseCold(request, actor) {
1075
+ // Cold is a terminal status, so it is not the "active" revision; scan the family.
1076
+ const cold = [...this.store.getFamily(request.id)].reverse().find(rev => rev.status === 'cold');
1077
+ if (cold === undefined) {
1078
+ throw new Error(`memory: no cold revision to re-release for ${request.id}`);
1079
+ }
1080
+ const next = { ...cold, status: 'candidate', updatedAt: Date.now() };
1081
+ delete next.challengeReason;
1082
+ this.store.upsertExperience(next);
1083
+ this.ledger('release-cold', 'experience', next.id, actor, { revision: next.revision }, request.reason);
1084
+ return next;
1085
+ }
1086
+ /** Human acknowledgement of a pending diary entry: reviewed, no fact extracted. */
1087
+ humanAckDiary(request, actor) {
1088
+ const entry = this.store.getDiary(request.diaryId);
1089
+ if (entry === undefined)
1090
+ throw new Error(`memory: unknown diary entry: ${request.diaryId}`);
1091
+ if (entry.extracted)
1092
+ throw new Error(`memory: diary entry already extracted: ${request.diaryId}`);
1093
+ this.store.markDiaryExtracted([request.diaryId]);
1094
+ this.ledger('diary-ack', 'diary', request.diaryId, actor, {}, request.reason);
1095
+ return { ...entry, extracted: true };
1096
+ }
1097
+ /** Human lifecycle change of a top-level concern (007 §2.4, audited). */
1098
+ humanSetConcernStatus(request, actor) {
1099
+ this.store.setConcernStatus(request.id, request.status);
1100
+ this.ledger('concern-status', 'concern', request.id, actor, { status: request.status, via: 'human' }, request.reason);
1101
+ }
1102
+ /** Human delete of a concern subtree (007 §2.4, tombstone, audited). */
1103
+ humanDeleteConcern(request, actor) {
1104
+ this.store.deleteConcernSubtree(request.id);
1105
+ this.ledger('concern-delete', 'concern', request.id, actor, { via: 'human' }, request.reason);
1106
+ }
1107
+ /** Human fact add: origin=human, locked by default. */
1108
+ humanAddFact(request, actor) {
1109
+ const now = Date.now();
1110
+ const current = this.store.currentFact(request.category, request.factKey);
1111
+ const version = {
1112
+ id: randomUUID(),
1113
+ category: request.category,
1114
+ factKey: request.factKey,
1115
+ value: request.value,
1116
+ origin: 'human',
1117
+ sourceDiaryIds: [],
1118
+ corroboration: 1,
1119
+ validFrom: now,
1120
+ recordedAt: now,
1121
+ locked: request.locked ?? true,
1122
+ deleted: false,
1123
+ conflictPending: false,
1124
+ };
1125
+ if (current !== undefined) {
1126
+ this.store.updateFact({ ...current, validTo: now, supersededBy: version.id });
1127
+ this.ledger('fact-supersede', 'fact', current.id, actor, { by: version.id }, request.reason);
1128
+ }
1129
+ this.store.insertFact(version);
1130
+ this.ledger('fact-add', 'fact', version.id, actor, {
1131
+ category: request.category,
1132
+ factKey: request.factKey,
1133
+ }, request.reason);
1134
+ return version;
1135
+ }
1136
+ /** Human fact edit: supersedes the current version with a locked one. */
1137
+ humanEditFact(request, actor) {
1138
+ const current = this.store.getFact(request.factId);
1139
+ if (current === undefined)
1140
+ throw new Error(`memory: fact not found: ${request.factId}`);
1141
+ if (current.validTo !== undefined || current.deleted)
1142
+ throw new Error('memory: only the current fact version can be edited');
1143
+ const now = Date.now();
1144
+ const version = {
1145
+ ...current,
1146
+ id: randomUUID(),
1147
+ value: request.value,
1148
+ origin: 'human',
1149
+ validFrom: now,
1150
+ recordedAt: now,
1151
+ locked: true,
1152
+ conflictPending: false,
1153
+ corroboration: current.corroboration,
1154
+ };
1155
+ // The replacement reopens the valid-time window of the edited slot.
1156
+ delete version.validTo;
1157
+ delete version.supersededBy;
1158
+ this.store.updateFact({ ...current, validTo: now, supersededBy: version.id });
1159
+ this.store.insertFact(version);
1160
+ this.ledger('fact-edit', 'fact', version.id, actor, {
1161
+ category: version.category,
1162
+ factKey: version.factKey,
1163
+ previous: current.id,
1164
+ }, request.reason);
1165
+ return version;
1166
+ }
1167
+ /** Human fact delete: tombstone the current version. */
1168
+ humanDeleteFact(request, actor) {
1169
+ const current = this.store.getFact(request.factId);
1170
+ if (current === undefined)
1171
+ throw new Error(`memory: fact not found: ${request.factId}`);
1172
+ if (current.validTo !== undefined || current.deleted)
1173
+ throw new Error('memory: only the current fact version can be deleted');
1174
+ this.store.updateFact({ ...current, validTo: Date.now(), deleted: true });
1175
+ this.ledger('fact-delete', 'fact', current.id, actor, {
1176
+ category: current.category,
1177
+ factKey: current.factKey,
1178
+ value: current.value,
1179
+ }, request.reason);
1180
+ }
1181
+ /** Human fact confirmation: lock or unlock the current version. */
1182
+ humanConfirmFact(request, actor) {
1183
+ const current = this.store.getFact(request.factId);
1184
+ if (current === undefined)
1185
+ throw new Error(`memory: fact not found: ${request.factId}`);
1186
+ const next = { ...current, locked: request.locked, conflictPending: false };
1187
+ this.store.updateFact(next);
1188
+ this.ledger(request.locked ? 'fact-lock' : 'fact-unlock', 'fact', next.id, actor, {
1189
+ category: next.category,
1190
+ factKey: next.factKey,
1191
+ }, request.reason);
1192
+ return next;
1193
+ }
1194
+ /** Human rollback (same gate, human actor). */
1195
+ humanRollback(request, actor) {
1196
+ return this.rollback(request, actor);
1197
+ }
1198
+ // ── stats / integrity / export ────────────────────────────────────────────
1199
+ /** Aggregated library statistics. */
1200
+ stats() {
1201
+ const all = this.store.listExperiences({});
1202
+ const byStatus = {
1203
+ candidate: 0, live: 0, challenged: 0, superseded: 0, archived: 0, cold: 0,
1204
+ };
1205
+ const byKind = { positive: 0, negative: 0 };
1206
+ let trustSum = 0;
1207
+ let pinned = 0;
1208
+ for (const exp of all) {
1209
+ byStatus[exp.status] += 1;
1210
+ byKind[exp.kind] += 1;
1211
+ trustSum += exp.trust;
1212
+ if (exp.pinned)
1213
+ pinned += 1;
1214
+ }
1215
+ const diary = this.store.diaryCounts();
1216
+ const recall = this.store.recallCounts();
1217
+ const reports = this.store.allReports().filter(r => r.counted !== 'none').length;
1218
+ return {
1219
+ experiences: {
1220
+ total: all.length,
1221
+ byStatus,
1222
+ byKind,
1223
+ pinned,
1224
+ avgTrust: all.length === 0 ? 0 : trustSum / all.length,
1225
+ },
1226
+ facts: this.store.factCounts(),
1227
+ diary: { total: diary.total, unextracted: diary.unextracted, extractions: this.store.extractionCount() },
1228
+ ledgerBlocks: this.store.ledgerCount(),
1229
+ recall: { events: recall.events, negative: recall.negative, reportsAfterRecall: reports },
1230
+ consolidation: {
1231
+ lastTs: this.store.lastConsolidationTs(),
1232
+ due: this.consolidationDue().due,
1233
+ newSince: this.store.countExperiencesSince(this.store.lastConsolidationTs()),
1234
+ },
1235
+ };
1236
+ }
1237
+ /** Verify the ledger hash chain end to end. */
1238
+ verifyLedger() {
1239
+ const blocks = this.store.ledgerAll();
1240
+ let prev = '';
1241
+ for (const block of blocks) {
1242
+ const expected = blockHash(block.ts, block.op, block.objectType, block.objectId, block.actor, block.payload, prev);
1243
+ if (block.prevHash !== prev || block.hash !== expected) {
1244
+ return { ok: false, checked: blocks.length, brokenAt: block.seq };
1245
+ }
1246
+ prev = block.hash;
1247
+ }
1248
+ return { ok: true, checked: blocks.length };
1249
+ }
1250
+ /** Ledger query for the workbench and the model tool. */
1251
+ ledgerQuery(request) {
1252
+ return this.store.ledgerQuery({
1253
+ ...(request.objectType === undefined ? {} : { objectType: request.objectType }),
1254
+ ...(request.objectId === undefined ? {} : { objectId: request.objectId }),
1255
+ ...(request.op === undefined ? {} : { op: request.op }),
1256
+ ...(request.offset === undefined ? {} : { offset: request.offset }),
1257
+ ...(request.seqFrom === undefined ? {} : { seqFrom: request.seqFrom }),
1258
+ ...(request.seqTo === undefined ? {} : { seqTo: request.seqTo }),
1259
+ limit: request.limit ?? 50,
1260
+ });
1261
+ }
1262
+ /** Count ledger blocks matching a filter (007 §2 pagination). */
1263
+ ledgerQueryCount(request) {
1264
+ return this.store.ledgerQueryCount({
1265
+ ...(request.objectType === undefined ? {} : { objectType: request.objectType }),
1266
+ ...(request.objectId === undefined ? {} : { objectId: request.objectId }),
1267
+ ...(request.op === undefined ? {} : { op: request.op }),
1268
+ ...(request.seqFrom === undefined ? {} : { seqFrom: request.seqFrom }),
1269
+ ...(request.seqTo === undefined ? {} : { seqTo: request.seqTo }),
1270
+ });
1271
+ }
1272
+ /** Full library export (experiments + migration). */
1273
+ exportLibrary() {
1274
+ const diaryEntries = this.store.listDiary(1_000_000, 0, false);
1275
+ return {
1276
+ exportedAt: Date.now(),
1277
+ schemaVersion: 1,
1278
+ experiences: this.store.listExperiences({}),
1279
+ reports: this.store.allReports().map(report => ({
1280
+ id: report.id,
1281
+ experienceId: report.experienceId,
1282
+ revision: report.revision,
1283
+ outcome: report.outcome,
1284
+ attribution: report.attribution,
1285
+ counted: report.counted,
1286
+ ...(report.evidence === undefined ? {} : { evidence: report.evidence }),
1287
+ ...(report.dedupeKey === undefined ? {} : { dedupeKey: report.dedupeKey }),
1288
+ ts: report.ts,
1289
+ })),
1290
+ diary: diaryEntries,
1291
+ facts: this.store.allFacts(),
1292
+ extractions: this.store.listExtractions(1_000_000, 0),
1293
+ recalls: this.store.allRecallEvents().map(event => ({
1294
+ id: event.id,
1295
+ ts: event.ts,
1296
+ situation: event.situation,
1297
+ injectedIds: event.injectedIds,
1298
+ none: event.none,
1299
+ })),
1300
+ ledger: this.store.ledgerAll(),
1301
+ };
1302
+ }
1303
+ }
1304
+ //# sourceMappingURL=core.js.map