recallnest 1.4.1 → 2.2.1
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/.env +4 -0
- package/CLAUDE.md +7 -3
- package/README.md +202 -130
- package/README_CN.md +263 -190
- package/bin/recallnest.mjs +62 -0
- package/config.json +55 -0
- package/daily-recall.sh +23 -0
- package/integrations/setup-all.sh +31 -0
- package/logs/daily-recall.error.log +0 -0
- package/logs/daily-recall.log +30 -0
- package/logs/ingest-2026-04-11.log +110 -0
- package/logs/ingest-2026-04-12.log +90 -0
- package/logs/launchagent-stderr.log +0 -0
- package/logs/launchagent-stdout.log +0 -0
- package/package.json +10 -3
- package/src/access-tracker.ts +3 -2
- package/src/activity-counter.ts +109 -0
- package/src/adaptive-retrieval.ts +6 -5
- package/src/admission-control.ts +163 -0
- package/src/anchor-generator.ts +80 -0
- package/src/api-server.ts +10 -0
- package/src/audit-log.ts +122 -0
- package/src/auto-gc.ts +98 -29
- package/src/auto-recall.ts +33 -4
- package/src/capture-engine.ts +372 -23
- package/src/cascade-forget.ts +3 -2
- package/src/cli.ts +107 -5
- package/src/compat.ts +22 -0
- package/src/confidence-tracker.ts +210 -47
- package/src/conflict-store.ts +12 -3
- package/src/consolidation-engine.ts +296 -8
- package/src/context-collapse-renderer.ts +222 -0
- package/src/context-composer-stable-selection.ts +5 -1
- package/src/context-composer.ts +239 -4
- package/src/context-reconstructor.ts +404 -0
- package/src/conversation-importer.ts +441 -0
- package/src/data-checkup.ts +232 -0
- package/src/decay-engine.ts +84 -0
- package/src/distill-lock.ts +122 -0
- package/src/doctor.ts +8 -10
- package/src/dream-pipeline.ts +227 -0
- package/src/emotion-detector.ts +90 -0
- package/src/eval.ts +3 -2
- package/src/forget-engine.ts +269 -0
- package/src/frequency-tracker.ts +175 -0
- package/src/graph-export.ts +644 -0
- package/src/ingest.ts +36 -6
- package/src/interference-detector.ts +187 -0
- package/src/kg-extractor.ts +15 -24
- package/src/kg-store.ts +2 -1
- package/src/language-hook.ts +67 -0
- package/src/llm-client.ts +289 -6
- package/src/mcp-server.ts +707 -80
- package/src/memory-assets.ts +22 -5
- package/src/memory-evolution.ts +365 -0
- package/src/memory-lint.ts +429 -0
- package/src/memory-output.ts +69 -0
- package/src/memory-schema.ts +74 -2
- package/src/narrative-schema.ts +79 -0
- package/src/narrative-tagger.ts +164 -0
- package/src/pii-detector.ts +98 -0
- package/src/post-retrieval-filter.ts +76 -0
- package/src/prediction-engine.ts +253 -0
- package/src/preference-slots.ts +65 -2
- package/src/prospective-memory.ts +215 -0
- package/src/query-expander.ts +67 -0
- package/src/recall-governor.ts +183 -0
- package/src/retention-policy.ts +90 -0
- package/src/retriever.ts +828 -55
- package/src/rif.ts +34 -8
- package/src/runtime-config.ts +23 -8
- package/src/scope-inventory.ts +4 -3
- package/src/scope-policy.ts +4 -0
- package/src/session-distiller.ts +307 -0
- package/src/session-output.ts +40 -0
- package/src/session-schema.ts +37 -0
- package/src/session-store.ts +7 -2
- package/src/skill-engine.ts +208 -0
- package/src/skill-promotion.ts +293 -0
- package/src/skill-schema.ts +39 -0
- package/src/store.ts +96 -10
- package/src/topic-tag.ts +66 -0
- package/src/tracker.ts +3 -1
- package/src/ui-server.ts +80 -8
- package/src/workflow-observation-scope-review.ts +2 -1
- package/src/workflow-observation-store.ts +2 -1
- package/src/write-verifier.ts +126 -0
- package/.claude-plugin/CLAUDE.md +0 -29
- package/.claude-plugin/marketplace.json +0 -18
- package/.github/workflows/ci.yml +0 -28
- package/.mcp.json +0 -9
- package/CHANGELOG.md +0 -51
- package/DEMO.md +0 -94
- package/EVAL.md +0 -89
- package/FAILURES.md +0 -75
- package/OPERATIONS.md +0 -59
- package/ROADMAP.md +0 -198
- package/assets/recallnest-workbench.png +0 -0
- package/assets/ui/app.js +0 -807
- package/assets/ui/index.html +0 -154
- package/assets/ui/styles.css +0 -605
- package/assets/wechat-qr.jpg +0 -0
- package/daily-reflections/2026-03-05.md +0 -15
- package/daily-reflections/2026-03-06.md +0 -15
- package/daily-reflections/2026-03-07.md +0 -7
- package/daily-reflections/2026-03-08.md +0 -34
- package/daily-reflections/2026-03-09.md +0 -35
- package/daily-reflections/2026-03-10.md +0 -36
- package/daily-reflections/2026-03-11.md +0 -35
- package/daily-reflections/2026-03-12.md +0 -36
- package/daily-reflections/2026-03-13.md +0 -35
- package/daily-reflections/2026-03-14.md +0 -35
- package/daily-reflections/2026-03-15.md +0 -35
- package/daily-reflections/2026-03-16.md +0 -41
- package/daily-reflections/2026-03-17.md +0 -10
- package/daily-reflections/2026-03-18.md +0 -10
- package/daily-reflections/2026-03-19.md +0 -10
- package/daily-reflections/2026-03-20.md +0 -10
- package/daily-reflections/2026-03-21.md +0 -10
- package/daily-reflections/2026-03-22.md +0 -10
- package/daily-reflections/2026-03-23.md +0 -10
- package/daily-reflections/2026-03-24.md +0 -10
- package/daily-reflections/2026-03-25.md +0 -32
- package/daily-reflections/2026-03-26.md +0 -37
- package/daily-reflections/2026-03-27.md +0 -35
- package/daily-reflections/2026-03-28.md +0 -35
- package/daily-reflections/2026-03-29.md +0 -35
- package/daily-reflections/2026-03-30.md +0 -36
- package/daily-reflections/2026-03-31.md +0 -35
- package/daily-reflections/2026-04-01.md +0 -35
- package/docs/api-reference.md +0 -1291
- package/docs/architecture.md +0 -168
- package/docs/memory-boundary-contract.md +0 -226
- package/docs/memory-categories.md +0 -119
- package/docs/pipeline-audit-ultramemory.md +0 -177
- package/docs/plans/2026-03-08-query-expansion.md +0 -270
- package/docs/plans/2026-03-16-continuity-layer.md +0 -196
- package/docs/plans/mcp-tiering-plan.md +0 -126
- package/docs/research/recall-quality-audit-2026-03-22.md +0 -153
- package/docs/self-evolution.md +0 -198
- package/docs/superpowers/plans/2026-03-19-ingest-pipeline-upgrade.md +0 -782
- package/docs/superpowers/specs/2026-03-19-ingest-pipeline-upgrade-design.md +0 -77
- package/docs/superpowers/specs/2026-03-23-pull-claude-desktop-design.md +0 -95
- package/eval/continuity/results-2026-03-16.md +0 -117
- package/eval/continuity/results-2026-03-19.md +0 -453
- package/eval/continuity/runbook.md +0 -110
- package/eval/continuity/seed-cases.md +0 -54
- package/eval/reports/2026-03-06-abstract-query-baseline.md +0 -96
- package/eval/reports/2026-03-06-aws-ssh-baseline.md +0 -82
- package/eval/reports/2026-03-06-baseline.md +0 -82
- package/eval/reports/query-expansion.md +0 -138
- package/lm +0 -19
- package/plan-v1.2-distributable.md +0 -105
- package/scripts/archive-distilled-junk.ts +0 -116
- package/scripts/backfill-l0l1l2.ts +0 -98
- package/scripts/blind-scan.ts +0 -588
- package/scripts/cleanup-step1-exact-dups.ts +0 -117
- package/scripts/cleanup-step2-template-dups.ts +0 -113
- package/scripts/cleanup-step3-stale-content.ts +0 -149
- package/scripts/cleanup-step4-orphan-scopes.ts +0 -131
- package/scripts/delete-distilled-junk.ts +0 -88
- package/scripts/distill-facts.ts +0 -305
- package/scripts/health-check.ts +0 -343
- package/scripts/incremental-ingest.sh +0 -71
- package/scripts/mlp-health-check.ts +0 -382
- package/scripts/pull-claude-desktop.ts +0 -515
- package/scripts/re-ingest-cc.sh +0 -66
- package/scripts/rebalance-memory-health.ts +0 -181
- package/scripts/run-desktop-ingest.sh +0 -11
- package/scripts/start-server.sh +0 -12
- package/scripts/viz-dashboard.html +0 -423
- package/scripts/viz-export.ts +0 -703
- package/src/__tests__/access-tracker-novelty.test.ts +0 -228
- package/src/__tests__/auto-consolidation.test.ts +0 -168
- package/src/__tests__/auto-recall.test.ts +0 -131
- package/src/__tests__/batch-dedup.test.ts +0 -65
- package/src/__tests__/capture-engine.test.ts +0 -1142
- package/src/__tests__/capture-heuristic.test.ts +0 -171
- package/src/__tests__/cascade-forget.test.ts +0 -210
- package/src/__tests__/category-min-scores.test.ts +0 -33
- package/src/__tests__/confidence-tracker.test.ts +0 -196
- package/src/__tests__/conflict-advisor.test.ts +0 -206
- package/src/__tests__/conflict-escalation.test.ts +0 -149
- package/src/__tests__/conflict-lifecycle.test.ts +0 -83
- package/src/__tests__/conflict-output.test.ts +0 -85
- package/src/__tests__/conflict-store.test.ts +0 -374
- package/src/__tests__/consolidation-engine.test.ts +0 -181
- package/src/__tests__/context-composer-pins.test.ts +0 -64
- package/src/__tests__/context-composer-task-selection.test.ts +0 -24
- package/src/__tests__/context-renderer.test.ts +0 -91
- package/src/__tests__/continuity-eval.test.ts +0 -275
- package/src/__tests__/continuity-schema.test.ts +0 -97
- package/src/__tests__/core-recall-eval.test.ts +0 -235
- package/src/__tests__/core-summary.test.ts +0 -109
- package/src/__tests__/dedup-actions.test.ts +0 -58
- package/src/__tests__/deterministic-id.test.ts +0 -39
- package/src/__tests__/doctor.test.ts +0 -303
- package/src/__tests__/drill-down.test.ts +0 -108
- package/src/__tests__/embedder.test.ts +0 -122
- package/src/__tests__/entity-resolver.test.ts +0 -58
- package/src/__tests__/event-segmenter.test.ts +0 -153
- package/src/__tests__/hotness-blend.test.ts +0 -76
- package/src/__tests__/ingest.test.ts +0 -360
- package/src/__tests__/kg-extractor.test.ts +0 -184
- package/src/__tests__/kg-store.test.ts +0 -198
- package/src/__tests__/llm-consolidation.test.ts +0 -226
- package/src/__tests__/memory-boundaries.test.ts +0 -237
- package/src/__tests__/memory-health-rebalance.test.ts +0 -144
- package/src/__tests__/memory-output.test.ts +0 -215
- package/src/__tests__/multi-vector.test.ts +0 -242
- package/src/__tests__/ppr-traversal.test.ts +0 -256
- package/src/__tests__/preference-guard-smoke.test.ts +0 -211
- package/src/__tests__/preference-matcher.test.ts +0 -230
- package/src/__tests__/preference-slots.test.ts +0 -106
- package/src/__tests__/prospective-memory.test.ts +0 -283
- package/src/__tests__/query-entity-detector.test.ts +0 -171
- package/src/__tests__/query-expander.test.ts +0 -34
- package/src/__tests__/recall-mode.test.ts +0 -64
- package/src/__tests__/result-synthesizer.test.ts +0 -198
- package/src/__tests__/resume-context.test.ts +0 -4743
- package/src/__tests__/retrieval-trace.test.ts +0 -213
- package/src/__tests__/retriever-boundary-weight.test.ts +0 -136
- package/src/__tests__/retroactive-boost.test.ts +0 -223
- package/src/__tests__/scope-inventory.test.ts +0 -200
- package/src/__tests__/scope-policy.test.ts +0 -69
- package/src/__tests__/session-checkpoint.test.ts +0 -259
- package/src/__tests__/session-gc.test.ts +0 -215
- package/src/__tests__/store-scope-audit.test.ts +0 -81
- package/src/__tests__/temporal-parser.test.ts +0 -241
- package/src/__tests__/term-registry.test.ts +0 -121
- package/src/__tests__/tool-output-compressor.test.ts +0 -133
- package/src/__tests__/version-manager.test.ts +0 -213
- package/src/__tests__/workflow-observation.test.ts +0 -400
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LME-8: Admission Control — 写入准入控制
|
|
3
|
+
*
|
|
4
|
+
* 在 capture-engine.ts 的 persistMemory 入口处执行写前门控,
|
|
5
|
+
* 拦截低质量记忆,避免垃圾进库再过滤。
|
|
6
|
+
*
|
|
7
|
+
* 4 层准入检查:
|
|
8
|
+
* 1. 最短文本长度 — 过短文本拒绝
|
|
9
|
+
* 2. Noise filter — 噪声模式前置过滤
|
|
10
|
+
* 3. Importance 下限 — 低 importance 拒绝
|
|
11
|
+
* 4. Scope 写入频率限制 — 防 flood
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { isNoise } from "./noise-filter.js";
|
|
15
|
+
import { logInfo } from "./stderr-log.js";
|
|
16
|
+
|
|
17
|
+
// ============================================================================
|
|
18
|
+
// Configuration
|
|
19
|
+
// ============================================================================
|
|
20
|
+
|
|
21
|
+
export interface AdmissionConfig {
|
|
22
|
+
/** Minimum text length to accept (default: 10) */
|
|
23
|
+
minTextLength: number;
|
|
24
|
+
/** Enable noise-filter pre-check (default: true) */
|
|
25
|
+
noiseFilterEnabled: boolean;
|
|
26
|
+
/** Minimum importance to accept (default: 0.2) */
|
|
27
|
+
minImportance: number;
|
|
28
|
+
/** Max writes per scope within the rate window (default: 50) */
|
|
29
|
+
maxWritesPerScope: number;
|
|
30
|
+
/** Rate limit window in milliseconds (default: 60_000 = 1 minute) */
|
|
31
|
+
rateLimitWindowMs: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const DEFAULT_CONFIG: AdmissionConfig = {
|
|
35
|
+
minTextLength: 10,
|
|
36
|
+
noiseFilterEnabled: true,
|
|
37
|
+
minImportance: 0.2,
|
|
38
|
+
maxWritesPerScope: 50,
|
|
39
|
+
rateLimitWindowMs: 60_000,
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export function resolveAdmissionConfig(
|
|
43
|
+
overrides?: Partial<AdmissionConfig>,
|
|
44
|
+
): AdmissionConfig {
|
|
45
|
+
return { ...DEFAULT_CONFIG, ...overrides };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// ============================================================================
|
|
49
|
+
// Admission Result
|
|
50
|
+
// ============================================================================
|
|
51
|
+
|
|
52
|
+
export type AdmissionVerdict = "accepted" | "rejected";
|
|
53
|
+
export type RejectionReason =
|
|
54
|
+
| "text_too_short"
|
|
55
|
+
| "noise_detected"
|
|
56
|
+
| "importance_too_low"
|
|
57
|
+
| "rate_limited";
|
|
58
|
+
|
|
59
|
+
export interface AdmissionResult {
|
|
60
|
+
verdict: AdmissionVerdict;
|
|
61
|
+
reason?: RejectionReason;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const ACCEPTED: AdmissionResult = { verdict: "accepted" };
|
|
65
|
+
|
|
66
|
+
// ============================================================================
|
|
67
|
+
// Rate Limiter — per-scope write frequency tracking
|
|
68
|
+
// ============================================================================
|
|
69
|
+
|
|
70
|
+
export class ScopeRateLimiter {
|
|
71
|
+
private readonly windows = new Map<string, number[]>();
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Record a write for a scope and check if rate limit is exceeded.
|
|
75
|
+
* Returns true if the write should be allowed.
|
|
76
|
+
*/
|
|
77
|
+
check(scope: string, config: AdmissionConfig): boolean {
|
|
78
|
+
const now = Date.now();
|
|
79
|
+
const cutoff = now - config.rateLimitWindowMs;
|
|
80
|
+
|
|
81
|
+
let timestamps = this.windows.get(scope);
|
|
82
|
+
if (!timestamps) {
|
|
83
|
+
timestamps = [];
|
|
84
|
+
this.windows.set(scope, timestamps);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Prune expired timestamps
|
|
88
|
+
const firstValid = timestamps.findIndex((t) => t >= cutoff);
|
|
89
|
+
if (firstValid > 0) {
|
|
90
|
+
timestamps.splice(0, firstValid);
|
|
91
|
+
} else if (firstValid === -1) {
|
|
92
|
+
timestamps.length = 0;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (timestamps.length >= config.maxWritesPerScope) {
|
|
96
|
+
return false;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
timestamps.push(now);
|
|
100
|
+
return true;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Number of scopes currently tracked. */
|
|
104
|
+
get size(): number {
|
|
105
|
+
return this.windows.size;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Clear all tracking state. */
|
|
109
|
+
clear(): void {
|
|
110
|
+
this.windows.clear();
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// ============================================================================
|
|
115
|
+
// Public API — admission gate
|
|
116
|
+
// ============================================================================
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Check whether a memory write should be admitted.
|
|
120
|
+
* Call this at the top of persistMemory, before embedding.
|
|
121
|
+
*
|
|
122
|
+
* @param text - Memory text to evaluate
|
|
123
|
+
* @param importance - Importance score (0–1)
|
|
124
|
+
* @param scope - Target scope
|
|
125
|
+
* @param rateLimiter - Optional shared rate limiter instance
|
|
126
|
+
* @param config - Optional config overrides
|
|
127
|
+
*/
|
|
128
|
+
export function checkAdmission(
|
|
129
|
+
text: string,
|
|
130
|
+
importance: number,
|
|
131
|
+
scope: string,
|
|
132
|
+
rateLimiter?: ScopeRateLimiter,
|
|
133
|
+
config?: Partial<AdmissionConfig>,
|
|
134
|
+
): AdmissionResult {
|
|
135
|
+
const cfg = resolveAdmissionConfig(config);
|
|
136
|
+
|
|
137
|
+
// Layer 1: minimum text length
|
|
138
|
+
const trimmed = text.trim();
|
|
139
|
+
if (trimmed.length < cfg.minTextLength) {
|
|
140
|
+
logInfo(`[admission-control] rejected: text too short (${trimmed.length} < ${cfg.minTextLength})`);
|
|
141
|
+
return { verdict: "rejected", reason: "text_too_short" };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Layer 2: noise filter pre-check
|
|
145
|
+
if (cfg.noiseFilterEnabled && isNoise(trimmed)) {
|
|
146
|
+
logInfo(`[admission-control] rejected: noise detected`);
|
|
147
|
+
return { verdict: "rejected", reason: "noise_detected" };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Layer 3: importance floor
|
|
151
|
+
if (importance < cfg.minImportance) {
|
|
152
|
+
logInfo(`[admission-control] rejected: importance too low (${importance} < ${cfg.minImportance})`);
|
|
153
|
+
return { verdict: "rejected", reason: "importance_too_low" };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Layer 4: scope rate limiting
|
|
157
|
+
if (rateLimiter && !rateLimiter.check(scope, cfg)) {
|
|
158
|
+
logInfo(`[admission-control] rejected: rate limited for scope "${scope}"`);
|
|
159
|
+
return { verdict: "rejected", reason: "rate_limited" };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return ACCEPTED;
|
|
163
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* P0.1 — Anchor Generator
|
|
3
|
+
*
|
|
4
|
+
* Generates a short (≤80 chars) retrieval anchor for each memory.
|
|
5
|
+
* Short anchors bridge the embedding distance gap between brief queries
|
|
6
|
+
* (e.g. "轮巡") and long stored documents.
|
|
7
|
+
*
|
|
8
|
+
* Heuristic-only — no LLM calls, no latency added to writes.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const MAX_ANCHOR_LENGTH = 80;
|
|
12
|
+
|
|
13
|
+
/** CJK character detector */
|
|
14
|
+
const CJK_RE = /[\u4e00-\u9fff\u3400-\u4dbf]/;
|
|
15
|
+
|
|
16
|
+
/** Extract first meaningful sentence (period, or newline). */
|
|
17
|
+
function firstSentence(text: string): string {
|
|
18
|
+
// Split on newline first (preserves paragraph structure)
|
|
19
|
+
const firstLine = text.split(/\n/)[0].trim();
|
|
20
|
+
|
|
21
|
+
// Then split on sentence boundaries within that line
|
|
22
|
+
const match = firstLine.match(/^(.+?)[。!?.!?]/);
|
|
23
|
+
return match ? match[1].trim() : firstLine;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Extract structured title from metadata (patterns, cases). */
|
|
27
|
+
function extractTitle(metadata?: Record<string, unknown>): string | undefined {
|
|
28
|
+
if (!metadata) return undefined;
|
|
29
|
+
const wp = metadata.workflowPattern as Record<string, unknown> | undefined;
|
|
30
|
+
if (wp?.title && typeof wp.title === "string") return wp.title;
|
|
31
|
+
const cm = metadata.caseMemory as Record<string, unknown> | undefined;
|
|
32
|
+
if (cm?.title && typeof cm.title === "string") return cm.title;
|
|
33
|
+
return undefined;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Generate a retrieval anchor (≤80 chars) for a memory entry.
|
|
38
|
+
*
|
|
39
|
+
* Priority:
|
|
40
|
+
* 1. Structured title from metadata (patterns/cases always have one)
|
|
41
|
+
* 2. First sentence of text (heuristic)
|
|
42
|
+
*
|
|
43
|
+
* If the text is already short (≤ MAX_ANCHOR_LENGTH), returns undefined
|
|
44
|
+
* (no anchor needed — the text itself is a good match target).
|
|
45
|
+
*/
|
|
46
|
+
export function generateAnchor(
|
|
47
|
+
text: string,
|
|
48
|
+
metadata?: Record<string, unknown>,
|
|
49
|
+
): string | undefined {
|
|
50
|
+
// Short text already matches short queries well — skip anchor
|
|
51
|
+
if (text.length <= MAX_ANCHOR_LENGTH) return undefined;
|
|
52
|
+
|
|
53
|
+
// Try structured title first (most precise)
|
|
54
|
+
const title = extractTitle(metadata);
|
|
55
|
+
if (title && title.length <= MAX_ANCHOR_LENGTH) {
|
|
56
|
+
return title;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Fall back to first sentence
|
|
60
|
+
let anchor = firstSentence(text);
|
|
61
|
+
|
|
62
|
+
// Truncate to max length, preserving word/char boundary
|
|
63
|
+
if (anchor.length > MAX_ANCHOR_LENGTH) {
|
|
64
|
+
if (CJK_RE.test(anchor)) {
|
|
65
|
+
// CJK: cut at char boundary
|
|
66
|
+
anchor = anchor.slice(0, MAX_ANCHOR_LENGTH);
|
|
67
|
+
} else {
|
|
68
|
+
// Latin: cut at word boundary
|
|
69
|
+
anchor = anchor.slice(0, MAX_ANCHOR_LENGTH).replace(/\s+\S*$/, "");
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// If anchor is too similar to full text start, it adds no value
|
|
74
|
+
if (anchor === text.slice(0, anchor.length)) {
|
|
75
|
+
// Still useful — the shorter embedding will be closer to short queries
|
|
76
|
+
return anchor;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return anchor || undefined;
|
|
80
|
+
}
|
package/src/api-server.ts
CHANGED
|
@@ -24,6 +24,7 @@ import { WorkflowObservationStore } from "./workflow-observation-store.js";
|
|
|
24
24
|
import { buildManagedCheckpointObservation, buildManagedResumeObservation } from "./workflow-observation-managed.js";
|
|
25
25
|
import { runAutoRecall } from "./auto-recall.js";
|
|
26
26
|
import { buildRetrievalContext, resolveScopeSelection } from "./scope-policy.js";
|
|
27
|
+
import { runMemoryLint } from "./memory-lint.js";
|
|
27
28
|
|
|
28
29
|
const config = (loadDotEnv(), loadConfig());
|
|
29
30
|
const getComponents = createComponentResolver(config);
|
|
@@ -589,6 +590,14 @@ async function handleStats(): Promise<Response> {
|
|
|
589
590
|
});
|
|
590
591
|
}
|
|
591
592
|
|
|
593
|
+
/** GET /v1/lint — memory quality lint */
|
|
594
|
+
async function handleLint(searchParams: URLSearchParams): Promise<Response> {
|
|
595
|
+
const { store } = getComponents();
|
|
596
|
+
const scope = searchParams.get("scope") || undefined;
|
|
597
|
+
const report = await runMemoryLint({ store, scope });
|
|
598
|
+
return jsonResponse(report);
|
|
599
|
+
}
|
|
600
|
+
|
|
592
601
|
/** GET /v1/health — health check */
|
|
593
602
|
async function handleHealth(): Promise<Response> {
|
|
594
603
|
try {
|
|
@@ -652,6 +661,7 @@ const server = Bun.serve({
|
|
|
652
661
|
if (pathname === "/v1/workflow-health") return await handleWorkflowHealth(request);
|
|
653
662
|
if (pathname === "/v1/workflow-evidence") return await handleWorkflowEvidence(request);
|
|
654
663
|
if (pathname === "/v1/stats") return await handleStats();
|
|
664
|
+
if (pathname === "/v1/lint") return await handleLint(url.searchParams);
|
|
655
665
|
if (pathname === "/v1/health") return await handleHealth();
|
|
656
666
|
}
|
|
657
667
|
|
package/src/audit-log.ts
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* F-1: Audit log — record every store/update/delete/retrieve operation.
|
|
3
|
+
* Logs are written to a separate file (not mixed into memory data).
|
|
4
|
+
*
|
|
5
|
+
* Each log entry: { timestamp, operation, scope, memoryId, actor, details }
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync } from "node:fs";
|
|
9
|
+
import { dirname, join } from "node:path";
|
|
10
|
+
|
|
11
|
+
export type AuditOperation =
|
|
12
|
+
| "store"
|
|
13
|
+
| "update"
|
|
14
|
+
| "delete"
|
|
15
|
+
| "retrieve"
|
|
16
|
+
| "archive"
|
|
17
|
+
| "supersede"
|
|
18
|
+
| "consolidate"
|
|
19
|
+
| "forget"
|
|
20
|
+
| "cascade_forget";
|
|
21
|
+
|
|
22
|
+
export interface AuditEntry {
|
|
23
|
+
timestamp: string; // ISO 8601
|
|
24
|
+
operation: AuditOperation;
|
|
25
|
+
scope?: string;
|
|
26
|
+
memoryId?: string;
|
|
27
|
+
actor: string; // "manual" | "agent" | "api" | "system"
|
|
28
|
+
details?: string; // brief context (<=200 chars)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface AuditLogger {
|
|
32
|
+
log(entry: Omit<AuditEntry, "timestamp">): void;
|
|
33
|
+
/** Read recent entries (newest first) */
|
|
34
|
+
getRecent(limit?: number): AuditEntry[];
|
|
35
|
+
/** Export all entries as JSON array */
|
|
36
|
+
exportAll(): AuditEntry[];
|
|
37
|
+
/** Count total entries */
|
|
38
|
+
count(): number;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function parseLines(raw: string): AuditEntry[] {
|
|
42
|
+
const entries: AuditEntry[] = [];
|
|
43
|
+
for (const line of raw.split("\n")) {
|
|
44
|
+
const trimmed = line.trim();
|
|
45
|
+
if (!trimmed) continue;
|
|
46
|
+
try {
|
|
47
|
+
entries.push(JSON.parse(trimmed) as AuditEntry);
|
|
48
|
+
} catch {
|
|
49
|
+
// skip malformed lines
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return entries;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* File-based audit logger. Appends JSON lines to a log file.
|
|
57
|
+
* Non-blocking: write failures are silently ignored (audit must never block operations).
|
|
58
|
+
*/
|
|
59
|
+
export function createAuditLogger(logPath?: string): AuditLogger {
|
|
60
|
+
const resolvedPath =
|
|
61
|
+
logPath ??
|
|
62
|
+
join(process.env.RECALLNEST_DATA_DIR || "data", "audit.jsonl");
|
|
63
|
+
|
|
64
|
+
// Ensure directory exists (silent on failure)
|
|
65
|
+
try {
|
|
66
|
+
const dir = dirname(resolvedPath);
|
|
67
|
+
if (!existsSync(dir)) {
|
|
68
|
+
mkdirSync(dir, { recursive: true });
|
|
69
|
+
}
|
|
70
|
+
} catch {
|
|
71
|
+
// silent — audit must never block
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return {
|
|
75
|
+
log(entry: Omit<AuditEntry, "timestamp">): void {
|
|
76
|
+
try {
|
|
77
|
+
const full: AuditEntry = {
|
|
78
|
+
timestamp: new Date().toISOString(),
|
|
79
|
+
...entry,
|
|
80
|
+
};
|
|
81
|
+
// Truncate details to 200 chars
|
|
82
|
+
if (full.details && full.details.length > 200) {
|
|
83
|
+
full.details = full.details.slice(0, 200);
|
|
84
|
+
}
|
|
85
|
+
appendFileSync(resolvedPath, JSON.stringify(full) + "\n", "utf-8");
|
|
86
|
+
} catch {
|
|
87
|
+
// silent — audit must never block operations
|
|
88
|
+
}
|
|
89
|
+
},
|
|
90
|
+
|
|
91
|
+
getRecent(limit = 20): AuditEntry[] {
|
|
92
|
+
try {
|
|
93
|
+
if (!existsSync(resolvedPath)) return [];
|
|
94
|
+
const raw = readFileSync(resolvedPath, "utf-8");
|
|
95
|
+
const all = parseLines(raw);
|
|
96
|
+
return all.slice(-limit).reverse();
|
|
97
|
+
} catch {
|
|
98
|
+
return [];
|
|
99
|
+
}
|
|
100
|
+
},
|
|
101
|
+
|
|
102
|
+
exportAll(): AuditEntry[] {
|
|
103
|
+
try {
|
|
104
|
+
if (!existsSync(resolvedPath)) return [];
|
|
105
|
+
const raw = readFileSync(resolvedPath, "utf-8");
|
|
106
|
+
return parseLines(raw);
|
|
107
|
+
} catch {
|
|
108
|
+
return [];
|
|
109
|
+
}
|
|
110
|
+
},
|
|
111
|
+
|
|
112
|
+
count(): number {
|
|
113
|
+
try {
|
|
114
|
+
if (!existsSync(resolvedPath)) return 0;
|
|
115
|
+
const raw = readFileSync(resolvedPath, "utf-8");
|
|
116
|
+
return parseLines(raw).length;
|
|
117
|
+
} catch {
|
|
118
|
+
return 0;
|
|
119
|
+
}
|
|
120
|
+
},
|
|
121
|
+
};
|
|
122
|
+
}
|
package/src/auto-gc.ts
CHANGED
|
@@ -5,10 +5,20 @@
|
|
|
5
5
|
* AND enough time has passed since last GC. Inspired by NREM triple-coupling:
|
|
6
6
|
* multiple conditions must be satisfied before consolidation fires.
|
|
7
7
|
*
|
|
8
|
-
*
|
|
8
|
+
* B-2 upgrade: Uses evolution system's computeDecayScore + buildArchivedMetadata
|
|
9
|
+
* instead of raw age/importance heuristics. Archive-first, delete-never.
|
|
9
10
|
*/
|
|
10
11
|
|
|
11
12
|
import type { MemoryStore } from "./store.js";
|
|
13
|
+
import {
|
|
14
|
+
parseEvolution,
|
|
15
|
+
computeDecayScore,
|
|
16
|
+
buildArchivedMetadata,
|
|
17
|
+
isActiveMemory,
|
|
18
|
+
} from "./memory-evolution.js";
|
|
19
|
+
import { loadRetentionPolicy, shouldArchiveByPolicy } from "./retention-policy.js";
|
|
20
|
+
import type { RetentionPolicy } from "./retention-policy.js";
|
|
21
|
+
import type { AuditLogger } from "./audit-log.js";
|
|
12
22
|
|
|
13
23
|
// ---------------------------------------------------------------------------
|
|
14
24
|
// Config
|
|
@@ -19,17 +29,20 @@ export interface AutoGcConfig {
|
|
|
19
29
|
minMemoryCount: number;
|
|
20
30
|
/** Minimum hours since last GC (default: 24) */
|
|
21
31
|
minHoursSinceLastGc: number;
|
|
22
|
-
/**
|
|
23
|
-
|
|
32
|
+
/** Decay score below which memories are archive candidates (default: 0.15) */
|
|
33
|
+
decayScoreThreshold: number;
|
|
24
34
|
/** Max entries to archive per run (default: 100) */
|
|
25
35
|
maxArchivePerRun: number;
|
|
36
|
+
/** Minimum age in days before a memory can be archived (default: 30) */
|
|
37
|
+
minAgeDays: number;
|
|
26
38
|
}
|
|
27
39
|
|
|
28
40
|
export const DEFAULT_AUTO_GC_CONFIG: AutoGcConfig = {
|
|
29
41
|
minMemoryCount: 1000,
|
|
30
42
|
minHoursSinceLastGc: 24,
|
|
31
|
-
|
|
43
|
+
decayScoreThreshold: 0.15,
|
|
32
44
|
maxArchivePerRun: 100,
|
|
45
|
+
minAgeDays: 30,
|
|
33
46
|
};
|
|
34
47
|
|
|
35
48
|
// ---------------------------------------------------------------------------
|
|
@@ -52,10 +65,18 @@ export interface GcResult {
|
|
|
52
65
|
/**
|
|
53
66
|
* Check conditions and run GC if all thresholds are met.
|
|
54
67
|
* Returns immediately if conditions not met (no-op).
|
|
68
|
+
*
|
|
69
|
+
* Archive criteria (B-2 evolution-aware):
|
|
70
|
+
* - Memory must be "active" (not already superseded/archived/consolidated)
|
|
71
|
+
* - Composite decay_score < threshold (default 0.15)
|
|
72
|
+
* - Age > minAgeDays (default 30 days)
|
|
73
|
+
* - Not pinned (importance >= 0.95 is considered pinned)
|
|
55
74
|
*/
|
|
56
75
|
export async function maybeRunGc(
|
|
57
76
|
store: MemoryStore,
|
|
58
77
|
config: AutoGcConfig = DEFAULT_AUTO_GC_CONFIG,
|
|
78
|
+
retentionConfigDir?: string,
|
|
79
|
+
auditLogger?: AuditLogger,
|
|
59
80
|
): Promise<GcResult> {
|
|
60
81
|
const stats = await store.stats();
|
|
61
82
|
const totalMemories = stats.total ?? 0;
|
|
@@ -74,8 +95,7 @@ export async function maybeRunGc(
|
|
|
74
95
|
// All conditions met — run GC
|
|
75
96
|
lastGcTimestamp = Date.now();
|
|
76
97
|
|
|
77
|
-
|
|
78
|
-
const cutoffMs = Date.now() - 60 * 86_400_000; // 60 days ago
|
|
98
|
+
const now = Date.now();
|
|
79
99
|
let archivedCount = 0;
|
|
80
100
|
let totalChecked = 0;
|
|
81
101
|
|
|
@@ -83,33 +103,82 @@ export async function maybeRunGc(
|
|
|
83
103
|
const entries = await store.list({ limit: 5000 });
|
|
84
104
|
totalChecked = entries.length;
|
|
85
105
|
|
|
106
|
+
// Pre-compute per-scope active memory counts and cache retention policies.
|
|
107
|
+
// This avoids repeated file reads inside the loop.
|
|
108
|
+
const activeCounts = new Map<string, number>();
|
|
109
|
+
const policyCache = new Map<string, RetentionPolicy>();
|
|
110
|
+
const scopesSeen = new Set<string>();
|
|
111
|
+
|
|
112
|
+
for (const entry of entries) {
|
|
113
|
+
const scope = entry.scope ?? "";
|
|
114
|
+
scopesSeen.add(scope);
|
|
115
|
+
if (isActiveMemory(entry.metadata)) {
|
|
116
|
+
activeCounts.set(scope, (activeCounts.get(scope) ?? 0) + 1);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Batch-load retention policies for all scopes (one file read per scope)
|
|
121
|
+
for (const scope of scopesSeen) {
|
|
122
|
+
policyCache.set(scope, loadRetentionPolicy(scope, retentionConfigDir));
|
|
123
|
+
}
|
|
124
|
+
|
|
86
125
|
for (const entry of entries) {
|
|
87
126
|
if (archivedCount >= config.maxArchivePerRun) break;
|
|
88
127
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
const
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
128
|
+
// Only archive active memories
|
|
129
|
+
if (!isActiveMemory(entry.metadata)) continue;
|
|
130
|
+
|
|
131
|
+
// Never archive pinned memories (importance >= 0.95)
|
|
132
|
+
const importance = entry.importance ?? 0.5;
|
|
133
|
+
if (importance >= 0.95) continue;
|
|
134
|
+
|
|
135
|
+
// Check minimum age
|
|
136
|
+
const ageDays = (now - entry.timestamp) / 86_400_000;
|
|
137
|
+
|
|
138
|
+
// Decay-based archival (existing logic)
|
|
139
|
+
let shouldArchive = false;
|
|
140
|
+
if (ageDays >= config.minAgeDays) {
|
|
141
|
+
const evo = parseEvolution(entry.metadata, entry.timestamp);
|
|
142
|
+
let decayScore = computeDecayScore(evo, importance, now, entry.metadata);
|
|
143
|
+
// F3: Expired memories (validUntil < now) get 2x decay acceleration
|
|
144
|
+
if (evo.validUntil != null && evo.validUntil < now) {
|
|
145
|
+
decayScore *= 0.5; // Halve the score → archives faster
|
|
146
|
+
}
|
|
147
|
+
if (decayScore < config.decayScoreThreshold) {
|
|
148
|
+
shouldArchive = true;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Retention-policy-based archival (F-2): OR with decay-based
|
|
153
|
+
if (!shouldArchive) {
|
|
154
|
+
const scope = entry.scope ?? "";
|
|
155
|
+
const policy = policyCache.get(scope) ?? loadRetentionPolicy(scope, retentionConfigDir);
|
|
156
|
+
const activeCount = activeCounts.get(scope) ?? 0;
|
|
157
|
+
const policyCheck = shouldArchiveByPolicy(policy, ageDays, activeCount);
|
|
158
|
+
if (policyCheck.archive) {
|
|
159
|
+
shouldArchive = true;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (shouldArchive) {
|
|
164
|
+
const archivedMeta = buildArchivedMetadata(entry.metadata);
|
|
165
|
+
await store.update(entry.id, { metadata: archivedMeta });
|
|
112
166
|
archivedCount++;
|
|
167
|
+
// F-1: Audit log — record archive operation (silent on failure)
|
|
168
|
+
try {
|
|
169
|
+
auditLogger?.log({
|
|
170
|
+
operation: "archive",
|
|
171
|
+
memoryId: entry.id,
|
|
172
|
+
actor: "system",
|
|
173
|
+
details: `auto-gc: age=${Math.floor(ageDays)}d`,
|
|
174
|
+
});
|
|
175
|
+
} catch { /* Audit must never block GC */ }
|
|
176
|
+
// Decrement active count for the scope (memory is no longer active)
|
|
177
|
+
const scope = entry.scope ?? "";
|
|
178
|
+
const prev = activeCounts.get(scope) ?? 0;
|
|
179
|
+
if (prev > 0) {
|
|
180
|
+
activeCounts.set(scope, prev - 1);
|
|
181
|
+
}
|
|
113
182
|
}
|
|
114
183
|
}
|
|
115
184
|
|
package/src/auto-recall.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { composeResumeContext, type ResumeContextDeps } from "./context-composer.js";
|
|
2
|
+
import { resolveTier } from "./decay-engine.js";
|
|
2
3
|
import type { DurableMemoryCategory } from "./memory-schema.js";
|
|
4
|
+
import { governResults, truncateQuery, type GovernorConfig, type GovernorSession } from "./recall-governor.js";
|
|
3
5
|
import type { RetrievalResult } from "./retriever.js";
|
|
4
6
|
import { buildRetrievalContext, resolveScopeSelection } from "./scope-policy.js";
|
|
5
7
|
import type { RetrievalProfileName, ResumeContextResponse } from "./session-schema.js";
|
|
@@ -17,6 +19,10 @@ export interface AutoRecallRequest {
|
|
|
17
19
|
profile?: RetrievalProfileName;
|
|
18
20
|
env?: NodeJS.ProcessEnv;
|
|
19
21
|
operation?: string;
|
|
22
|
+
/** Governor config overrides (charBudget, maxItems, maxQueryChars) */
|
|
23
|
+
governor?: Partial<GovernorConfig>;
|
|
24
|
+
/** Governor session for cross-call dedup within a single conversation turn */
|
|
25
|
+
governorSession?: GovernorSession;
|
|
20
26
|
}
|
|
21
27
|
|
|
22
28
|
export interface AutoRecallResponse {
|
|
@@ -31,8 +37,10 @@ export async function runAutoRecall(
|
|
|
31
37
|
deps: ResumeContextDeps,
|
|
32
38
|
request: AutoRecallRequest,
|
|
33
39
|
): Promise<AutoRecallResponse> {
|
|
34
|
-
const
|
|
35
|
-
const
|
|
40
|
+
const govCfg = request.governor;
|
|
41
|
+
const maxQ = govCfg?.maxQueryChars ?? 1000;
|
|
42
|
+
const message = truncateQuery(request.message.replace(/\s+/g, " ").trim(), maxQ);
|
|
43
|
+
const task = truncateQuery((request.task || message).replace(/\s+/g, " ").trim(), maxQ);
|
|
36
44
|
|
|
37
45
|
if (!message) {
|
|
38
46
|
throw new Error("message is required");
|
|
@@ -72,9 +80,11 @@ export async function runAutoRecall(
|
|
|
72
80
|
};
|
|
73
81
|
}
|
|
74
82
|
|
|
75
|
-
const
|
|
83
|
+
const effectiveLimit = request.limit || 5;
|
|
84
|
+
// Fetch extra candidates so tier filtering still yields enough results
|
|
85
|
+
const rawResults = await deps.retriever.retrieve(buildRetrievalContext({
|
|
76
86
|
query: message,
|
|
77
|
-
limit:
|
|
87
|
+
limit: effectiveLimit * 3,
|
|
78
88
|
category: request.category,
|
|
79
89
|
scope: resolvedScope,
|
|
80
90
|
sessionId: request.sessionId,
|
|
@@ -86,6 +96,25 @@ export async function runAutoRecall(
|
|
|
86
96
|
allowUnscoped: request.allScopes === true,
|
|
87
97
|
}));
|
|
88
98
|
|
|
99
|
+
// Tier-aware filtering: prefer core/working memories, backfill with peripheral.
|
|
100
|
+
// Peripheral memories get a 15% score discount to reduce noise injection.
|
|
101
|
+
const promoted: RetrievalResult[] = [];
|
|
102
|
+
const demoted: RetrievalResult[] = [];
|
|
103
|
+
for (const r of rawResults) {
|
|
104
|
+
const tier = resolveTier(r.entry.metadata);
|
|
105
|
+
if (tier === "core" || tier === "working") {
|
|
106
|
+
promoted.push(r);
|
|
107
|
+
} else {
|
|
108
|
+
demoted.push({ ...r, score: r.score * 0.85 });
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
const tierFiltered = [...promoted, ...demoted]
|
|
112
|
+
.sort((a, b) => b.score - a.score)
|
|
113
|
+
.slice(0, effectiveLimit);
|
|
114
|
+
|
|
115
|
+
// LME-7: Recall Governor — evolution filter, budget control, session dedup
|
|
116
|
+
const results = governResults(tierFiltered, request.governorSession, govCfg);
|
|
117
|
+
|
|
89
118
|
return {
|
|
90
119
|
mode: "resume+search",
|
|
91
120
|
resolvedScope,
|