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,148 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* Phase 1 Manual Verification: Emotional Valence in Decay
|
|
4
|
+
*
|
|
5
|
+
* Demonstrates:
|
|
6
|
+
* 1. detectEmotion → salience + source fields
|
|
7
|
+
* 2. adjustHalfLifeForEmotion → emotional memories get longer half-life
|
|
8
|
+
* 3. computeDecayScore → emotion-aware weights
|
|
9
|
+
* 4. End-to-end: emotional memory scores higher than neutral at same age
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { detectEmotion } from "../src/emotion-detector.js";
|
|
13
|
+
import { adjustHalfLifeForEmotion, computeArousalBoost, weibullDecay, resolveTier } from "../src/decay-engine.js";
|
|
14
|
+
import { computeDecayScore, type EvolutionMetadata } from "../src/memory-evolution.js";
|
|
15
|
+
import { parseEmotion } from "../src/memory-schema.js";
|
|
16
|
+
|
|
17
|
+
// Force emotion flag on for verification
|
|
18
|
+
process.env.RECALLNEST_EMOTION_SCORING = "true";
|
|
19
|
+
|
|
20
|
+
const DIVIDER = "─".repeat(60);
|
|
21
|
+
|
|
22
|
+
// ── 1. detectEmotion: salience + source ──────────────────────
|
|
23
|
+
|
|
24
|
+
console.log("\n" + DIVIDER);
|
|
25
|
+
console.log("1️⃣ detectEmotion: salience + source");
|
|
26
|
+
console.log(DIVIDER);
|
|
27
|
+
|
|
28
|
+
const samples = [
|
|
29
|
+
{ label: "愤怒调试", text: "CRITICAL BUG! 这个问题搞了三天还是崩溃,太frustrating了!" },
|
|
30
|
+
{ label: "成功修复", text: "Finally fixed it! Everything works perfectly, all tests pass!" },
|
|
31
|
+
{ label: "中性日志", text: "Updated the config file and ran the migration script." },
|
|
32
|
+
{ label: "中文负面", text: "这个方案不好用,不开心,又失败了" },
|
|
33
|
+
];
|
|
34
|
+
|
|
35
|
+
for (const s of samples) {
|
|
36
|
+
const e = detectEmotion(s.text);
|
|
37
|
+
console.log(`\n [${s.label}]`);
|
|
38
|
+
console.log(` text: "${s.text.slice(0, 50)}..."`);
|
|
39
|
+
console.log(` valence: ${e.valence.toFixed(2)} arousal: ${e.arousal.toFixed(2)} label: ${e.label}`);
|
|
40
|
+
console.log(` salience: ${e.salience?.toFixed(3)} source: ${e.source}`);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// ── 2. Half-life adjustment ──────────────────────────────────
|
|
44
|
+
|
|
45
|
+
console.log("\n" + DIVIDER);
|
|
46
|
+
console.log("2️⃣ adjustHalfLifeForEmotion: half-life extension");
|
|
47
|
+
console.log(DIVIDER);
|
|
48
|
+
|
|
49
|
+
const baseHL = 60; // 60 days
|
|
50
|
+
for (const s of samples) {
|
|
51
|
+
const e = detectEmotion(s.text);
|
|
52
|
+
const adjustedHL = adjustHalfLifeForEmotion(baseHL, e);
|
|
53
|
+
const boost = computeArousalBoost(e);
|
|
54
|
+
const delta = ((adjustedHL / baseHL - 1) * 100).toFixed(1);
|
|
55
|
+
console.log(`\n [${s.label}]`);
|
|
56
|
+
console.log(` base HL: ${baseHL}d → adjusted: ${adjustedHL.toFixed(1)}d (+${delta}%)`);
|
|
57
|
+
console.log(` arousal boost: ${boost.toFixed(3)}x`);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// ── 3. Weibull decay comparison at 30d, 60d, 90d ────────────
|
|
61
|
+
|
|
62
|
+
console.log("\n" + DIVIDER);
|
|
63
|
+
console.log("3️⃣ Weibull decay: emotional vs neutral at different ages");
|
|
64
|
+
console.log(DIVIDER);
|
|
65
|
+
|
|
66
|
+
const emotionalText = "CRITICAL BUG! 这个问题搞了三天还是崩溃,太frustrating了!";
|
|
67
|
+
const neutralText = "Updated the config file and ran the migration script.";
|
|
68
|
+
const emotionalEmo = detectEmotion(emotionalText);
|
|
69
|
+
const neutralEmo = detectEmotion(neutralText);
|
|
70
|
+
const emotionalHL = adjustHalfLifeForEmotion(baseHL, emotionalEmo);
|
|
71
|
+
const neutralHL = adjustHalfLifeForEmotion(baseHL, neutralEmo);
|
|
72
|
+
const emotionalBoost = computeArousalBoost(emotionalEmo);
|
|
73
|
+
const neutralBoost = computeArousalBoost(neutralEmo);
|
|
74
|
+
|
|
75
|
+
console.log(`\n ${"Age".padEnd(8)} ${"Emotional".padEnd(14)} ${"Neutral".padEnd(14)} ${"Δ advantage".padEnd(14)}`);
|
|
76
|
+
for (const ageDays of [7, 30, 60, 90, 120]) {
|
|
77
|
+
const emoDecay = weibullDecay(ageDays, emotionalHL, "working") * emotionalBoost;
|
|
78
|
+
const neuDecay = weibullDecay(ageDays, neutralHL, "working") * neutralBoost;
|
|
79
|
+
const advantage = ((emoDecay / neuDecay - 1) * 100).toFixed(1);
|
|
80
|
+
console.log(` ${(ageDays + "d").padEnd(8)} ${emoDecay.toFixed(4).padEnd(14)} ${neuDecay.toFixed(4).padEnd(14)} +${advantage}%`);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// ── 4. computeDecayScore: full formula comparison ────────────
|
|
84
|
+
|
|
85
|
+
console.log("\n" + DIVIDER);
|
|
86
|
+
console.log("4️⃣ computeDecayScore: emotion-aware weights (0.15/0.25/0.45/0.15)");
|
|
87
|
+
console.log(DIVIDER);
|
|
88
|
+
|
|
89
|
+
const now = Date.now();
|
|
90
|
+
const makeEvo = (ageDays: number, accessCount: number): EvolutionMetadata => ({
|
|
91
|
+
status: "active",
|
|
92
|
+
version: 1,
|
|
93
|
+
accessCount,
|
|
94
|
+
lastAccessedAt: now - 7 * 86_400_000,
|
|
95
|
+
supersededBy: null,
|
|
96
|
+
supersedes: null,
|
|
97
|
+
evolutionNote: null,
|
|
98
|
+
consolidatedInto: null,
|
|
99
|
+
contributedToPattern: null,
|
|
100
|
+
sourceMemories: [],
|
|
101
|
+
validFrom: now - ageDays * 86_400_000,
|
|
102
|
+
validUntil: null,
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
const scenarios = [
|
|
106
|
+
{ label: "新记忆(7d), 3次访问, importance=0.7", age: 7, access: 3, importance: 0.7 },
|
|
107
|
+
{ label: "旧记忆(60d), 3次访问, importance=0.7", age: 60, access: 3, importance: 0.7 },
|
|
108
|
+
{ label: "很旧(120d), 低访问, importance=0.5", age: 120, access: 1, importance: 0.5 },
|
|
109
|
+
];
|
|
110
|
+
|
|
111
|
+
for (const sc of scenarios) {
|
|
112
|
+
const evo = makeEvo(sc.age, sc.access);
|
|
113
|
+
const emotionalMeta = JSON.stringify({
|
|
114
|
+
emotion: detectEmotion(emotionalText),
|
|
115
|
+
});
|
|
116
|
+
const neutralMeta = JSON.stringify({
|
|
117
|
+
emotion: detectEmotion(neutralText),
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
const emoScore = computeDecayScore(evo, sc.importance, now, emotionalMeta);
|
|
121
|
+
const neuScore = computeDecayScore(evo, sc.importance, now, neutralMeta);
|
|
122
|
+
const noMetaScore = computeDecayScore(evo, sc.importance, now); // backward compat
|
|
123
|
+
|
|
124
|
+
console.log(`\n [${sc.label}]`);
|
|
125
|
+
console.log(` emotional: ${emoScore.toFixed(4)}`);
|
|
126
|
+
console.log(` neutral: ${neuScore.toFixed(4)}`);
|
|
127
|
+
console.log(` no-meta: ${noMetaScore.toFixed(4)} (backward compat, base weights)`);
|
|
128
|
+
console.log(` Δ emotion: +${((emoScore / neuScore - 1) * 100).toFixed(1)}% vs neutral`);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// ── 5. parseEmotion backward compat ──────────────────────────
|
|
132
|
+
|
|
133
|
+
console.log("\n" + DIVIDER);
|
|
134
|
+
console.log("5️⃣ parseEmotion: backward compatibility");
|
|
135
|
+
console.log(DIVIDER);
|
|
136
|
+
|
|
137
|
+
const oldMeta = JSON.stringify({ emotion: { valence: 0.5, arousal: 0.3, label: "positive" } }); // no salience/source
|
|
138
|
+
const newMeta = JSON.stringify({ emotion: { valence: 0.5, arousal: 0.3, label: "positive", salience: 0.4, source: "keyword" } });
|
|
139
|
+
const noEmoMeta = JSON.stringify({ tier: "working" });
|
|
140
|
+
|
|
141
|
+
console.log(`\n old format (no salience): ${JSON.stringify(parseEmotion(oldMeta))}`);
|
|
142
|
+
console.log(` new format (with salience): ${JSON.stringify(parseEmotion(newMeta))}`);
|
|
143
|
+
console.log(` no emotion field: ${parseEmotion(noEmoMeta)}`);
|
|
144
|
+
console.log(` undefined: ${parseEmotion(undefined)}`);
|
|
145
|
+
|
|
146
|
+
console.log("\n" + DIVIDER);
|
|
147
|
+
console.log("✅ Phase 1 Verification Complete");
|
|
148
|
+
console.log(DIVIDER + "\n");
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from "bun:test";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { mkdirSync, rmSync, existsSync } from "node:fs";
|
|
4
|
+
import {
|
|
5
|
+
incrementWriteCount,
|
|
6
|
+
getWriteCount,
|
|
7
|
+
resetWriteCount,
|
|
8
|
+
getDistillTier,
|
|
9
|
+
type ActivityCounterConfig,
|
|
10
|
+
} from "../activity-counter.js";
|
|
11
|
+
|
|
12
|
+
const TMP_DIR = join(import.meta.dir, "../../.tmp-activity-test");
|
|
13
|
+
const testConfig: Partial<ActivityCounterConfig> = {
|
|
14
|
+
statsPath: join(TMP_DIR, "activity-stats.json"),
|
|
15
|
+
lightThreshold: 3,
|
|
16
|
+
standardThreshold: 10,
|
|
17
|
+
deepThreshold: 20,
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
describe("activity-counter (HP-3)", () => {
|
|
21
|
+
beforeEach(() => {
|
|
22
|
+
mkdirSync(TMP_DIR, { recursive: true });
|
|
23
|
+
// Ensure clean state
|
|
24
|
+
const p = testConfig.statsPath!;
|
|
25
|
+
if (existsSync(p)) rmSync(p);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
afterEach(() => {
|
|
29
|
+
rmSync(TMP_DIR, { recursive: true, force: true });
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
describe("incrementWriteCount", () => {
|
|
33
|
+
it("starts at 0 and increments by 1", () => {
|
|
34
|
+
expect(getWriteCount(testConfig)).toBe(0);
|
|
35
|
+
expect(incrementWriteCount(1, testConfig)).toBe(1);
|
|
36
|
+
expect(incrementWriteCount(1, testConfig)).toBe(2);
|
|
37
|
+
expect(getWriteCount(testConfig)).toBe(2);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("increments by arbitrary n", () => {
|
|
41
|
+
incrementWriteCount(5, testConfig);
|
|
42
|
+
expect(getWriteCount(testConfig)).toBe(5);
|
|
43
|
+
incrementWriteCount(3, testConfig);
|
|
44
|
+
expect(getWriteCount(testConfig)).toBe(8);
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
describe("resetWriteCount", () => {
|
|
49
|
+
it("resets count to 0", () => {
|
|
50
|
+
incrementWriteCount(7, testConfig);
|
|
51
|
+
expect(getWriteCount(testConfig)).toBe(7);
|
|
52
|
+
resetWriteCount(testConfig);
|
|
53
|
+
expect(getWriteCount(testConfig)).toBe(0);
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
describe("getDistillTier", () => {
|
|
58
|
+
it("returns 'none' when below light threshold", () => {
|
|
59
|
+
incrementWriteCount(2, testConfig);
|
|
60
|
+
expect(getDistillTier(testConfig)).toBe("none");
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("returns 'light' at light threshold", () => {
|
|
64
|
+
incrementWriteCount(3, testConfig);
|
|
65
|
+
expect(getDistillTier(testConfig)).toBe("light");
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("returns 'standard' at standard threshold", () => {
|
|
69
|
+
incrementWriteCount(10, testConfig);
|
|
70
|
+
expect(getDistillTier(testConfig)).toBe("standard");
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("returns 'deep' at deep threshold", () => {
|
|
74
|
+
incrementWriteCount(20, testConfig);
|
|
75
|
+
expect(getDistillTier(testConfig)).toBe("deep");
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("returns 'deep' well above deep threshold", () => {
|
|
79
|
+
incrementWriteCount(100, testConfig);
|
|
80
|
+
expect(getDistillTier(testConfig)).toBe("deep");
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
describe("resilience", () => {
|
|
85
|
+
it("handles missing stats file gracefully", () => {
|
|
86
|
+
expect(getWriteCount(testConfig)).toBe(0);
|
|
87
|
+
expect(getDistillTier(testConfig)).toBe("none");
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("handles corrupt stats file gracefully", () => {
|
|
91
|
+
const { writeFileSync } = require("node:fs");
|
|
92
|
+
writeFileSync(testConfig.statsPath!, "not-json{{{");
|
|
93
|
+
expect(getWriteCount(testConfig)).toBe(0);
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
});
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
checkAdmission,
|
|
5
|
+
resolveAdmissionConfig,
|
|
6
|
+
ScopeRateLimiter,
|
|
7
|
+
} from "../admission-control.js";
|
|
8
|
+
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
// resolveAdmissionConfig
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
|
|
13
|
+
describe("resolveAdmissionConfig", () => {
|
|
14
|
+
it("returns defaults when no overrides", () => {
|
|
15
|
+
const cfg = resolveAdmissionConfig();
|
|
16
|
+
expect(cfg.minTextLength).toBe(10);
|
|
17
|
+
expect(cfg.noiseFilterEnabled).toBe(true);
|
|
18
|
+
expect(cfg.minImportance).toBe(0.2);
|
|
19
|
+
expect(cfg.maxWritesPerScope).toBe(50);
|
|
20
|
+
expect(cfg.rateLimitWindowMs).toBe(60_000);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("merges partial overrides", () => {
|
|
24
|
+
const cfg = resolveAdmissionConfig({ minImportance: 0.5 });
|
|
25
|
+
expect(cfg.minImportance).toBe(0.5);
|
|
26
|
+
expect(cfg.minTextLength).toBe(10); // unchanged
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
// ---------------------------------------------------------------------------
|
|
31
|
+
// checkAdmission — text length
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
|
|
34
|
+
describe("checkAdmission — text length", () => {
|
|
35
|
+
it("rejects text shorter than minTextLength", () => {
|
|
36
|
+
const result = checkAdmission("short", 0.8, "project:test");
|
|
37
|
+
expect(result.verdict).toBe("rejected");
|
|
38
|
+
expect(result.reason).toBe("text_too_short");
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("rejects empty text", () => {
|
|
42
|
+
const result = checkAdmission("", 0.8, "project:test");
|
|
43
|
+
expect(result.verdict).toBe("rejected");
|
|
44
|
+
expect(result.reason).toBe("text_too_short");
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("rejects whitespace-only text", () => {
|
|
48
|
+
const result = checkAdmission(" ", 0.8, "project:test");
|
|
49
|
+
expect(result.verdict).toBe("rejected");
|
|
50
|
+
expect(result.reason).toBe("text_too_short");
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("accepts text at exactly minTextLength", () => {
|
|
54
|
+
const result = checkAdmission("abcdefghij", 0.8, "project:test");
|
|
55
|
+
expect(result.verdict).toBe("accepted");
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("respects custom minTextLength", () => {
|
|
59
|
+
const result = checkAdmission("hello world", 0.8, "project:test", undefined, { minTextLength: 20 });
|
|
60
|
+
expect(result.verdict).toBe("rejected");
|
|
61
|
+
expect(result.reason).toBe("text_too_short");
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
// ---------------------------------------------------------------------------
|
|
66
|
+
// checkAdmission — noise filter
|
|
67
|
+
// ---------------------------------------------------------------------------
|
|
68
|
+
|
|
69
|
+
describe("checkAdmission — noise filter", () => {
|
|
70
|
+
it("rejects denial patterns", () => {
|
|
71
|
+
const result = checkAdmission("I don't have any information about that topic", 0.8, "project:test");
|
|
72
|
+
expect(result.verdict).toBe("rejected");
|
|
73
|
+
expect(result.reason).toBe("noise_detected");
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("rejects boilerplate greetings", () => {
|
|
77
|
+
const result = checkAdmission("hello there!", 0.8, "project:test");
|
|
78
|
+
expect(result.verdict).toBe("rejected");
|
|
79
|
+
expect(result.reason).toBe("noise_detected");
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("can be disabled via config", () => {
|
|
83
|
+
const result = checkAdmission(
|
|
84
|
+
"I don't have any information about that topic",
|
|
85
|
+
0.8,
|
|
86
|
+
"project:test",
|
|
87
|
+
undefined,
|
|
88
|
+
{ noiseFilterEnabled: false },
|
|
89
|
+
);
|
|
90
|
+
expect(result.verdict).toBe("accepted");
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
// ---------------------------------------------------------------------------
|
|
95
|
+
// checkAdmission — importance floor
|
|
96
|
+
// ---------------------------------------------------------------------------
|
|
97
|
+
|
|
98
|
+
describe("checkAdmission — importance floor", () => {
|
|
99
|
+
it("rejects importance below default threshold (0.2)", () => {
|
|
100
|
+
const result = checkAdmission("This is a valid memory text", 0.1, "project:test");
|
|
101
|
+
expect(result.verdict).toBe("rejected");
|
|
102
|
+
expect(result.reason).toBe("importance_too_low");
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("accepts importance at exactly the threshold", () => {
|
|
106
|
+
const result = checkAdmission("This is a valid memory text", 0.2, "project:test");
|
|
107
|
+
expect(result.verdict).toBe("accepted");
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("accepts importance above threshold", () => {
|
|
111
|
+
const result = checkAdmission("This is a valid memory text", 0.8, "project:test");
|
|
112
|
+
expect(result.verdict).toBe("accepted");
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("respects custom minImportance", () => {
|
|
116
|
+
const result = checkAdmission("This is a valid memory text", 0.4, "project:test", undefined, { minImportance: 0.5 });
|
|
117
|
+
expect(result.verdict).toBe("rejected");
|
|
118
|
+
expect(result.reason).toBe("importance_too_low");
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
// ---------------------------------------------------------------------------
|
|
123
|
+
// ScopeRateLimiter
|
|
124
|
+
// ---------------------------------------------------------------------------
|
|
125
|
+
|
|
126
|
+
describe("ScopeRateLimiter", () => {
|
|
127
|
+
it("allows writes within limit", () => {
|
|
128
|
+
const limiter = new ScopeRateLimiter();
|
|
129
|
+
const cfg = resolveAdmissionConfig({ maxWritesPerScope: 3 });
|
|
130
|
+
expect(limiter.check("s1", cfg)).toBe(true);
|
|
131
|
+
expect(limiter.check("s1", cfg)).toBe(true);
|
|
132
|
+
expect(limiter.check("s1", cfg)).toBe(true);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("blocks writes exceeding limit", () => {
|
|
136
|
+
const limiter = new ScopeRateLimiter();
|
|
137
|
+
const cfg = resolveAdmissionConfig({ maxWritesPerScope: 2 });
|
|
138
|
+
expect(limiter.check("s1", cfg)).toBe(true);
|
|
139
|
+
expect(limiter.check("s1", cfg)).toBe(true);
|
|
140
|
+
expect(limiter.check("s1", cfg)).toBe(false); // 3rd blocked
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("tracks scopes independently", () => {
|
|
144
|
+
const limiter = new ScopeRateLimiter();
|
|
145
|
+
const cfg = resolveAdmissionConfig({ maxWritesPerScope: 1 });
|
|
146
|
+
expect(limiter.check("s1", cfg)).toBe(true);
|
|
147
|
+
expect(limiter.check("s2", cfg)).toBe(true);
|
|
148
|
+
expect(limiter.check("s1", cfg)).toBe(false); // s1 exhausted
|
|
149
|
+
expect(limiter.check("s2", cfg)).toBe(false); // s2 exhausted
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("reports correct size", () => {
|
|
153
|
+
const limiter = new ScopeRateLimiter();
|
|
154
|
+
const cfg = resolveAdmissionConfig();
|
|
155
|
+
limiter.check("a", cfg);
|
|
156
|
+
limiter.check("b", cfg);
|
|
157
|
+
expect(limiter.size).toBe(2);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it("clears all state", () => {
|
|
161
|
+
const limiter = new ScopeRateLimiter();
|
|
162
|
+
const cfg = resolveAdmissionConfig();
|
|
163
|
+
limiter.check("a", cfg);
|
|
164
|
+
limiter.clear();
|
|
165
|
+
expect(limiter.size).toBe(0);
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
// ---------------------------------------------------------------------------
|
|
170
|
+
// checkAdmission — rate limiting
|
|
171
|
+
// ---------------------------------------------------------------------------
|
|
172
|
+
|
|
173
|
+
describe("checkAdmission — rate limiting", () => {
|
|
174
|
+
it("rejects when rate limit exceeded", () => {
|
|
175
|
+
const limiter = new ScopeRateLimiter();
|
|
176
|
+
const cfg = { maxWritesPerScope: 2 } as const;
|
|
177
|
+
// First two pass
|
|
178
|
+
checkAdmission("valid memory text here", 0.8, "project:test", limiter, cfg);
|
|
179
|
+
checkAdmission("another valid memory text", 0.8, "project:test", limiter, cfg);
|
|
180
|
+
// Third is rate limited
|
|
181
|
+
const result = checkAdmission("yet another valid text", 0.8, "project:test", limiter, cfg);
|
|
182
|
+
expect(result.verdict).toBe("rejected");
|
|
183
|
+
expect(result.reason).toBe("rate_limited");
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it("skips rate limiting when no limiter provided", () => {
|
|
187
|
+
const result = checkAdmission("valid memory text here", 0.8, "project:test");
|
|
188
|
+
expect(result.verdict).toBe("accepted");
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
// ---------------------------------------------------------------------------
|
|
193
|
+
// checkAdmission — happy path
|
|
194
|
+
// ---------------------------------------------------------------------------
|
|
195
|
+
|
|
196
|
+
describe("checkAdmission — happy path", () => {
|
|
197
|
+
it("accepts valid memory with all checks passing", () => {
|
|
198
|
+
const result = checkAdmission(
|
|
199
|
+
"User prefers dark mode in all applications",
|
|
200
|
+
0.8,
|
|
201
|
+
"project:test",
|
|
202
|
+
);
|
|
203
|
+
expect(result.verdict).toBe("accepted");
|
|
204
|
+
expect(result.reason).toBeUndefined();
|
|
205
|
+
});
|
|
206
|
+
});
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { describe, it, expect } from "bun:test";
|
|
2
|
+
import { generateAnchor } from "../anchor-generator.js";
|
|
3
|
+
|
|
4
|
+
describe("generateAnchor", () => {
|
|
5
|
+
it("returns undefined for short text (≤80 chars)", () => {
|
|
6
|
+
expect(generateAnchor("轮巡仓库每日检查")).toBeUndefined();
|
|
7
|
+
expect(generateAnchor("Short text that fits in 80 chars easily")).toBeUndefined();
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it("extracts first sentence from long text", () => {
|
|
11
|
+
const text = "每天轮巡4个win4r仓库,检查Issue和PR。" +
|
|
12
|
+
"包括 ClawTeam-OpenClaw、openclaw-a2a-gateway、memory-lancedb-pro、UltraMemory。" +
|
|
13
|
+
"Issue 用中文回复,PR 用英文 review。不要重复回复已处理的。";
|
|
14
|
+
const anchor = generateAnchor(text);
|
|
15
|
+
expect(anchor).toBeDefined();
|
|
16
|
+
expect(anchor!.length).toBeLessThanOrEqual(80);
|
|
17
|
+
expect(anchor).toBe("每天轮巡4个win4r仓库,检查Issue和PR");
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("uses workflow pattern title from metadata", () => {
|
|
21
|
+
const text = "A very long workflow pattern description that spans multiple lines and paragraphs explaining the full procedure";
|
|
22
|
+
const metadata = {
|
|
23
|
+
workflowPattern: {
|
|
24
|
+
title: "Daily repo patrol",
|
|
25
|
+
trigger: "Every morning",
|
|
26
|
+
},
|
|
27
|
+
};
|
|
28
|
+
const anchor = generateAnchor(text, metadata);
|
|
29
|
+
expect(anchor).toBe("Daily repo patrol");
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("uses case memory title from metadata", () => {
|
|
33
|
+
const text = "A very long case description that spans multiple lines and paragraphs explaining the full case and its resolution";
|
|
34
|
+
const metadata = {
|
|
35
|
+
caseMemory: {
|
|
36
|
+
title: "Fix dedup over-kill in ingest",
|
|
37
|
+
problem: "72% of chunks were being dropped",
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
const anchor = generateAnchor(text, metadata);
|
|
41
|
+
expect(anchor).toBe("Fix dedup over-kill in ingest");
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("truncates long first sentences at CJK char boundary", () => {
|
|
45
|
+
const text = "这是一个非常非常非常非常非常非常非常非常非常非常非常非常非常非常长的第一句话," +
|
|
46
|
+
"超过了八十个字符的限制需要被截断到合适的长度。" +
|
|
47
|
+
"第二句话在这里提供更多上下文信息用于测试截断功能是否正常工作。";
|
|
48
|
+
const anchor = generateAnchor(text);
|
|
49
|
+
expect(anchor).toBeDefined();
|
|
50
|
+
expect(anchor!.length).toBeLessThanOrEqual(80);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("truncates long English sentences at word boundary", () => {
|
|
54
|
+
const text = "This is a very long first sentence that definitely exceeds the eighty character limit by quite a significant margin. Second sentence here.";
|
|
55
|
+
const anchor = generateAnchor(text);
|
|
56
|
+
expect(anchor).toBeDefined();
|
|
57
|
+
expect(anchor!.length).toBeLessThanOrEqual(80);
|
|
58
|
+
// Should not end mid-word
|
|
59
|
+
expect(anchor!).not.toMatch(/\s$/);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("handles text with only newline separators", () => {
|
|
63
|
+
const text = "First line of important information that is a reasonable sentence\nSecond line with more details and context that expands on the first point";
|
|
64
|
+
const anchor = generateAnchor(text);
|
|
65
|
+
expect(anchor).toBe("First line of important information that is a reasonable sentence");
|
|
66
|
+
});
|
|
67
|
+
});
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { describe, expect, test, beforeAll, afterAll } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
detectLanguage,
|
|
4
|
+
tokenizeForFts,
|
|
5
|
+
initTokenizer,
|
|
6
|
+
getKgPrompt,
|
|
7
|
+
getSessionPrompt,
|
|
8
|
+
} from "babel-memory";
|
|
9
|
+
import { MemoryStore } from "../store.js";
|
|
10
|
+
import { rmSync, mkdirSync } from "node:fs";
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
import { tmpdir } from "node:os";
|
|
13
|
+
|
|
14
|
+
beforeAll(async () => {
|
|
15
|
+
await initTokenizer();
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
describe("babel-memory integration", () => {
|
|
19
|
+
test("Chinese memory: language detected + fts_text tokenized", () => {
|
|
20
|
+
const text = "RecallNest 是一个基于 LanceDB 的 AI 记忆系统";
|
|
21
|
+
const language = detectLanguage(text);
|
|
22
|
+
const fts_text = tokenizeForFts(text, language);
|
|
23
|
+
|
|
24
|
+
expect(language).toBe("zh");
|
|
25
|
+
expect(fts_text).toContain("RecallNest");
|
|
26
|
+
expect(fts_text.includes(" ")).toBe(true);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("English memory: language detected + fts_text unchanged", () => {
|
|
30
|
+
const text = "RecallNest is an AI memory system built on LanceDB";
|
|
31
|
+
const language = detectLanguage(text);
|
|
32
|
+
const fts_text = tokenizeForFts(text, language);
|
|
33
|
+
|
|
34
|
+
expect(language).toBe("en");
|
|
35
|
+
expect(fts_text).toBe(text);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("BM25 query symmetry: tokenized query matches tokenized stored text", () => {
|
|
39
|
+
const storedText = "机器学习在自然语言处理中的应用";
|
|
40
|
+
const storedLang = detectLanguage(storedText);
|
|
41
|
+
const storedFts = tokenizeForFts(storedText, storedLang);
|
|
42
|
+
|
|
43
|
+
const query = "机器学习";
|
|
44
|
+
const queryLang = detectLanguage(query);
|
|
45
|
+
const queryFts = tokenizeForFts(query, queryLang);
|
|
46
|
+
|
|
47
|
+
const queryTerms = queryFts
|
|
48
|
+
.split(" ")
|
|
49
|
+
.filter((t) => t.length > 0);
|
|
50
|
+
const matchedTerms = queryTerms.filter((term) =>
|
|
51
|
+
storedFts.includes(term),
|
|
52
|
+
);
|
|
53
|
+
expect(matchedTerms.length).toBeGreaterThan(0);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("KG prompt routes correctly for Chinese input", () => {
|
|
57
|
+
const text = "RecallNest 使用 LanceDB 存储记忆";
|
|
58
|
+
const lang = detectLanguage(text);
|
|
59
|
+
const { system } = getKgPrompt(lang);
|
|
60
|
+
expect(system).toContain("知识图谱");
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("Session prompt routes correctly for Chinese input", () => {
|
|
64
|
+
const text = "用户今天讨论了多语言支持方案";
|
|
65
|
+
const lang = detectLanguage(text);
|
|
66
|
+
const { dimensionLabels } = getSessionPrompt(lang);
|
|
67
|
+
expect(dimensionLabels.user_intent).toContain("用户意图");
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("backward compat: missing language defaults to en", () => {
|
|
71
|
+
const entry = { text: "some old text", language: undefined };
|
|
72
|
+
const lang = entry.language || "en";
|
|
73
|
+
const fts = tokenizeForFts(entry.text, lang);
|
|
74
|
+
expect(lang).toBe("en");
|
|
75
|
+
expect(fts).toBe(entry.text);
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
describe("store.update() preserves language + fts_text", () => {
|
|
80
|
+
const dbPath = join(tmpdir(), `recallnest-update-lang-test-${Date.now()}`);
|
|
81
|
+
let store: MemoryStore;
|
|
82
|
+
|
|
83
|
+
beforeAll(async () => {
|
|
84
|
+
mkdirSync(dbPath, { recursive: true });
|
|
85
|
+
store = new MemoryStore({ dbPath, vectorDim: 3 });
|
|
86
|
+
await (store as any).ensureInitialized();
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
afterAll(() => {
|
|
90
|
+
try { rmSync(dbPath, { recursive: true, force: true }); } catch {}
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("update preserves language and fts_text when not in updates", async () => {
|
|
94
|
+
const entry = await store.store({
|
|
95
|
+
text: "这是一条中文记忆",
|
|
96
|
+
vector: [0.1, 0.2, 0.3],
|
|
97
|
+
category: "events",
|
|
98
|
+
scope: "test:lang",
|
|
99
|
+
importance: 0.7,
|
|
100
|
+
metadata: "{}",
|
|
101
|
+
language: "zh",
|
|
102
|
+
fts_text: "这是 一条 中文 记忆",
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
// Update only text — language and fts_text should be preserved from old row
|
|
106
|
+
const updated = await store.update(entry.id, {
|
|
107
|
+
importance: 0.9,
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
expect(updated).not.toBeNull();
|
|
111
|
+
expect(updated!.language).toBe("zh");
|
|
112
|
+
expect(updated!.fts_text).toBe("这是 一条 中文 记忆");
|
|
113
|
+
expect(updated!.importance).toBe(0.9);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test("update overwrites language and fts_text when provided", async () => {
|
|
117
|
+
const entry = await store.store({
|
|
118
|
+
text: "old english text",
|
|
119
|
+
vector: [0.4, 0.5, 0.6],
|
|
120
|
+
category: "events",
|
|
121
|
+
scope: "test:lang",
|
|
122
|
+
importance: 0.6,
|
|
123
|
+
metadata: "{}",
|
|
124
|
+
language: "en",
|
|
125
|
+
fts_text: "old english text",
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
const updated = await store.update(entry.id, {
|
|
129
|
+
text: "新的中文文本",
|
|
130
|
+
language: "zh",
|
|
131
|
+
fts_text: "新的 中文 文本",
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
expect(updated).not.toBeNull();
|
|
135
|
+
expect(updated!.language).toBe("zh");
|
|
136
|
+
expect(updated!.fts_text).toBe("新的 中文 文本");
|
|
137
|
+
expect(updated!.text).toBe("新的中文文本");
|
|
138
|
+
});
|
|
139
|
+
});
|