recallnest 2.6.1 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +191 -0
- package/README.md +131 -14
- package/README_CN.md +121 -17
- package/config.json.example +8 -1
- package/docs/api-reference.md +1 -1
- package/integrations/README.md +1 -1
- package/integrations/agy/README.md +73 -0
- package/integrations/agy/setup.sh +75 -0
- package/integrations/setup-all.sh +1 -1
- package/package.json +5 -4
- package/src/__tests__/access-tracker-novelty.test.ts +22 -2
- package/src/__tests__/activity-counter.test.ts +38 -38
- package/src/__tests__/age-label.test.ts +33 -0
- package/src/__tests__/cluster-consolidation.test.ts +313 -22
- package/src/__tests__/consolidation-engine.test.ts +90 -0
- package/src/__tests__/contracts.test.ts +2 -2
- package/src/__tests__/data-checkup.test.ts +31 -3
- package/src/__tests__/dream-pipeline.test.ts +41 -1
- package/src/__tests__/dream-scope-isolation.test.ts +386 -0
- package/src/__tests__/dream-write-batching.test.ts +211 -0
- package/src/__tests__/env-config-invariant.test.ts +80 -0
- package/src/__tests__/env-config.test.ts +60 -0
- package/src/__tests__/ingest-scope-prefix.test.ts +69 -0
- package/src/__tests__/maintenance-locks.test.ts +8 -0
- package/src/__tests__/memory-evolution-b2-b3-e1.test.ts +17 -0
- package/src/__tests__/memory-output.test.ts +9 -0
- package/src/__tests__/memory-utility.test.ts +256 -0
- package/src/__tests__/openai-contract.test.ts +513 -0
- package/src/__tests__/promotion-distinct-sources.test.ts +162 -0
- package/src/__tests__/retrieve-audit-revision.test.ts +255 -0
- package/src/__tests__/source-heartbeat.test.ts +6 -2
- package/src/__tests__/store-patch-metadata.test.ts +30 -0
- package/src/__tests__/synthesis-contract.test.ts +252 -0
- package/src/__tests__/synthesis-promotion.test.ts +369 -0
- package/src/__tests__/trusted-memory.test.ts +28 -0
- package/src/__tests__/utility-weight.test.ts +57 -0
- package/src/__tests__/verbatim-self-recall.test.ts +279 -0
- package/src/__tests__/version-manager.test.ts +35 -0
- package/src/access-tracker.ts +10 -1
- package/src/activity-counter.ts +69 -11
- package/src/age-label.ts +29 -0
- package/src/audit-log.ts +35 -0
- package/src/auto-gc.ts +97 -30
- package/src/cli.ts +224 -10
- package/src/consolidation-engine.ts +317 -123
- package/src/data-checkup.ts +17 -3
- package/src/dream-pipeline.ts +90 -12
- package/src/embedder.ts +20 -5
- package/src/env-config.ts +53 -0
- package/src/forget-engine.ts +8 -2
- package/src/gateway-server.ts +272 -0
- package/src/ingest.ts +23 -5
- package/src/llm-client.ts +86 -25
- package/src/mcp-server.ts +2 -1
- package/src/mcp-tools-advanced.ts +7 -2
- package/src/mcp-tools-core.ts +11 -1
- package/src/mcp-tools-governance.ts +45 -3
- package/src/memory-evolution.ts +16 -0
- package/src/memory-output.ts +12 -8
- package/src/memory-promotion.ts +280 -0
- package/src/memory-store-port.ts +32 -3
- package/src/memory-utility.ts +314 -0
- package/src/recall-ledger.ts +76 -0
- package/src/retriever.ts +82 -9
- package/src/scope-policy.ts +22 -1
- package/src/session-output.ts +4 -3
- package/src/skill-promotion.ts +152 -1
- package/src/store.ts +231 -71
- package/src/synthesis-contract.ts +286 -0
- package/src/version-manager.ts +83 -0
- package/src/workflow-observation-schema.ts +26 -0
- package/integrations/gemini-cli/README.md +0 -57
- package/integrations/gemini-cli/setup.sh +0 -64
- /package/integrations/{gemini-cli/gemini-md-snippet.md → agy/agy-md-snippet.md} +0 -0
package/src/memory-promotion.ts
CHANGED
|
@@ -244,3 +244,283 @@ export function formatPromoteScanResult(result: PromoteScanResult): string {
|
|
|
244
244
|
}
|
|
245
245
|
return lines.join("\n");
|
|
246
246
|
}
|
|
247
|
+
|
|
248
|
+
// ---------------------------------------------------------------------------
|
|
249
|
+
// Synthesis promotion: evidence → durable for dream-synthesized conclusions
|
|
250
|
+
// ---------------------------------------------------------------------------
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* The layering this implements is "evidence retained → current projection → eligible for
|
|
254
|
+
* recall → ranked result". `scanMemoryPromotions` above covers one road into the durable
|
|
255
|
+
* layer: transcript-downgraded facts that recur often enough to be worth keeping. This
|
|
256
|
+
* covers the other one, which had no road at all.
|
|
257
|
+
*
|
|
258
|
+
* `buildDerivedBoundary` (consolidation-engine) stamps every dream-synthesized insight and
|
|
259
|
+
* cross-memory pattern as `layer: "evidence"`, deliberately — a model re-reading its own
|
|
260
|
+
* memories is a lead to its sources, not authority over them. But
|
|
261
|
+
* `shouldUseStableMemoryResult` refuses anything on the evidence layer, so the consequence
|
|
262
|
+
* was absolute: **a synthesized conclusion could never take part in stable memory, no
|
|
263
|
+
* matter how well supported it was.** The synthesis contract added in `efdb043` made those
|
|
264
|
+
* conclusions checkable (a verdict may abstain, and evidence ids are validated against the
|
|
265
|
+
* cluster) without giving them anywhere to go.
|
|
266
|
+
*
|
|
267
|
+
* ## Why the gate is not the one above
|
|
268
|
+
*
|
|
269
|
+
* The recurrence gate — "the same fact showed up in N separate places" — is the wrong
|
|
270
|
+
* question to ask a synthesis. A synthesis is *already* a cross-entry aggregate; demanding
|
|
271
|
+
* that the dream produce the same conclusion three times would be counting one thing three
|
|
272
|
+
* ways. What it carries instead is its own support set, so that is what gets checked:
|
|
273
|
+
* enough distinct sources, still live, under a contract version that validated them.
|
|
274
|
+
*
|
|
275
|
+
* ## What promotion does and does not touch
|
|
276
|
+
*
|
|
277
|
+
* The synthesis row is never modified — archive-first, and the evidence has to stay
|
|
278
|
+
* readable as evidence. Promotion writes a *separate* durable entry through the same
|
|
279
|
+
* `promoteMemory` path everything else uses, so it inherits canonicalKey dedup (re-running
|
|
280
|
+
* revises rather than duplicating), secret redaction, and `promotedFrom` provenance
|
|
281
|
+
* pointing back at the synthesis. The durable copy is what later recall can lean on.
|
|
282
|
+
*/
|
|
283
|
+
|
|
284
|
+
/** Synthesis contract version required before a derived row is eligible. */
|
|
285
|
+
export const MIN_PROMOTABLE_CONTRACT_VERSION = 2;
|
|
286
|
+
|
|
287
|
+
export interface SynthesisPromoteScanConfig {
|
|
288
|
+
/** Min distinct, still-live evidence memories behind the conclusion (default 2). */
|
|
289
|
+
minDistinctEvidence: number;
|
|
290
|
+
/** Min importance of the synthesis itself (default 0.6). */
|
|
291
|
+
minImportance: number;
|
|
292
|
+
/** Max derived rows fetched from the store (default 2000). */
|
|
293
|
+
listLimit: number;
|
|
294
|
+
/** When true (default), report candidates without writing anything. */
|
|
295
|
+
dryRun: boolean;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
export const DEFAULT_SYNTHESIS_PROMOTE_CONFIG: SynthesisPromoteScanConfig = {
|
|
299
|
+
minDistinctEvidence: 2,
|
|
300
|
+
minImportance: 0.6,
|
|
301
|
+
listLimit: 2000,
|
|
302
|
+
dryRun: true,
|
|
303
|
+
};
|
|
304
|
+
|
|
305
|
+
/** Why an otherwise-derived row was not promoted. Reported, never silent. */
|
|
306
|
+
export type SynthesisSkipReason =
|
|
307
|
+
| "pre_contract"
|
|
308
|
+
| "already_promoted"
|
|
309
|
+
| "evidence_unresolvable"
|
|
310
|
+
| "too_few_distinct_evidence"
|
|
311
|
+
| "below_importance";
|
|
312
|
+
|
|
313
|
+
export interface SynthesisPromoteCandidate {
|
|
314
|
+
seedId: string;
|
|
315
|
+
seedText: string;
|
|
316
|
+
/** Durable category the conclusion is promoted into (from boundary.originalCategory). */
|
|
317
|
+
category: DurableMemoryCategory;
|
|
318
|
+
contractVersion: number;
|
|
319
|
+
/** Distinct evidence ids that are still active in the store. */
|
|
320
|
+
evidenceIds: string[];
|
|
321
|
+
importance: number;
|
|
322
|
+
/** Filled after a real promotion; null in dryRun mode. */
|
|
323
|
+
promoted: StoredPromotedMemoryRecord | null;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
export interface SynthesisPromoteScanResult {
|
|
327
|
+
candidates: SynthesisPromoteCandidate[];
|
|
328
|
+
/** Derived rows examined (carrying a distillation boundary), before gating. */
|
|
329
|
+
scannedSyntheses: number;
|
|
330
|
+
/** Per-reason counts for everything examined and rejected. */
|
|
331
|
+
skipped: Record<SynthesisSkipReason, number>;
|
|
332
|
+
promoted: number;
|
|
333
|
+
dryRun: boolean;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
export interface SynthesisPromoteScanDeps {
|
|
337
|
+
store: Pick<MemoryStore, "list"> & {
|
|
338
|
+
getById?(id: string): Promise<MemoryEntry | null>;
|
|
339
|
+
get?(id: string): Promise<MemoryEntry | null>;
|
|
340
|
+
};
|
|
341
|
+
promote: PromoteFn;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function readContractVersion(metadata?: string): number | null {
|
|
345
|
+
const parsed = parseMetadataObject(metadata);
|
|
346
|
+
const raw = parsed?.synthesis_contract;
|
|
347
|
+
return typeof raw === "number" && Number.isFinite(raw) ? raw : null;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/** Ids the model itself named as support, deduplicated and order-preserved. */
|
|
351
|
+
function readEvidenceIds(metadata?: string): string[] | null {
|
|
352
|
+
const parsed = parseMetadataObject(metadata);
|
|
353
|
+
const raw = parsed?.evidenceMemories;
|
|
354
|
+
if (!Array.isArray(raw)) return null;
|
|
355
|
+
const ids = raw.filter((id): id is string => typeof id === "string" && id.trim().length > 0);
|
|
356
|
+
return [...new Set(ids)];
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/** A dream-synthesized derivative — evidence layer written by the distillation path. */
|
|
360
|
+
function isSynthesisRow(entry: MemoryEntry): boolean {
|
|
361
|
+
const boundary = extractBoundaryMetadata(entry.metadata);
|
|
362
|
+
return boundary?.layer === "evidence" && boundary.authority === "distillation";
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
async function loadEntry(
|
|
366
|
+
deps: SynthesisPromoteScanDeps,
|
|
367
|
+
id: string,
|
|
368
|
+
): Promise<MemoryEntry | null> {
|
|
369
|
+
if (deps.store.getById) return deps.store.getById(id);
|
|
370
|
+
if (deps.store.get) return deps.store.get(id);
|
|
371
|
+
return null;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* Find synthesized conclusions whose own support set justifies a durable copy.
|
|
376
|
+
*
|
|
377
|
+
* Abstains rather than guesses: a row whose evidence ids cannot be resolved at all is
|
|
378
|
+
* counted under `evidence_unresolvable` and left alone. That mirrors the abstention path
|
|
379
|
+
* in `scanForPromotions`' cross-source check — when the provenance needed for the judgement
|
|
380
|
+
* is missing, not judging is the correct answer, not judging leniently.
|
|
381
|
+
*/
|
|
382
|
+
export async function scanSynthesisPromotions(
|
|
383
|
+
deps: SynthesisPromoteScanDeps,
|
|
384
|
+
scope: string,
|
|
385
|
+
config?: Partial<SynthesisPromoteScanConfig>,
|
|
386
|
+
): Promise<SynthesisPromoteScanResult> {
|
|
387
|
+
const cfg: SynthesisPromoteScanConfig = { ...DEFAULT_SYNTHESIS_PROMOTE_CONFIG, ...config };
|
|
388
|
+
|
|
389
|
+
const result: SynthesisPromoteScanResult = {
|
|
390
|
+
candidates: [],
|
|
391
|
+
scannedSyntheses: 0,
|
|
392
|
+
skipped: {
|
|
393
|
+
pre_contract: 0,
|
|
394
|
+
already_promoted: 0,
|
|
395
|
+
evidence_unresolvable: 0,
|
|
396
|
+
too_few_distinct_evidence: 0,
|
|
397
|
+
below_importance: 0,
|
|
398
|
+
},
|
|
399
|
+
promoted: 0,
|
|
400
|
+
dryRun: cfg.dryRun,
|
|
401
|
+
};
|
|
402
|
+
|
|
403
|
+
// Synthesized insights take the cluster's majority category, so this cannot be narrowed
|
|
404
|
+
// to one category the way the downgraded-evidence scan is.
|
|
405
|
+
const rows = await deps.store.list([scope], undefined, cfg.listLimit, 0);
|
|
406
|
+
const syntheses = rows.filter((e) => isActiveMemory(e.metadata) && isSynthesisRow(e));
|
|
407
|
+
result.scannedSyntheses = syntheses.length;
|
|
408
|
+
|
|
409
|
+
for (const entry of syntheses) {
|
|
410
|
+
const contractVersion = readContractVersion(entry.metadata);
|
|
411
|
+
// Derivatives written before the synthesis contract were never validated — the whole
|
|
412
|
+
// point of the version stamp is that "which code produced this" stops being a guess.
|
|
413
|
+
if (contractVersion === null || contractVersion < MIN_PROMOTABLE_CONTRACT_VERSION) {
|
|
414
|
+
result.skipped.pre_contract += 1;
|
|
415
|
+
continue;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
if (hasPromotedToMarker(entry.metadata)) {
|
|
419
|
+
result.skipped.already_promoted += 1;
|
|
420
|
+
continue;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
const declared = readEvidenceIds(entry.metadata);
|
|
424
|
+
if (declared === null || declared.length === 0) {
|
|
425
|
+
result.skipped.evidence_unresolvable += 1;
|
|
426
|
+
continue;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// Evidence that has since been superseded or archived no longer supports anything.
|
|
430
|
+
const resolved: string[] = [];
|
|
431
|
+
let lookupFailed = false;
|
|
432
|
+
for (const id of declared) {
|
|
433
|
+
const source = await loadEntry(deps, id);
|
|
434
|
+
if (source === null) {
|
|
435
|
+
lookupFailed = true;
|
|
436
|
+
continue;
|
|
437
|
+
}
|
|
438
|
+
if (isActiveMemory(source.metadata)) resolved.push(id);
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// Cannot see any of the support: abstain instead of promoting on an unchecked claim.
|
|
442
|
+
if (resolved.length === 0 && lookupFailed) {
|
|
443
|
+
result.skipped.evidence_unresolvable += 1;
|
|
444
|
+
continue;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
if (resolved.length < cfg.minDistinctEvidence) {
|
|
448
|
+
result.skipped.too_few_distinct_evidence += 1;
|
|
449
|
+
continue;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
if ((entry.importance ?? 0) < cfg.minImportance) {
|
|
453
|
+
result.skipped.below_importance += 1;
|
|
454
|
+
continue;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
const boundary = extractBoundaryMetadata(entry.metadata);
|
|
458
|
+
const category = (boundary?.originalCategory ?? "events") as DurableMemoryCategory;
|
|
459
|
+
|
|
460
|
+
const candidate: SynthesisPromoteCandidate = {
|
|
461
|
+
seedId: entry.id,
|
|
462
|
+
seedText: entry.text,
|
|
463
|
+
category,
|
|
464
|
+
contractVersion,
|
|
465
|
+
evidenceIds: resolved,
|
|
466
|
+
importance: entry.importance,
|
|
467
|
+
promoted: null,
|
|
468
|
+
};
|
|
469
|
+
|
|
470
|
+
if (!cfg.dryRun) {
|
|
471
|
+
candidate.promoted = await deps.promote({
|
|
472
|
+
memoryId: entry.id,
|
|
473
|
+
category,
|
|
474
|
+
scope,
|
|
475
|
+
importance: entry.importance,
|
|
476
|
+
});
|
|
477
|
+
result.promoted += 1;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
result.candidates.push(candidate);
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
return result;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
export function formatSynthesisPromoteResult(result: SynthesisPromoteScanResult): string {
|
|
487
|
+
const skippedTotal = Object.values(result.skipped).reduce((a, b) => a + b, 0);
|
|
488
|
+
const lines = [
|
|
489
|
+
`Synthesis promote-scan: ${result.scannedSyntheses} synthesized conclusion(s) examined, ${skippedTotal} skipped.`,
|
|
490
|
+
];
|
|
491
|
+
|
|
492
|
+
// Always print the rejection breakdown: a scan that promotes nothing should still say
|
|
493
|
+
// why, or "nothing found" and "everything filtered" look the same from outside.
|
|
494
|
+
const reasons = (Object.entries(result.skipped) as Array<[SynthesisSkipReason, number]>)
|
|
495
|
+
.filter(([, n]) => n > 0)
|
|
496
|
+
.map(([reason, n]) => `${reason}=${n}`);
|
|
497
|
+
if (reasons.length > 0) lines.push(`Skipped: ${reasons.join(", ")}`);
|
|
498
|
+
|
|
499
|
+
if (result.candidates.length === 0) {
|
|
500
|
+
lines.push("No promotion candidates found.");
|
|
501
|
+
return lines.join("\n");
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
lines.push(
|
|
505
|
+
result.dryRun
|
|
506
|
+
? `Found ${result.candidates.length} candidate(s) (dry-run, nothing written):\n`
|
|
507
|
+
: `Promoted ${result.promoted} of ${result.candidates.length} candidate(s):\n`,
|
|
508
|
+
);
|
|
509
|
+
|
|
510
|
+
for (const [i, c] of result.candidates.entries()) {
|
|
511
|
+
lines.push(`### ${i + 1}. [${c.category}] ${c.seedText.split("\n")[0].slice(0, 80)}`);
|
|
512
|
+
lines.push(
|
|
513
|
+
`Evidence: ${c.evidenceIds.length} distinct (${c.evidenceIds.map((id) => id.slice(0, 8)).join(", ")})`,
|
|
514
|
+
);
|
|
515
|
+
lines.push(`Contract: v${c.contractVersion} | importance: ${c.importance.toFixed(2)} | seed: ${c.seedId.slice(0, 8)}`);
|
|
516
|
+
if (c.promoted) {
|
|
517
|
+
lines.push(
|
|
518
|
+
c.promoted.disposition === "conflict" && c.promoted.conflictId
|
|
519
|
+
? `-> conflict ${c.promoted.conflictId.slice(0, 8)} (manual review)`
|
|
520
|
+
: `-> durable ${c.promoted.id.slice(0, 8)} (${c.promoted.disposition}, key ${c.promoted.canonicalKey})`,
|
|
521
|
+
);
|
|
522
|
+
}
|
|
523
|
+
lines.push("");
|
|
524
|
+
}
|
|
525
|
+
return lines.join("\n");
|
|
526
|
+
}
|
package/src/memory-store-port.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { MemoryEntry, MemorySearchResult } from "./store.js";
|
|
2
|
+
import type { ScopeMatchMode } from "./scope-policy.js";
|
|
2
3
|
|
|
3
4
|
export interface MemoryStoreStats {
|
|
4
5
|
totalCount: number;
|
|
@@ -18,8 +19,16 @@ export type MemoryStoreUpdate = {
|
|
|
18
19
|
};
|
|
19
20
|
|
|
20
21
|
export interface MemoryStorePort {
|
|
21
|
-
|
|
22
|
-
|
|
22
|
+
/** scopeMatch 见 scope-policy.ts 的 ScopeMatchMode:缺省 "family"(历史行为,
|
|
23
|
+
* 无冒号 scope 按前缀),"exact" 由调用方在"我给的是一个具体 scope 名"时显式声明。 */
|
|
24
|
+
stats(scopeFilter?: string[], scopeMatch?: ScopeMatchMode): Promise<MemoryStoreStats>;
|
|
25
|
+
list(
|
|
26
|
+
scopeFilter?: string[],
|
|
27
|
+
category?: string,
|
|
28
|
+
limit?: number,
|
|
29
|
+
offset?: number,
|
|
30
|
+
scopeMatch?: ScopeMatchMode,
|
|
31
|
+
): Promise<MemoryEntry[]>;
|
|
23
32
|
/** 按 id 批量取真实向量。list()/listPage() 为性能恒返回 vector:[](假空数组),
|
|
24
33
|
* 任何要做相似度/聚类的消费者必须用这个回填——promote_scan 与 dream 3b 都栽过同一坑。 */
|
|
25
34
|
getVectors(ids: string[]): Promise<Map<string, number[]>>;
|
|
@@ -42,5 +51,25 @@ export interface MemoryStorePort {
|
|
|
42
51
|
patchFn: (meta: Record<string, unknown>, entry: MemoryEntry) => Record<string, unknown>,
|
|
43
52
|
scopeFilter?: string[],
|
|
44
53
|
): Promise<MemoryEntry | null>;
|
|
45
|
-
|
|
54
|
+
/** metadata 批量读改写:单锁单 commit 写一批行(≤200/批)。patchFn 在锁内以库中
|
|
55
|
+
* 最新行为底座、按数组序依次应用(同批闭包可传递决策)。单条失败跳过不炸整批,
|
|
56
|
+
* 返回成功写入数。dream 3a/3b/auto-gc 的写放大治理入口(2026-08-14)。 */
|
|
57
|
+
patchMetadataBatch(
|
|
58
|
+
patches: Array<{
|
|
59
|
+
id: string;
|
|
60
|
+
patchFn: (meta: Record<string, unknown>, entry: MemoryEntry) => Record<string, unknown>;
|
|
61
|
+
}>,
|
|
62
|
+
scopeFilter?: string[],
|
|
63
|
+
): Promise<number>;
|
|
64
|
+
/** Remove version-group metadata from groups with only one surviving member.
|
|
65
|
+
* Implementations should take one membership snapshot and apply repairs under
|
|
66
|
+
* the same write lock so concurrent group creation cannot invalidate the plan. */
|
|
67
|
+
repairSingletonVersionGroups(): Promise<number>;
|
|
68
|
+
vectorSearch(
|
|
69
|
+
vector: number[],
|
|
70
|
+
limit?: number,
|
|
71
|
+
minScore?: number,
|
|
72
|
+
scopeFilter?: string[],
|
|
73
|
+
scopeMatch?: ScopeMatchMode,
|
|
74
|
+
): Promise<MemorySearchResult[]>;
|
|
46
75
|
}
|
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Memory Utility — 价值回溯(MemOS Reflect2Evolve 的零 LLM 变体)。
|
|
3
|
+
*
|
|
4
|
+
* 借鉴来源:MemOS 自进化记忆系统借鉴评估(2026-06-13 审计,C1「价值回溯算术内核」)。
|
|
5
|
+
* MemOS 原式两处烧 LLM(任务级 R_human 三轴 rubric + 步级反思权重 α_t),
|
|
6
|
+
* 这里两处都换成库里现成的确定性信号:
|
|
7
|
+
* R_human → workflow observation 的四档 outcome 查表(OUTCOME_REWARD)
|
|
8
|
+
* α_t → join 强度(任务级精确命中 vs 会话级弱关联),见 JOIN_WEIGHT
|
|
9
|
+
* 回溯本体在 MemOS 里就是纯算术,这里同样零 LLM、零网络。
|
|
10
|
+
*
|
|
11
|
+
* ⚠️ **这一列不写进 `importance`**,理由是硬的:
|
|
12
|
+
* 1. importance 是**阈值语义**——`resolveTier` 拿 ≥0.95 判 core、`isDecayExempt`
|
|
13
|
+
* 拿 core+≥0.95 判**永不衰减**(decay-engine.ts:180-186)。一条效用高的记忆
|
|
14
|
+
* 被顶过 0.95 就顺手拿到永久免衰减,这是串扰不是特性。
|
|
15
|
+
* 2. 那一列已经有写手:STC 的 retroactive-boost 在改它,传播的是**语义相关性**;
|
|
16
|
+
* 价值回溯传播的是**因果贡献**。两件事不该共用一列,否则互相盖。
|
|
17
|
+
* 3. importance 不能为负,而负值那半最值钱——「上次这么干失败了」在 importance 里没有容器装。
|
|
18
|
+
* utility 落在 metadata(与 tier/accessCount/readerIds 同族),只喂检索排序:
|
|
19
|
+
* `resolveTier` 与 `isDecayExempt` 都不读这个字段,tier 与豁免行为天然零变化。
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import type { MemoryEntry } from "./store.js";
|
|
23
|
+
import type {
|
|
24
|
+
WorkflowObservationOutcome,
|
|
25
|
+
WorkflowObservationRecord,
|
|
26
|
+
} from "./workflow-observation-schema.js";
|
|
27
|
+
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
// 常量
|
|
30
|
+
// ---------------------------------------------------------------------------
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* outcome → 回报值。取代 MemOS 的 LLM 三轴打分(目标 0.45/过程 0.30/满意 0.25)。
|
|
34
|
+
* 数值可调,关键是它**确定性、零成本**——LLM 打的分没法在 7 万条库上重放。
|
|
35
|
+
*/
|
|
36
|
+
export const OUTCOME_REWARD: Record<WorkflowObservationOutcome, number> = {
|
|
37
|
+
success: 0.8,
|
|
38
|
+
corrected: 0.2,
|
|
39
|
+
missed: -0.3,
|
|
40
|
+
failure: -0.8,
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* join 强度权重,充当 MemOS α_t(反思权重)的位置。
|
|
45
|
+
* exact = observation 自己列出了这条 memory;
|
|
46
|
+
* session = 只知道「同一个会话读过这条、且那个会话报了这个结果」,弱得多——
|
|
47
|
+
* 一个会话会跑很多任务,readerIds 又是累积的,不加权就会把无关任务的成败摊到这条头上。
|
|
48
|
+
*/
|
|
49
|
+
export const JOIN_WEIGHT = { exact: 1.0, session: 0.25 } as const;
|
|
50
|
+
|
|
51
|
+
/** 半衰期(天)。旧结果该让位给新结果,与 MemOS priority 式的 0.5^(Δdays/30) 同形。 */
|
|
52
|
+
export const UTILITY_HALF_LIFE_DAYS = 30;
|
|
53
|
+
|
|
54
|
+
/** 低于这个有效样本量时不下结论(utility=null)。 */
|
|
55
|
+
export const MIN_EFFECTIVE_SAMPLES = 0.5;
|
|
56
|
+
|
|
57
|
+
export type JoinKind = keyof typeof JOIN_WEIGHT;
|
|
58
|
+
|
|
59
|
+
// ---------------------------------------------------------------------------
|
|
60
|
+
// 类型
|
|
61
|
+
// ---------------------------------------------------------------------------
|
|
62
|
+
|
|
63
|
+
export interface MemoryOutcomeLink {
|
|
64
|
+
observationId: string;
|
|
65
|
+
workflowId: string;
|
|
66
|
+
outcome: WorkflowObservationOutcome;
|
|
67
|
+
recordedAt: string;
|
|
68
|
+
join: JoinKind;
|
|
69
|
+
summary: string;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface MemoryOutcomeSummary {
|
|
73
|
+
memoryId: string;
|
|
74
|
+
/** 参与过的任务总数(exact + session)。 */
|
|
75
|
+
total: number;
|
|
76
|
+
exactCount: number;
|
|
77
|
+
sessionCount: number;
|
|
78
|
+
counts: Record<WorkflowObservationOutcome, number>;
|
|
79
|
+
/** success / total。没有样本时为 null——0% 和「没数据」不是一回事。 */
|
|
80
|
+
successRate: number | null;
|
|
81
|
+
/** 只看精确 join 的成功率,null 表示没有精确样本。 */
|
|
82
|
+
exactSuccessRate: number | null;
|
|
83
|
+
/**
|
|
84
|
+
* 效用值 ∈ [-0.8, 0.8],可为负。样本不足时为 null。
|
|
85
|
+
* null 与 0 语义不同:null = 没数据,0 = 有数据但正负相抵。
|
|
86
|
+
*/
|
|
87
|
+
utility: number | null;
|
|
88
|
+
/** 时间折扣后的有效样本量,用于判断这个 utility 有多可信。 */
|
|
89
|
+
effectiveSamples: number;
|
|
90
|
+
/**
|
|
91
|
+
* utility 是拿哪部分样本算的。
|
|
92
|
+
* `exact` = 有精确 join 样本,会话级样本被排除在计算之外(默认策略,见 summarizeMemoryOutcomes);
|
|
93
|
+
* `session` = 只有会话级样本,聊胜于无;`none` = 没样本。
|
|
94
|
+
*/
|
|
95
|
+
utilityBasis: "exact" | "session" | "none";
|
|
96
|
+
links: MemoryOutcomeLink[];
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// ---------------------------------------------------------------------------
|
|
100
|
+
// join
|
|
101
|
+
// ---------------------------------------------------------------------------
|
|
102
|
+
|
|
103
|
+
/** 从 memory metadata 里取 readerIds(会话级 join 的另一半 key)。 */
|
|
104
|
+
export function readReaderIds(metadata?: string): string[] {
|
|
105
|
+
try {
|
|
106
|
+
const meta = JSON.parse(metadata || "{}") as Record<string, unknown>;
|
|
107
|
+
return Array.isArray(meta.readerIds)
|
|
108
|
+
? meta.readerIds.filter((r): r is string => typeof r === "string")
|
|
109
|
+
: [];
|
|
110
|
+
} catch {
|
|
111
|
+
return [];
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* 把一条 memory 和 workflow observations 配对起来——本方案要补的那个 join key。
|
|
117
|
+
*
|
|
118
|
+
* 两条路,精确的优先:
|
|
119
|
+
* exact —— observation.recalledIds 直接点名了这条 memory
|
|
120
|
+
* session —— observation.readerId 在这条 memory 的 readerIds 里(同一会话读过它)
|
|
121
|
+
* 同一条 observation 只算一次,exact 覆盖 session。
|
|
122
|
+
*/
|
|
123
|
+
export function linkMemoryToObservations(
|
|
124
|
+
memoryId: string,
|
|
125
|
+
memoryMetadata: string | undefined,
|
|
126
|
+
observations: readonly WorkflowObservationRecord[],
|
|
127
|
+
): MemoryOutcomeLink[] {
|
|
128
|
+
const readerIds = new Set(readReaderIds(memoryMetadata));
|
|
129
|
+
const links: MemoryOutcomeLink[] = [];
|
|
130
|
+
|
|
131
|
+
for (const obs of observations) {
|
|
132
|
+
const exact = Array.isArray(obs.recalledIds) && obs.recalledIds.includes(memoryId);
|
|
133
|
+
const session = !exact && !!obs.readerId && readerIds.has(obs.readerId);
|
|
134
|
+
if (!exact && !session) continue;
|
|
135
|
+
links.push({
|
|
136
|
+
observationId: obs.observationId,
|
|
137
|
+
workflowId: obs.workflowId,
|
|
138
|
+
outcome: obs.outcome,
|
|
139
|
+
recordedAt: obs.recordedAt,
|
|
140
|
+
join: exact ? "exact" : "session",
|
|
141
|
+
summary: obs.summary,
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return links.sort((a, b) => Date.parse(b.recordedAt) - Date.parse(a.recordedAt));
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// ---------------------------------------------------------------------------
|
|
149
|
+
// 汇总 + 效用
|
|
150
|
+
// ---------------------------------------------------------------------------
|
|
151
|
+
|
|
152
|
+
function emptyCounts(): Record<WorkflowObservationOutcome, number> {
|
|
153
|
+
return { success: 0, failure: 0, corrected: 0, missed: 0 };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* 汇总一条 memory 的参与记录,算出成功率与 utility。
|
|
158
|
+
*
|
|
159
|
+
* utility = Σ(w_join · decay · R) / Σ(w_join · decay)
|
|
160
|
+
* 加权平均而不是求和:一条被读一百次的记忆不该仅凭次数就压过一条被读三次但每次都救场的。
|
|
161
|
+
*/
|
|
162
|
+
export function summarizeMemoryOutcomes(
|
|
163
|
+
memoryId: string,
|
|
164
|
+
links: readonly MemoryOutcomeLink[],
|
|
165
|
+
now = Date.now(),
|
|
166
|
+
options: { preferExact?: boolean } = {},
|
|
167
|
+
): MemoryOutcomeSummary {
|
|
168
|
+
const preferExact = options.preferExact ?? true;
|
|
169
|
+
const counts = emptyCounts();
|
|
170
|
+
let exactCount = 0;
|
|
171
|
+
let sessionCount = 0;
|
|
172
|
+
let exactSuccess = 0;
|
|
173
|
+
const acc = {
|
|
174
|
+
exact: { reward: 0, weight: 0 },
|
|
175
|
+
session: { reward: 0, weight: 0 },
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
for (const link of links) {
|
|
179
|
+
counts[link.outcome]++;
|
|
180
|
+
if (link.join === "exact") {
|
|
181
|
+
exactCount++;
|
|
182
|
+
if (link.outcome === "success") exactSuccess++;
|
|
183
|
+
} else {
|
|
184
|
+
sessionCount++;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const ageDays = Math.max(0, (now - Date.parse(link.recordedAt)) / 86_400_000);
|
|
188
|
+
const decay = Math.pow(0.5, ageDays / UTILITY_HALF_LIFE_DAYS);
|
|
189
|
+
const weight = JOIN_WEIGHT[link.join] * decay;
|
|
190
|
+
acc[link.join].reward += weight * OUTCOME_REWARD[link.outcome];
|
|
191
|
+
acc[link.join].weight += weight;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// 有精确样本时不掺会话级样本 —— 这不是洁癖,是 2026-08-23 落地当天的实测结论:
|
|
195
|
+
// 同一个会话跑多个任务时,readerId join 会把无关任务的成败摊到每条被读过的记忆头上。
|
|
196
|
+
// 实测那次 5 条记忆的会话级成功率全是 44%(同一批 observation),
|
|
197
|
+
// 而精确成功率是 100% / 33% / 0% —— 后者才带信息,前者是会话噪声的常数。
|
|
198
|
+
// 一条从没被精确点名过的记忆(历史 observation / CLI 上报)仍退回会话级,聊胜于无。
|
|
199
|
+
const useExact = preferExact && acc.exact.weight >= MIN_EFFECTIVE_SAMPLES;
|
|
200
|
+
const basisAcc = useExact
|
|
201
|
+
? acc.exact
|
|
202
|
+
: { reward: acc.exact.reward + acc.session.reward, weight: acc.exact.weight + acc.session.weight };
|
|
203
|
+
|
|
204
|
+
const total = links.length;
|
|
205
|
+
const hasUtility = basisAcc.weight >= MIN_EFFECTIVE_SAMPLES;
|
|
206
|
+
return {
|
|
207
|
+
memoryId,
|
|
208
|
+
total,
|
|
209
|
+
exactCount,
|
|
210
|
+
sessionCount,
|
|
211
|
+
counts,
|
|
212
|
+
successRate: total > 0 ? counts.success / total : null,
|
|
213
|
+
exactSuccessRate: exactCount > 0 ? exactSuccess / exactCount : null,
|
|
214
|
+
utility: hasUtility ? basisAcc.reward / basisAcc.weight : null,
|
|
215
|
+
effectiveSamples: basisAcc.weight,
|
|
216
|
+
utilityBasis: !hasUtility ? "none" : useExact ? "exact" : "session",
|
|
217
|
+
links: [...links],
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** 一步到位:memory + observations → 汇总。 */
|
|
222
|
+
export function computeMemoryUtility(
|
|
223
|
+
entry: Pick<MemoryEntry, "id" | "metadata">,
|
|
224
|
+
observations: readonly WorkflowObservationRecord[],
|
|
225
|
+
now = Date.now(),
|
|
226
|
+
options: { preferExact?: boolean } = {},
|
|
227
|
+
): MemoryOutcomeSummary {
|
|
228
|
+
return summarizeMemoryOutcomes(
|
|
229
|
+
entry.id,
|
|
230
|
+
linkMemoryToObservations(entry.id, entry.metadata, observations),
|
|
231
|
+
now,
|
|
232
|
+
options,
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// ---------------------------------------------------------------------------
|
|
237
|
+
// 写回 metadata
|
|
238
|
+
// ---------------------------------------------------------------------------
|
|
239
|
+
|
|
240
|
+
/** metadata 里 utility 三件套的字段名。改名要同步 retriever 的 applyUtilityWeight。 */
|
|
241
|
+
export const UTILITY_FIELD = "utility";
|
|
242
|
+
export const UTILITY_SAMPLES_FIELD = "utilitySamples";
|
|
243
|
+
export const UTILITY_UPDATED_FIELD = "utilityUpdatedAt";
|
|
244
|
+
export const UTILITY_BASIS_FIELD = "utilityBasis";
|
|
245
|
+
|
|
246
|
+
/** 从 metadata 读 utility;没有/坏了都返回 null(不是 0——见上文语义说明)。 */
|
|
247
|
+
export function readUtility(metadata?: string): number | null {
|
|
248
|
+
try {
|
|
249
|
+
const meta = JSON.parse(metadata || "{}") as Record<string, unknown>;
|
|
250
|
+
const value = meta[UTILITY_FIELD];
|
|
251
|
+
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
|
252
|
+
} catch {
|
|
253
|
+
return null;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* 把 utility 写进 metadata 对象(纯函数,调用方负责落库)。
|
|
259
|
+
* utility=null 时**清除**旧值,而不是写 0:证据没了就该退回「没数据」,不是退回「中性」。
|
|
260
|
+
*/
|
|
261
|
+
export function applyUtilityToMeta(
|
|
262
|
+
meta: Record<string, unknown>,
|
|
263
|
+
summary: MemoryOutcomeSummary,
|
|
264
|
+
now = Date.now(),
|
|
265
|
+
): Record<string, unknown> {
|
|
266
|
+
const next = { ...meta };
|
|
267
|
+
if (summary.utility === null) {
|
|
268
|
+
delete next[UTILITY_FIELD];
|
|
269
|
+
delete next[UTILITY_SAMPLES_FIELD];
|
|
270
|
+
delete next[UTILITY_BASIS_FIELD];
|
|
271
|
+
delete next[UTILITY_UPDATED_FIELD];
|
|
272
|
+
return next;
|
|
273
|
+
}
|
|
274
|
+
next[UTILITY_FIELD] = Number(summary.utility.toFixed(4));
|
|
275
|
+
next[UTILITY_SAMPLES_FIELD] = Number(summary.effectiveSamples.toFixed(4));
|
|
276
|
+
next[UTILITY_BASIS_FIELD] = summary.utilityBasis;
|
|
277
|
+
next[UTILITY_UPDATED_FIELD] = now;
|
|
278
|
+
return next;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// ---------------------------------------------------------------------------
|
|
282
|
+
// 输出
|
|
283
|
+
// ---------------------------------------------------------------------------
|
|
284
|
+
|
|
285
|
+
function pct(value: number | null): string {
|
|
286
|
+
return value === null ? "n/a" : `${Math.round(value * 100)}%`;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
export function formatMemoryUtility(summary: MemoryOutcomeSummary, options: { showLinks?: number } = {}): string {
|
|
290
|
+
const lines: string[] = [];
|
|
291
|
+
lines.push(`Memory ${summary.memoryId}`);
|
|
292
|
+
lines.push(` 参与任务: ${summary.total} 条 (精确 ${summary.exactCount} / 会话级 ${summary.sessionCount})`);
|
|
293
|
+
lines.push(
|
|
294
|
+
` outcome: success=${summary.counts.success} corrected=${summary.counts.corrected}`
|
|
295
|
+
+ ` missed=${summary.counts.missed} failure=${summary.counts.failure}`,
|
|
296
|
+
);
|
|
297
|
+
lines.push(` 成功率: ${pct(summary.successRate)} (仅精确 join: ${pct(summary.exactSuccessRate)})`);
|
|
298
|
+
lines.push(
|
|
299
|
+
` utility: ${summary.utility === null ? "null(样本不足,不下结论)" : summary.utility.toFixed(4)}`
|
|
300
|
+
+ ` [基于 ${summary.utilityBasis} join,有效样本 ${summary.effectiveSamples.toFixed(2)}]`,
|
|
301
|
+
);
|
|
302
|
+
|
|
303
|
+
const limit = options.showLinks ?? 0;
|
|
304
|
+
if (limit > 0 && summary.links.length > 0) {
|
|
305
|
+
lines.push(" 最近参与:");
|
|
306
|
+
for (const link of summary.links.slice(0, limit)) {
|
|
307
|
+
lines.push(
|
|
308
|
+
` ${link.recordedAt.slice(0, 10)} [${link.join}] ${link.workflowId} → ${link.outcome}`
|
|
309
|
+
+ ` ${link.summary.slice(0, 60)}`,
|
|
310
|
+
);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
return lines.join("\n");
|
|
314
|
+
}
|