recallnest 1.4.1 → 2.2.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/.dockerignore +11 -0
- package/CLAUDE.md +7 -3
- package/Dockerfile +20 -0
- package/README.md +202 -130
- package/README_CN.md +263 -190
- package/ROADMAP.md +128 -81
- package/SKILL.md +250 -0
- package/assets/dashboard.png +0 -0
- package/assets/knowledge-graph.png +0 -0
- package/assets/screenshots/ui-full.png +0 -0
- package/assets/screenshots/ui-overview.png +0 -0
- package/assets/screenshots/ui-search.png +0 -0
- package/assets/ui/app.js +227 -3
- package/assets/ui/index.html +81 -11
- package/assets/ui/styles.css +178 -2
- package/bin/recallnest.mjs +62 -0
- package/bun.lock +337 -0
- package/daily-recall.sh +23 -0
- package/daily-reflections/2026-04-02.md +35 -0
- package/daily-reflections/2026-04-03.md +37 -0
- package/daily-reflections/2026-04-04.md +37 -0
- package/daily-reflections/2026-04-05.md +35 -0
- package/daily-reflections/2026-04-06.md +36 -0
- package/daily-reflections/2026-04-07.md +35 -0
- package/daily-reflections/2026-04-08.md +35 -0
- package/daily-reflections/2026-04-12.md +35 -0
- package/data/alias-map.example.json +22 -0
- package/docs/plans/2026-04-01-recall-boost.md +100 -0
- package/docs/superpowers/plans/2026-04-11-constructive-retrieval-emotion.md +1195 -0
- package/docs/superpowers/specs/2026-04-11-constructive-retrieval-emotion-design.md +398 -0
- package/glama.json +6 -0
- package/integrations/setup-all.sh +31 -0
- package/package.json +10 -3
- package/plan-philosophy-upgrades.md +363 -0
- package/plan-product-upgrades.md +155 -0
- package/plan.md +140 -0
- package/research-memory-evolution.md +440 -0
- package/research-rl-agent-memory.md +330 -0
- package/scripts/backfill-anchors.ts +130 -0
- package/scripts/diagnose-vector-search.ts +132 -0
- package/scripts/migrate-language.ts +75 -0
- package/scripts/verify-emotion-decay.ts +148 -0
- package/src/__tests__/activity-counter.test.ts +96 -0
- package/src/__tests__/admission-control.test.ts +206 -0
- package/src/__tests__/anchor-generator.test.ts +67 -0
- package/src/__tests__/babel-integration.test.ts +139 -0
- package/src/__tests__/capture-engine.test.ts +5 -3
- package/src/__tests__/cascade-forget.test.ts +1 -1
- package/src/__tests__/cc8-cc1-essential-context.test.ts +298 -0
- package/src/__tests__/circuit-breaker.test.ts +99 -0
- package/src/__tests__/cluster-consolidation.test.ts +712 -0
- package/src/__tests__/confidence-metadata.test.ts +125 -0
- package/src/__tests__/confidence-tracker.test.ts +53 -0
- package/src/__tests__/consolidation-engine.test.ts +1 -1
- package/src/__tests__/constructive-retrieval.test.ts +509 -0
- package/src/__tests__/context-collapse-renderer.test.ts +214 -0
- package/src/__tests__/context-reconstructor.test.ts +65 -0
- package/src/__tests__/conversation-importer.test.ts +692 -0
- package/src/__tests__/data-checkup.test.ts +225 -0
- package/src/__tests__/decay-exemption.test.ts +84 -0
- package/src/__tests__/dedup-guard.test.ts +39 -0
- package/src/__tests__/distill-lock.test.ts +176 -0
- package/src/__tests__/dream-pipeline.test.ts +232 -0
- package/src/__tests__/emotion-decay.test.ts +46 -0
- package/src/__tests__/emotion-detector.test.ts +56 -0
- package/src/__tests__/emotion-phase1-integration.test.ts +153 -0
- package/src/__tests__/emotion-retrieval.test.ts +56 -0
- package/src/__tests__/ethics-layer.test.ts +476 -0
- package/src/__tests__/feature-flags-integration.test.ts +54 -0
- package/src/__tests__/frequency-tracker.test.ts +113 -0
- package/src/__tests__/graph-export.test.ts +489 -0
- package/src/__tests__/interference-detector.test.ts +203 -0
- package/src/__tests__/mcp-efficiency.test.ts +288 -0
- package/src/__tests__/memory-evolution-b2-b3-e1.test.ts +243 -0
- package/src/__tests__/memory-evolution.test.ts +271 -0
- package/src/__tests__/memory-lint.test.ts +271 -0
- package/src/__tests__/narrative-architecture.test.ts +456 -0
- package/src/__tests__/pending-review.test.ts +77 -0
- package/src/__tests__/post-retrieval-filter.test.ts +178 -0
- package/src/__tests__/prediction-engine.test.ts +349 -0
- package/src/__tests__/predictive-prospective-memory.test.ts +416 -0
- package/src/__tests__/preference-slots.test.ts +99 -0
- package/src/__tests__/query-expander-alias.test.ts +89 -0
- package/src/__tests__/recall-governor.test.ts +227 -0
- package/src/__tests__/resume-context.test.ts +156 -1
- package/src/__tests__/salience-scoring.test.ts +106 -0
- package/src/__tests__/session-distiller.test.ts +611 -0
- package/src/__tests__/skill-engine.test.ts +546 -0
- package/src/__tests__/skill-promotion.test.ts +349 -0
- package/src/__tests__/supersede-chain.test.ts +154 -0
- package/src/__tests__/temporal-validity.test.ts +160 -0
- package/src/__tests__/topic-tag.test.ts +113 -0
- package/src/__tests__/trusted-memory.test.ts +828 -0
- package/src/__tests__/write-verifier.test.ts +122 -0
- 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
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { describe, it, expect } from "bun:test";
|
|
2
|
+
import { verifyWrite } from "../write-verifier.js";
|
|
3
|
+
import type { MemoryEntry } from "../store.js";
|
|
4
|
+
|
|
5
|
+
function mockStore(entry: MemoryEntry | null) {
|
|
6
|
+
return {
|
|
7
|
+
async get(_id: string) {
|
|
8
|
+
return entry;
|
|
9
|
+
},
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function makeEntry(overrides: Partial<MemoryEntry> = {}): MemoryEntry {
|
|
14
|
+
return {
|
|
15
|
+
id: "test-id",
|
|
16
|
+
text: "User prefers dark mode",
|
|
17
|
+
vector: [0.1, 0.2, 0.3],
|
|
18
|
+
category: "preferences",
|
|
19
|
+
importance: 0.75,
|
|
20
|
+
timestamp: Date.now(),
|
|
21
|
+
metadata: JSON.stringify({ scope: "project:test", importance: 0.75 }),
|
|
22
|
+
...overrides,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
describe("HP-2: write-verifier", () => {
|
|
27
|
+
it("passes for a complete entry", async () => {
|
|
28
|
+
const result = await verifyWrite(mockStore(makeEntry()), "test-id");
|
|
29
|
+
expect(result.ok).toBe(true);
|
|
30
|
+
expect(result.issues).toEqual([]);
|
|
31
|
+
expect(result.durationMs).toBeGreaterThanOrEqual(0);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("detects missing entry", async () => {
|
|
35
|
+
const result = await verifyWrite(mockStore(null), "missing-id");
|
|
36
|
+
expect(result.ok).toBe(false);
|
|
37
|
+
expect(result.issues).toContain("missing_entry");
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("detects empty vector", async () => {
|
|
41
|
+
const result = await verifyWrite(
|
|
42
|
+
mockStore(makeEntry({ vector: [] })),
|
|
43
|
+
"test-id",
|
|
44
|
+
);
|
|
45
|
+
expect(result.ok).toBe(false);
|
|
46
|
+
expect(result.issues).toContain("missing_vector");
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("detects empty text", async () => {
|
|
50
|
+
const result = await verifyWrite(
|
|
51
|
+
mockStore(makeEntry({ text: " " })),
|
|
52
|
+
"test-id",
|
|
53
|
+
);
|
|
54
|
+
expect(result.ok).toBe(false);
|
|
55
|
+
expect(result.issues).toContain("empty_text");
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("detects missing scope in metadata", async () => {
|
|
59
|
+
const result = await verifyWrite(
|
|
60
|
+
mockStore(makeEntry({ metadata: JSON.stringify({ importance: 0.7 }) })),
|
|
61
|
+
"test-id",
|
|
62
|
+
);
|
|
63
|
+
expect(result.ok).toBe(false);
|
|
64
|
+
expect(result.issues).toContain("missing_scope");
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("detects missing importance in metadata", async () => {
|
|
68
|
+
const result = await verifyWrite(
|
|
69
|
+
mockStore(makeEntry({ metadata: JSON.stringify({ scope: "test" }) })),
|
|
70
|
+
"test-id",
|
|
71
|
+
);
|
|
72
|
+
expect(result.ok).toBe(false);
|
|
73
|
+
expect(result.issues).toContain("missing_importance");
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("detects corrupt metadata", async () => {
|
|
77
|
+
const result = await verifyWrite(
|
|
78
|
+
mockStore(makeEntry({ metadata: "not-json" })),
|
|
79
|
+
"test-id",
|
|
80
|
+
);
|
|
81
|
+
expect(result.ok).toBe(false);
|
|
82
|
+
expect(result.issues).toContain("missing_scope");
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("respects enabled=false config", async () => {
|
|
86
|
+
const result = await verifyWrite(mockStore(null), "any", { enabled: false });
|
|
87
|
+
expect(result.ok).toBe(true);
|
|
88
|
+
expect(result.durationMs).toBe(0);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("handles store.get timeout gracefully", async () => {
|
|
92
|
+
const slowStore = {
|
|
93
|
+
async get() {
|
|
94
|
+
await new Promise(r => setTimeout(r, 5000));
|
|
95
|
+
return null;
|
|
96
|
+
},
|
|
97
|
+
};
|
|
98
|
+
const result = await verifyWrite(slowStore, "slow-id", { timeoutMs: 50 });
|
|
99
|
+
expect(result.ok).toBe(false);
|
|
100
|
+
expect(result.issues).toContain("missing_entry");
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("handles store.get throwing", async () => {
|
|
104
|
+
const errStore = {
|
|
105
|
+
async get() {
|
|
106
|
+
throw new Error("db error");
|
|
107
|
+
},
|
|
108
|
+
};
|
|
109
|
+
const result = await verifyWrite(errStore, "err-id");
|
|
110
|
+
expect(result.ok).toBe(false);
|
|
111
|
+
expect(result.issues).toContain("missing_entry");
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("reports multiple issues at once", async () => {
|
|
115
|
+
const result = await verifyWrite(
|
|
116
|
+
mockStore(makeEntry({ text: "", vector: [], metadata: "{}" })),
|
|
117
|
+
"test-id",
|
|
118
|
+
);
|
|
119
|
+
expect(result.ok).toBe(false);
|
|
120
|
+
expect(result.issues.length).toBeGreaterThanOrEqual(3);
|
|
121
|
+
});
|
|
122
|
+
});
|
package/src/access-tracker.ts
CHANGED
|
@@ -95,10 +95,11 @@ export class AccessTracker {
|
|
|
95
95
|
* Accumulates deltas in memory; flushed to store after debounce.
|
|
96
96
|
*
|
|
97
97
|
* @param ids Memory entry IDs to record access for
|
|
98
|
-
* @param scores Optional parallel array of similarity scores for novelty gating.
|
|
98
|
+
* @param scores Optional parallel array of raw similarity scores for novelty gating.
|
|
99
|
+
* Entries with an undefined score skip novelty gating.
|
|
99
100
|
* When provided, only entries passing shouldReinforce() are recorded.
|
|
100
101
|
*/
|
|
101
|
-
recordAccess(ids: string[], scores?: number
|
|
102
|
+
recordAccess(ids: string[], scores?: Array<number | undefined>): void {
|
|
102
103
|
if (ids.length === 0) return;
|
|
103
104
|
|
|
104
105
|
const now = Date.now();
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HP-3: Activity-driven distill frequency.
|
|
3
|
+
* Tracks write operations since last distill, exposes tier-based thresholds.
|
|
4
|
+
* Complements CC-6 distill-lock: HP-3 decides "when to trigger", CC-6 decides "whether to allow".
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
existsSync,
|
|
9
|
+
readFileSync,
|
|
10
|
+
writeFileSync,
|
|
11
|
+
mkdirSync,
|
|
12
|
+
} from "node:fs";
|
|
13
|
+
import { join, dirname } from "node:path";
|
|
14
|
+
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
// Config
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
|
|
19
|
+
export interface ActivityCounterConfig {
|
|
20
|
+
/** Path to the stats file (default: data/activity-stats.json) */
|
|
21
|
+
statsPath: string;
|
|
22
|
+
/** Writes needed for light scoring/tagging pass */
|
|
23
|
+
lightThreshold: number;
|
|
24
|
+
/** Writes needed for standard distill */
|
|
25
|
+
standardThreshold: number;
|
|
26
|
+
/** Writes needed for deep checkpoint */
|
|
27
|
+
deepThreshold: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export const DEFAULT_ACTIVITY_CONFIG: ActivityCounterConfig = {
|
|
31
|
+
statsPath: join(
|
|
32
|
+
process.env.RECALLNEST_DATA_DIR || "data",
|
|
33
|
+
"activity-stats.json",
|
|
34
|
+
),
|
|
35
|
+
lightThreshold: 15,
|
|
36
|
+
standardThreshold: 50,
|
|
37
|
+
deepThreshold: 200,
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export type DistillTier = "none" | "light" | "standard" | "deep";
|
|
41
|
+
|
|
42
|
+
// ---------------------------------------------------------------------------
|
|
43
|
+
// Internal
|
|
44
|
+
// ---------------------------------------------------------------------------
|
|
45
|
+
|
|
46
|
+
interface ActivityStats {
|
|
47
|
+
writesSinceLastDistill: number;
|
|
48
|
+
lastResetAt: number;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function resolveConfig(
|
|
52
|
+
cfg?: Partial<ActivityCounterConfig>,
|
|
53
|
+
): ActivityCounterConfig {
|
|
54
|
+
return { ...DEFAULT_ACTIVITY_CONFIG, ...cfg };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function readStats(statsPath: string): ActivityStats {
|
|
58
|
+
if (!existsSync(statsPath)) {
|
|
59
|
+
return { writesSinceLastDistill: 0, lastResetAt: Date.now() };
|
|
60
|
+
}
|
|
61
|
+
try {
|
|
62
|
+
return JSON.parse(readFileSync(statsPath, "utf-8"));
|
|
63
|
+
} catch {
|
|
64
|
+
return { writesSinceLastDistill: 0, lastResetAt: Date.now() };
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function writeStats(statsPath: string, stats: ActivityStats): void {
|
|
69
|
+
mkdirSync(dirname(statsPath), { recursive: true });
|
|
70
|
+
writeFileSync(statsPath, JSON.stringify(stats, null, 2), "utf-8");
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ---------------------------------------------------------------------------
|
|
74
|
+
// Public API
|
|
75
|
+
// ---------------------------------------------------------------------------
|
|
76
|
+
|
|
77
|
+
/** Increment write counter by n (default 1). Returns new count. */
|
|
78
|
+
export function incrementWriteCount(
|
|
79
|
+
n = 1,
|
|
80
|
+
cfg?: Partial<ActivityCounterConfig>,
|
|
81
|
+
): number {
|
|
82
|
+
const { statsPath } = resolveConfig(cfg);
|
|
83
|
+
const stats = readStats(statsPath);
|
|
84
|
+
stats.writesSinceLastDistill += n;
|
|
85
|
+
writeStats(statsPath, stats);
|
|
86
|
+
return stats.writesSinceLastDistill;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Read current write count without modifying. */
|
|
90
|
+
export function getWriteCount(cfg?: Partial<ActivityCounterConfig>): number {
|
|
91
|
+
const { statsPath } = resolveConfig(cfg);
|
|
92
|
+
return readStats(statsPath).writesSinceLastDistill;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Reset counter after successful distill. */
|
|
96
|
+
export function resetWriteCount(cfg?: Partial<ActivityCounterConfig>): void {
|
|
97
|
+
const { statsPath } = resolveConfig(cfg);
|
|
98
|
+
writeStats(statsPath, { writesSinceLastDistill: 0, lastResetAt: Date.now() });
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Determine which distill tier the current write count warrants. */
|
|
102
|
+
export function getDistillTier(cfg?: Partial<ActivityCounterConfig>): DistillTier {
|
|
103
|
+
const config = resolveConfig(cfg);
|
|
104
|
+
const count = getWriteCount(cfg);
|
|
105
|
+
if (count >= config.deepThreshold) return "deep";
|
|
106
|
+
if (count >= config.standardThreshold) return "standard";
|
|
107
|
+
if (count >= config.lightThreshold) return "light";
|
|
108
|
+
return "none";
|
|
109
|
+
}
|
|
@@ -44,15 +44,16 @@ export function shouldSkipRetrieval(query: string, minLength?: number): boolean
|
|
|
44
44
|
// Force retrieve if query has memory-related intent (BEFORE length check)
|
|
45
45
|
if (FORCE_RETRIEVE_PATTERNS.some(p => p.test(trimmed))) return false;
|
|
46
46
|
|
|
47
|
-
//
|
|
48
|
-
|
|
47
|
+
// CJK-aware: Chinese chars carry much more semantic density than ASCII.
|
|
48
|
+
// 2 Chinese chars ("轮巡") is a meaningful query equivalent to "daily patrol".
|
|
49
|
+
const hasCJK = /[\u4e00-\u9fff\u3040-\u309f\u30a0-\u30ff]/.test(trimmed);
|
|
50
|
+
const absoluteMin = hasCJK ? 2 : 4;
|
|
51
|
+
if (trimmed.length < absoluteMin) return true;
|
|
49
52
|
|
|
50
53
|
// Pattern-based skip
|
|
51
54
|
if (SKIP_PATTERNS.some(p => p.test(trimmed))) return true;
|
|
52
55
|
|
|
53
|
-
|
|
54
|
-
const hasCJK = /[\u4e00-\u9fff\u3040-\u309f\u30a0-\u30ff]/.test(trimmed);
|
|
55
|
-
const effectiveMinLength = minLength ?? (hasCJK ? 6 : 15);
|
|
56
|
+
const effectiveMinLength = minLength ?? (hasCJK ? 2 : 15);
|
|
56
57
|
|
|
57
58
|
// Short non-question messages are skipped; questions (? ?) are always worth checking
|
|
58
59
|
if (trimmed.length < effectiveMinLength &&
|
|
@@ -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
|
+
}
|