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.
Files changed (166) hide show
  1. package/.dockerignore +11 -0
  2. package/CLAUDE.md +7 -3
  3. package/Dockerfile +20 -0
  4. package/README.md +202 -130
  5. package/README_CN.md +263 -190
  6. package/ROADMAP.md +128 -81
  7. package/SKILL.md +250 -0
  8. package/assets/dashboard.png +0 -0
  9. package/assets/knowledge-graph.png +0 -0
  10. package/assets/screenshots/ui-full.png +0 -0
  11. package/assets/screenshots/ui-overview.png +0 -0
  12. package/assets/screenshots/ui-search.png +0 -0
  13. package/assets/ui/app.js +227 -3
  14. package/assets/ui/index.html +81 -11
  15. package/assets/ui/styles.css +178 -2
  16. package/bin/recallnest.mjs +62 -0
  17. package/bun.lock +337 -0
  18. package/daily-recall.sh +23 -0
  19. package/daily-reflections/2026-04-02.md +35 -0
  20. package/daily-reflections/2026-04-03.md +37 -0
  21. package/daily-reflections/2026-04-04.md +37 -0
  22. package/daily-reflections/2026-04-05.md +35 -0
  23. package/daily-reflections/2026-04-06.md +36 -0
  24. package/daily-reflections/2026-04-07.md +35 -0
  25. package/daily-reflections/2026-04-08.md +35 -0
  26. package/daily-reflections/2026-04-12.md +35 -0
  27. package/data/alias-map.example.json +22 -0
  28. package/docs/plans/2026-04-01-recall-boost.md +100 -0
  29. package/docs/superpowers/plans/2026-04-11-constructive-retrieval-emotion.md +1195 -0
  30. package/docs/superpowers/specs/2026-04-11-constructive-retrieval-emotion-design.md +398 -0
  31. package/glama.json +6 -0
  32. package/integrations/setup-all.sh +31 -0
  33. package/package.json +10 -3
  34. package/plan-philosophy-upgrades.md +363 -0
  35. package/plan-product-upgrades.md +155 -0
  36. package/plan.md +140 -0
  37. package/research-memory-evolution.md +440 -0
  38. package/research-rl-agent-memory.md +330 -0
  39. package/scripts/backfill-anchors.ts +130 -0
  40. package/scripts/diagnose-vector-search.ts +132 -0
  41. package/scripts/migrate-language.ts +75 -0
  42. package/scripts/verify-emotion-decay.ts +148 -0
  43. package/src/__tests__/activity-counter.test.ts +96 -0
  44. package/src/__tests__/admission-control.test.ts +206 -0
  45. package/src/__tests__/anchor-generator.test.ts +67 -0
  46. package/src/__tests__/babel-integration.test.ts +139 -0
  47. package/src/__tests__/capture-engine.test.ts +5 -3
  48. package/src/__tests__/cascade-forget.test.ts +1 -1
  49. package/src/__tests__/cc8-cc1-essential-context.test.ts +298 -0
  50. package/src/__tests__/circuit-breaker.test.ts +99 -0
  51. package/src/__tests__/cluster-consolidation.test.ts +712 -0
  52. package/src/__tests__/confidence-metadata.test.ts +125 -0
  53. package/src/__tests__/confidence-tracker.test.ts +53 -0
  54. package/src/__tests__/consolidation-engine.test.ts +1 -1
  55. package/src/__tests__/constructive-retrieval.test.ts +509 -0
  56. package/src/__tests__/context-collapse-renderer.test.ts +214 -0
  57. package/src/__tests__/context-reconstructor.test.ts +65 -0
  58. package/src/__tests__/conversation-importer.test.ts +692 -0
  59. package/src/__tests__/data-checkup.test.ts +225 -0
  60. package/src/__tests__/decay-exemption.test.ts +84 -0
  61. package/src/__tests__/dedup-guard.test.ts +39 -0
  62. package/src/__tests__/distill-lock.test.ts +176 -0
  63. package/src/__tests__/dream-pipeline.test.ts +232 -0
  64. package/src/__tests__/emotion-decay.test.ts +46 -0
  65. package/src/__tests__/emotion-detector.test.ts +56 -0
  66. package/src/__tests__/emotion-phase1-integration.test.ts +153 -0
  67. package/src/__tests__/emotion-retrieval.test.ts +56 -0
  68. package/src/__tests__/ethics-layer.test.ts +476 -0
  69. package/src/__tests__/feature-flags-integration.test.ts +54 -0
  70. package/src/__tests__/frequency-tracker.test.ts +113 -0
  71. package/src/__tests__/graph-export.test.ts +489 -0
  72. package/src/__tests__/interference-detector.test.ts +203 -0
  73. package/src/__tests__/mcp-efficiency.test.ts +288 -0
  74. package/src/__tests__/memory-evolution-b2-b3-e1.test.ts +243 -0
  75. package/src/__tests__/memory-evolution.test.ts +271 -0
  76. package/src/__tests__/memory-lint.test.ts +271 -0
  77. package/src/__tests__/narrative-architecture.test.ts +456 -0
  78. package/src/__tests__/pending-review.test.ts +77 -0
  79. package/src/__tests__/post-retrieval-filter.test.ts +178 -0
  80. package/src/__tests__/prediction-engine.test.ts +349 -0
  81. package/src/__tests__/predictive-prospective-memory.test.ts +416 -0
  82. package/src/__tests__/preference-slots.test.ts +99 -0
  83. package/src/__tests__/query-expander-alias.test.ts +89 -0
  84. package/src/__tests__/recall-governor.test.ts +227 -0
  85. package/src/__tests__/resume-context.test.ts +156 -1
  86. package/src/__tests__/salience-scoring.test.ts +106 -0
  87. package/src/__tests__/session-distiller.test.ts +611 -0
  88. package/src/__tests__/skill-engine.test.ts +546 -0
  89. package/src/__tests__/skill-promotion.test.ts +349 -0
  90. package/src/__tests__/supersede-chain.test.ts +154 -0
  91. package/src/__tests__/temporal-validity.test.ts +160 -0
  92. package/src/__tests__/topic-tag.test.ts +113 -0
  93. package/src/__tests__/trusted-memory.test.ts +828 -0
  94. package/src/__tests__/write-verifier.test.ts +122 -0
  95. package/src/access-tracker.ts +3 -2
  96. package/src/activity-counter.ts +109 -0
  97. package/src/adaptive-retrieval.ts +6 -5
  98. package/src/admission-control.ts +163 -0
  99. package/src/anchor-generator.ts +80 -0
  100. package/src/api-server.ts +10 -0
  101. package/src/audit-log.ts +122 -0
  102. package/src/auto-gc.ts +98 -29
  103. package/src/auto-recall.ts +33 -4
  104. package/src/capture-engine.ts +372 -23
  105. package/src/cascade-forget.ts +3 -2
  106. package/src/cli.ts +107 -5
  107. package/src/compat.ts +22 -0
  108. package/src/confidence-tracker.ts +210 -47
  109. package/src/conflict-store.ts +12 -3
  110. package/src/consolidation-engine.ts +296 -8
  111. package/src/context-collapse-renderer.ts +222 -0
  112. package/src/context-composer-stable-selection.ts +5 -1
  113. package/src/context-composer.ts +239 -4
  114. package/src/context-reconstructor.ts +404 -0
  115. package/src/conversation-importer.ts +441 -0
  116. package/src/data-checkup.ts +232 -0
  117. package/src/decay-engine.ts +84 -0
  118. package/src/distill-lock.ts +122 -0
  119. package/src/doctor.ts +8 -10
  120. package/src/dream-pipeline.ts +227 -0
  121. package/src/emotion-detector.ts +90 -0
  122. package/src/eval.ts +3 -2
  123. package/src/forget-engine.ts +269 -0
  124. package/src/frequency-tracker.ts +175 -0
  125. package/src/graph-export.ts +644 -0
  126. package/src/ingest.ts +36 -6
  127. package/src/interference-detector.ts +187 -0
  128. package/src/kg-extractor.ts +15 -24
  129. package/src/kg-store.ts +2 -1
  130. package/src/language-hook.ts +67 -0
  131. package/src/llm-client.ts +289 -6
  132. package/src/mcp-server.ts +707 -80
  133. package/src/memory-assets.ts +22 -5
  134. package/src/memory-evolution.ts +365 -0
  135. package/src/memory-lint.ts +429 -0
  136. package/src/memory-output.ts +69 -0
  137. package/src/memory-schema.ts +74 -2
  138. package/src/narrative-schema.ts +79 -0
  139. package/src/narrative-tagger.ts +164 -0
  140. package/src/pii-detector.ts +98 -0
  141. package/src/post-retrieval-filter.ts +76 -0
  142. package/src/prediction-engine.ts +253 -0
  143. package/src/preference-slots.ts +65 -2
  144. package/src/prospective-memory.ts +215 -0
  145. package/src/query-expander.ts +67 -0
  146. package/src/recall-governor.ts +183 -0
  147. package/src/retention-policy.ts +90 -0
  148. package/src/retriever.ts +828 -55
  149. package/src/rif.ts +34 -8
  150. package/src/runtime-config.ts +23 -8
  151. package/src/scope-inventory.ts +4 -3
  152. package/src/scope-policy.ts +4 -0
  153. package/src/session-distiller.ts +307 -0
  154. package/src/session-output.ts +40 -0
  155. package/src/session-schema.ts +37 -0
  156. package/src/session-store.ts +7 -2
  157. package/src/skill-engine.ts +208 -0
  158. package/src/skill-promotion.ts +293 -0
  159. package/src/skill-schema.ts +39 -0
  160. package/src/store.ts +96 -10
  161. package/src/topic-tag.ts +66 -0
  162. package/src/tracker.ts +3 -1
  163. package/src/ui-server.ts +80 -8
  164. package/src/workflow-observation-scope-review.ts +2 -1
  165. package/src/workflow-observation-store.ts +2 -1
  166. 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
+ });
@@ -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[]): void {
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
- // Very short = skip
48
- if (trimmed.length < 4) return true;
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
- // CJK-aware minimum length: Chinese chars carry more semantic density
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
 
@@ -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
+ }