recallnest 2.3.0 → 2.5.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +150 -0
- package/README.md +17 -31
- package/README_CN.md +12 -22
- package/assets/ui/app.js +1031 -0
- package/assets/ui/index.html +224 -0
- package/assets/ui/styles.css +781 -0
- package/config.json.example +4 -1
- package/docs/api-reference.md +1291 -0
- package/docs/architecture.md +168 -0
- package/docs/connector-spec.md +126 -0
- package/eval/cases-canary.json +58 -0
- package/eval/cases-scope-robustness.json +278 -0
- package/eval/cases.json +117 -20
- package/package.json +22 -1
- package/src/__tests__/access-tracker-novelty.test.ts +238 -0
- package/src/__tests__/activity-counter.test.ts +125 -0
- package/src/__tests__/adaptive-fidelity.test.ts +102 -0
- package/src/__tests__/admission-control.test.ts +206 -0
- package/src/__tests__/anchor-generator.test.ts +67 -0
- package/src/__tests__/auto-consolidation.test.ts +168 -0
- package/src/__tests__/auto-recall.test.ts +131 -0
- package/src/__tests__/babel-integration.test.ts +144 -0
- package/src/__tests__/batch-dedup.test.ts +65 -0
- package/src/__tests__/canary-eval.test.ts +114 -0
- package/src/__tests__/capture-engine.test.ts +1238 -0
- package/src/__tests__/capture-heuristic.test.ts +171 -0
- package/src/__tests__/cascade-forget.test.ts +210 -0
- package/src/__tests__/category-min-scores.test.ts +33 -0
- 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 +782 -0
- package/src/__tests__/confidence-metadata.test.ts +125 -0
- package/src/__tests__/confidence-tracker.test.ts +261 -0
- package/src/__tests__/conflict-advisor.test.ts +206 -0
- package/src/__tests__/conflict-escalation.test.ts +149 -0
- package/src/__tests__/conflict-lifecycle.test.ts +83 -0
- package/src/__tests__/conflict-output.test.ts +85 -0
- package/src/__tests__/conflict-store.test.ts +374 -0
- package/src/__tests__/connector-v1.test.ts +181 -0
- package/src/__tests__/consolidation-engine.test.ts +333 -0
- package/src/__tests__/constructive-retrieval.test.ts +509 -0
- package/src/__tests__/context-collapse-renderer.test.ts +214 -0
- package/src/__tests__/context-composer-pins.test.ts +64 -0
- package/src/__tests__/context-composer-task-selection.test.ts +24 -0
- package/src/__tests__/context-reconstructor.test.ts +65 -0
- package/src/__tests__/context-renderer.test.ts +91 -0
- package/src/__tests__/continuity-eval.test.ts +275 -0
- package/src/__tests__/continuity-schema.test.ts +97 -0
- package/src/__tests__/contracts.test.ts +216 -0
- package/src/__tests__/conversation-importer.test.ts +692 -0
- package/src/__tests__/core-recall-eval.test.ts +235 -0
- package/src/__tests__/core-summary.test.ts +109 -0
- package/src/__tests__/data-checkup.test.ts +276 -0
- package/src/__tests__/decay-exemption.test.ts +84 -0
- package/src/__tests__/dedup-actions.test.ts +58 -0
- package/src/__tests__/dedup-guard.test.ts +39 -0
- package/src/__tests__/deterministic-id.test.ts +39 -0
- package/src/__tests__/distill-lock.test.ts +305 -0
- package/src/__tests__/distill-session-argshape.test.ts +79 -0
- package/src/__tests__/doctor.test.ts +319 -0
- package/src/__tests__/dream-pipeline.test.ts +321 -0
- package/src/__tests__/drill-down.test.ts +108 -0
- package/src/__tests__/embedder.test.ts +122 -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__/entity-resolver.test.ts +58 -0
- package/src/__tests__/env-config.test.ts +151 -0
- package/src/__tests__/error-signature-boost.test.ts +87 -0
- package/src/__tests__/error-signature.test.ts +95 -0
- package/src/__tests__/error-taxonomy.test.ts +129 -0
- package/src/__tests__/ethics-layer.test.ts +476 -0
- package/src/__tests__/event-segmenter.test.ts +153 -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__/hotness-blend.test.ts +76 -0
- package/src/__tests__/ingest-chunking.test.ts +44 -0
- package/src/__tests__/ingest.test.ts +360 -0
- package/src/__tests__/interference-detector.test.ts +203 -0
- package/src/__tests__/kg-extractor.test.ts +192 -0
- package/src/__tests__/kg-store.test.ts +300 -0
- package/src/__tests__/llm-consolidation.test.ts +226 -0
- package/src/__tests__/maintenance-locks.test.ts +174 -0
- package/src/__tests__/mcp-efficiency.test.ts +288 -0
- package/src/__tests__/mcp-related-scopes.test.ts +151 -0
- package/src/__tests__/memory-boundaries.test.ts +250 -0
- package/src/__tests__/memory-evolution-b2-b3-e1.test.ts +345 -0
- package/src/__tests__/memory-evolution.test.ts +271 -0
- package/src/__tests__/memory-health-rebalance.test.ts +144 -0
- package/src/__tests__/memory-lint.test.ts +421 -0
- package/src/__tests__/memory-output.test.ts +215 -0
- package/src/__tests__/memory-promotion.test.ts +315 -0
- package/src/__tests__/migrate-dedup-ids.test.ts +65 -0
- package/src/__tests__/multi-vector.test.ts +242 -0
- package/src/__tests__/narrative-architecture.test.ts +456 -0
- package/src/__tests__/noise-filter.test.ts +205 -0
- package/src/__tests__/obsidian-connector.test.ts +363 -0
- package/src/__tests__/omitted-reporting.test.ts +98 -0
- package/src/__tests__/pending-review.test.ts +77 -0
- package/src/__tests__/pii-redact.test.ts +314 -0
- package/src/__tests__/post-retrieval-filter.test.ts +178 -0
- package/src/__tests__/ppr-traversal.test.ts +256 -0
- package/src/__tests__/prediction-engine.test.ts +349 -0
- package/src/__tests__/predictive-prospective-memory.test.ts +416 -0
- package/src/__tests__/preference-guard-smoke.test.ts +211 -0
- package/src/__tests__/preference-matcher.test.ts +230 -0
- package/src/__tests__/preference-slots.test.ts +205 -0
- package/src/__tests__/prospective-memory.test.ts +283 -0
- package/src/__tests__/query-entity-detector.test.ts +171 -0
- package/src/__tests__/query-expander-alias.test.ts +146 -0
- package/src/__tests__/query-expander.test.ts +34 -0
- package/src/__tests__/recall-governor.test.ts +227 -0
- package/src/__tests__/recall-mode.test.ts +64 -0
- package/src/__tests__/result-synthesizer.test.ts +198 -0
- package/src/__tests__/resume-context.test.ts +4898 -0
- package/src/__tests__/retrieval-trace.test.ts +213 -0
- package/src/__tests__/retriever-boundary-weight.test.ts +136 -0
- package/src/__tests__/retroactive-boost.test.ts +223 -0
- package/src/__tests__/salience-scoring.test.ts +106 -0
- package/src/__tests__/scope-inventory.test.ts +200 -0
- package/src/__tests__/scope-policy.test.ts +69 -0
- package/src/__tests__/scope-suggester.test.ts +80 -0
- package/src/__tests__/session-checkpoint.test.ts +291 -0
- package/src/__tests__/session-distiller.test.ts +640 -0
- package/src/__tests__/session-gc.test.ts +215 -0
- package/src/__tests__/skill-engine.test.ts +552 -0
- package/src/__tests__/skill-promotion.test.ts +601 -0
- package/src/__tests__/skill-verifier.test.ts +122 -0
- package/src/__tests__/source-heartbeat.test.ts +250 -0
- package/src/__tests__/store-patch-metadata.test.ts +205 -0
- package/src/__tests__/store-scope-audit.test.ts +81 -0
- package/src/__tests__/store-upsert.test.ts +184 -0
- package/src/__tests__/supersede-chain.test.ts +154 -0
- package/src/__tests__/temporal-parser.test.ts +241 -0
- package/src/__tests__/temporal-validity.test.ts +160 -0
- package/src/__tests__/term-registry.test.ts +121 -0
- package/src/__tests__/tool-error-surfacing.test.ts +98 -0
- package/src/__tests__/tool-output-compressor.test.ts +160 -0
- package/src/__tests__/topic-tag.test.ts +113 -0
- package/src/__tests__/trusted-memory.test.ts +839 -0
- package/src/__tests__/usage-tracker.test.ts +287 -0
- package/src/__tests__/version-manager.test.ts +213 -0
- package/src/__tests__/workflow-observation-skill-outcome.test.ts +352 -0
- package/src/__tests__/workflow-observation.test.ts +433 -0
- package/src/__tests__/write-verifier.test.ts +122 -0
- package/src/access-tracker.ts +38 -32
- package/src/activity-counter.ts +46 -18
- package/src/aliases.ts +133 -0
- package/src/api-server.ts +27 -7
- package/src/audit-log.ts +4 -2
- package/src/auto-consolidation.ts +8 -3
- package/src/auto-gc.ts +85 -21
- package/src/capture-engine.ts +54 -21
- package/src/cli.ts +169 -37
- package/src/confidence-tracker.ts +36 -18
- package/src/consolidation-engine.ts +109 -11
- package/src/context-composer.ts +8 -1
- package/src/data-checkup.ts +95 -6
- package/src/distill-lock.ts +194 -15
- package/src/doctor.ts +9 -2
- package/src/dream-pipeline.ts +118 -34
- package/src/env-config.ts +77 -0
- package/src/error-signature.ts +153 -0
- package/src/error-taxonomy.ts +115 -0
- package/src/eval.ts +251 -6
- package/src/idempotency.ts +8 -0
- package/src/ingest.ts +31 -10
- package/src/kg-extractor.ts +6 -2
- package/src/kg-store.ts +236 -44
- package/src/llm-consolidation.ts +2 -1
- package/src/mcp-server.ts +85 -1799
- package/src/mcp-tool-deps.ts +48 -0
- package/src/mcp-tools-advanced.ts +941 -0
- package/src/mcp-tools-core.ts +633 -0
- package/src/mcp-tools-governance.ts +475 -0
- package/src/memory-boundaries.ts +3 -1
- package/src/memory-evolution.ts +73 -1
- package/src/memory-lint.ts +183 -10
- package/src/memory-output.ts +114 -3
- package/src/memory-promotion.ts +246 -0
- package/src/memory-schema.ts +25 -2
- package/src/memory-store-port.ts +43 -0
- package/src/multi-vector.ts +2 -1
- package/src/narrative-schema.ts +2 -1
- package/src/noise-filter.ts +57 -6
- package/src/pii-detector.ts +157 -5
- package/src/query-expander.ts +90 -9
- package/src/result-synthesizer.ts +2 -1
- package/src/retention-policy.ts +2 -1
- package/src/retriever.ts +259 -139
- package/src/runtime-config.ts +8 -1
- package/src/scope-suggester.ts +113 -0
- package/src/session-distiller.ts +23 -2
- package/src/session-engine.ts +2 -1
- package/src/session-schema.ts +1 -0
- package/src/session-store.ts +5 -0
- package/src/skill-engine.ts +78 -0
- package/src/skill-promotion.ts +247 -47
- package/src/skill-schema.ts +16 -3
- package/src/skill-verifier.ts +152 -0
- package/src/store.ts +267 -38
- package/src/tool-output-compressor.ts +3 -1
- package/src/ui-server.ts +2 -1
- package/src/usage-tracker.ts +242 -0
- package/src/version-manager.ts +3 -2
- package/src/workflow-observation-engine.ts +2 -1
- package/src/workflow-observation-schema.ts +2 -1
- package/src/workflow-observation-store.ts +6 -2
- package/.env +0 -4
- package/CLAUDE.md +0 -46
- package/config.json +0 -55
- package/daily-recall.sh +0 -110
- package/logs/daily-recall.error.log +0 -0
- package/logs/daily-recall.log +0 -60
- package/logs/ingest-2026-04-11.log +0 -110
- package/logs/ingest-2026-04-12.log +0 -90
- package/logs/ingest-2026-04-13.log +0 -87
- package/logs/launchagent-stderr.log +0 -0
- package/logs/launchagent-stdout.log +0 -0
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import { formatCollapsedResults } from "../memory-output.js";
|
|
4
|
+
import type { RetrievalResult } from "../retriever.js";
|
|
5
|
+
|
|
6
|
+
// P-fidelity (点4): adaptive 档 search-native 实现(CC+Codex 共识 C:不复用 resume 的
|
|
7
|
+
// collapseResults)。借鉴 RepoPrompt CE「保真度阶梯」内核——按相关性分配保真度 + token 预算,
|
|
8
|
+
// 但渲染 query-aware(search 结果是"为什么命中",必须显示匹配证据)。
|
|
9
|
+
|
|
10
|
+
function makeResult(id: string, text: string, score: number, metadata = "{}"): RetrievalResult {
|
|
11
|
+
return {
|
|
12
|
+
entry: {
|
|
13
|
+
id,
|
|
14
|
+
text,
|
|
15
|
+
vector: [],
|
|
16
|
+
category: "events",
|
|
17
|
+
scope: "test",
|
|
18
|
+
importance: 0.5,
|
|
19
|
+
timestamp: Date.parse("2026-06-29T00:00:00.000Z"),
|
|
20
|
+
metadata,
|
|
21
|
+
},
|
|
22
|
+
score,
|
|
23
|
+
sources: {},
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const ctx = { query: "QUERYMATCH", profile: "default" as never };
|
|
28
|
+
|
|
29
|
+
describe("formatCollapsedResults (adaptive, search-native)", () => {
|
|
30
|
+
it("returns 'No results found' for empty input", () => {
|
|
31
|
+
expect(formatCollapsedResults([], ctx)).toBe("No results found.");
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("renders high-relevance (score ≥ 0.85) as FULL text", () => {
|
|
35
|
+
const out = formatCollapsedResults([makeResult("aaaaaaaa", "full detailed QUERYMATCH content here", 0.95)], ctx);
|
|
36
|
+
expect(out).toContain("[FULL] aaaaaaaa");
|
|
37
|
+
expect(out).toContain("full detailed QUERYMATCH content here");
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("renders lower-relevance as SNIP and keeps the matching evidence even when it's late in the text (P2-3)", () => {
|
|
41
|
+
const text = "Intro sentence. " + "filler ".repeat(20) + "the QUERYMATCH evidence is right here.";
|
|
42
|
+
const out = formatCollapsedResults([makeResult("bbbbbbbb", text, 0.60)], ctx);
|
|
43
|
+
expect(out).toContain("[SNIP] bbbbbbbb");
|
|
44
|
+
// 关键:query-aware snippet 必须保留匹配证据,而不是只截开头(这正是复用 collapse 时的 P2-3)
|
|
45
|
+
expect(out).toContain("QUERYMATCH");
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("does NOT drop low-score hits — adaptive is display, not a filter (P2-1)", () => {
|
|
49
|
+
const out = formatCollapsedResults([makeResult("cccccccc", "valid low-score QUERYMATCH hit", 0.30)], ctx);
|
|
50
|
+
expect(out).toContain("[SNIP] cccccccc");
|
|
51
|
+
expect(out).not.toContain("No results");
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("preserves caller order (e.g. highlight reorder), not raw score order (P2-2)", () => {
|
|
55
|
+
// 模拟 render:highlight — contextual 最相关但 raw score 较低的排在前。
|
|
56
|
+
const out = formatCollapsedResults([
|
|
57
|
+
makeResult("term0000", "term-relevant QUERYMATCH but lower score", 0.55),
|
|
58
|
+
makeResult("high0000", "higher score but less contextual", 0.95),
|
|
59
|
+
], ctx);
|
|
60
|
+
const idxTerm = out.indexOf("term0000");
|
|
61
|
+
const idxHigh = out.indexOf("high0000");
|
|
62
|
+
expect(idxTerm).toBeGreaterThan(-1);
|
|
63
|
+
expect(idxHigh).toBeGreaterThan(-1);
|
|
64
|
+
expect(idxTerm).toBeLessThan(idxHigh); // term0000 仍在前
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("includes locator info (id / score / category)", () => {
|
|
68
|
+
const out = formatCollapsedResults([makeResult("dddddddd", "QUERYMATCH content", 0.90)], ctx);
|
|
69
|
+
expect(out).toContain("dddddddd");
|
|
70
|
+
expect(out).toContain("90%");
|
|
71
|
+
expect(out).toContain("events"); // category label
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("keeps the match visible for a short acronym query (adaptiveSnippet window, P2)", () => {
|
|
75
|
+
// pickBestSnippet 对短缩写 query 会退回开头;adaptiveSnippet 用 raw query 定位 + 窗口。
|
|
76
|
+
const text = "Some long intro line without the keyword. ".repeat(5) + "Finally the CI pipeline broke here.";
|
|
77
|
+
const out = formatCollapsedResults([makeResult("ffffffff", text, 0.50)], { query: "CI", profile: "default" as never });
|
|
78
|
+
expect(out).toContain("[SNIP] ffffffff");
|
|
79
|
+
expect(out.toLowerCase()).toContain("ci pipeline"); // 匹配证据可见,而非只截开头
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("windows around a match that is late in a long single line (P2)", () => {
|
|
83
|
+
// 超长无句读的 log 行:整句截断会丢后段匹配;adaptiveSnippet 取匹配词周围窗口。
|
|
84
|
+
const text = "prefix ".repeat(50) + "ZEBRAWORD is the late match here" + " suffix".repeat(50);
|
|
85
|
+
const out = formatCollapsedResults([makeResult("11111111", text, 0.50)], { query: "ZEBRAWORD", profile: "default" as never });
|
|
86
|
+
expect(out).toContain("ZEBRAWORD"); // 后段匹配仍在窗口内
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("does not false-match an acronym inside an unrelated earlier word (word boundary, P2)", () => {
|
|
90
|
+
// "specific" 含子串 "ci";裸 substring 会 window 到它、漏掉真的 CI。word boundary 跳过。
|
|
91
|
+
const text = "This is a specific introduction. " + "filler ".repeat(10) + "Then the CI build failed.";
|
|
92
|
+
const out = formatCollapsedResults([makeResult("22222222", text, 0.50)], { query: "CI", profile: "default" as never });
|
|
93
|
+
expect(out).toContain("CI build"); // window 到真 CI,不是 specific 里的 ci
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("matches CJK fallback queries via substring (ASCII word boundary doesn't apply, P2-1)", () => {
|
|
97
|
+
// 中文单字 query:JS \b 只认 ASCII,必须走 substring,否则退回开头丢匹配(Alice 的记忆大量中文)。
|
|
98
|
+
const text = "开头一些无关的内容。" + "填充内容 ".repeat(10) + "这里才提到猫的关键证据。";
|
|
99
|
+
const out = formatCollapsedResults([makeResult("33333333", text, 0.50)], { query: "猫", profile: "default" as never });
|
|
100
|
+
expect(out).toContain("猫"); // 中文匹配证据可见
|
|
101
|
+
});
|
|
102
|
+
});
|
|
@@ -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,168 @@
|
|
|
1
|
+
import { describe, expect, it, beforeEach } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
maybeConsolidate,
|
|
5
|
+
resetConsolidationState,
|
|
6
|
+
DEFAULT_AUTO_CONSOLIDATION_CONFIG,
|
|
7
|
+
type AutoConsolidationConfig,
|
|
8
|
+
} from "../auto-consolidation.js";
|
|
9
|
+
import type { MemoryEntry, MemorySearchResult } from "../store.js";
|
|
10
|
+
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
// Helpers
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
|
|
15
|
+
function makeEntry(id: string, text: string, overrides: Partial<MemoryEntry> = {}): MemoryEntry {
|
|
16
|
+
return {
|
|
17
|
+
id,
|
|
18
|
+
text,
|
|
19
|
+
vector: [1, 0, 0],
|
|
20
|
+
category: "events",
|
|
21
|
+
scope: "project:test",
|
|
22
|
+
importance: 0.7,
|
|
23
|
+
timestamp: Date.now(),
|
|
24
|
+
metadata: "{}",
|
|
25
|
+
...overrides,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function createMockStore(entries: MemoryEntry[]) {
|
|
30
|
+
const data = new Map(entries.map(e => [e.id, { ...e }]));
|
|
31
|
+
return {
|
|
32
|
+
async list(scopeFilter?: string[], _category?: string, limit = 500, _offset = 0) {
|
|
33
|
+
return [...data.values()]
|
|
34
|
+
.filter(e => !scopeFilter || scopeFilter.some(s => e.scope === s))
|
|
35
|
+
.slice(0, limit);
|
|
36
|
+
},
|
|
37
|
+
async getById(id: string) {
|
|
38
|
+
return data.get(id) ?? null;
|
|
39
|
+
},
|
|
40
|
+
async vectorSearch(_vector: number[], _limit = 5, _minScore = 0.3, _scopeFilter?: string[]) {
|
|
41
|
+
return [] as MemorySearchResult[];
|
|
42
|
+
},
|
|
43
|
+
async update(id: string, updates: any, _scopeFilter?: string[]) {
|
|
44
|
+
const entry = data.get(id);
|
|
45
|
+
if (!entry) return null;
|
|
46
|
+
Object.assign(entry, updates);
|
|
47
|
+
return entry;
|
|
48
|
+
},
|
|
49
|
+
async stats(scopeFilter?: string[]) {
|
|
50
|
+
const filtered = [...data.values()].filter(
|
|
51
|
+
e => !scopeFilter || scopeFilter.some(s => e.scope === s),
|
|
52
|
+
);
|
|
53
|
+
return { totalCount: filtered.length, scopeCounts: {}, categoryCounts: {} };
|
|
54
|
+
},
|
|
55
|
+
hasFtsSupport: false,
|
|
56
|
+
} as any;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// ---------------------------------------------------------------------------
|
|
60
|
+
// Tests
|
|
61
|
+
// ---------------------------------------------------------------------------
|
|
62
|
+
|
|
63
|
+
describe("auto-consolidation", () => {
|
|
64
|
+
beforeEach(() => {
|
|
65
|
+
resetConsolidationState();
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("skips when not enough new memories", async () => {
|
|
69
|
+
const entries = Array.from({ length: 10 }, (_, i) =>
|
|
70
|
+
makeEntry(`id-${i}`, `memory ${i}`),
|
|
71
|
+
);
|
|
72
|
+
const store = createMockStore(entries);
|
|
73
|
+
|
|
74
|
+
const result = await maybeConsolidate(store, "project:test");
|
|
75
|
+
expect(result.triggered).toBe(false);
|
|
76
|
+
expect(result.reason).toBe("insufficient_new_memories");
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("skips when too soon since last run", async () => {
|
|
80
|
+
// Start with 60 entries, trigger first run, then grow to 120 so
|
|
81
|
+
// the memory-count gate passes but the time gate blocks.
|
|
82
|
+
const data: MemoryEntry[] = Array.from({ length: 60 }, (_, i) =>
|
|
83
|
+
makeEntry(`id-${i}`, `memory ${i}`),
|
|
84
|
+
);
|
|
85
|
+
const store = createMockStore(data);
|
|
86
|
+
|
|
87
|
+
const config: AutoConsolidationConfig = {
|
|
88
|
+
minNewMemories: 50,
|
|
89
|
+
minHoursSinceLastRun: 12,
|
|
90
|
+
consolidation: DEFAULT_AUTO_CONSOLIDATION_CONFIG.consolidation,
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
// First run: triggers (never run before, 60 > 50 new memories)
|
|
94
|
+
const first = await maybeConsolidate(store, "project:test", config);
|
|
95
|
+
expect(first.triggered).toBe(true);
|
|
96
|
+
|
|
97
|
+
// Grow the store so memory-count gate would pass
|
|
98
|
+
for (let i = 60; i < 120; i++) {
|
|
99
|
+
data.push(makeEntry(`id-${i}`, `memory ${i}`));
|
|
100
|
+
}
|
|
101
|
+
const bigStore = createMockStore(data);
|
|
102
|
+
|
|
103
|
+
// Second run immediately: time gate blocks (< 12 hours)
|
|
104
|
+
const second = await maybeConsolidate(bigStore, "project:test", config);
|
|
105
|
+
expect(second.triggered).toBe(false);
|
|
106
|
+
expect(second.reason).toBe("too_soon");
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("triggers when both conditions met", async () => {
|
|
110
|
+
const entries = Array.from({ length: 60 }, (_, i) =>
|
|
111
|
+
makeEntry(`id-${i}`, `memory ${i}`),
|
|
112
|
+
);
|
|
113
|
+
const store = createMockStore(entries);
|
|
114
|
+
|
|
115
|
+
const config: AutoConsolidationConfig = {
|
|
116
|
+
minNewMemories: 50,
|
|
117
|
+
minHoursSinceLastRun: 0, // disable time gate for test
|
|
118
|
+
consolidation: {
|
|
119
|
+
clusterThreshold: 0.82,
|
|
120
|
+
mergeThreshold: 0.92,
|
|
121
|
+
maxEntriesPerRun: 500,
|
|
122
|
+
},
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
const result = await maybeConsolidate(store, "project:test", config);
|
|
126
|
+
expect(result.triggered).toBe(true);
|
|
127
|
+
expect(result.consolidation).toBeDefined();
|
|
128
|
+
expect(result.consolidation!.scope).toBe("project:test");
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("returns consolidation result when triggered", async () => {
|
|
132
|
+
const entries = Array.from({ length: 60 }, (_, i) =>
|
|
133
|
+
makeEntry(`id-${i}`, `memory ${i}`),
|
|
134
|
+
);
|
|
135
|
+
const store = createMockStore(entries);
|
|
136
|
+
|
|
137
|
+
const config: AutoConsolidationConfig = {
|
|
138
|
+
minNewMemories: 50,
|
|
139
|
+
minHoursSinceLastRun: 0,
|
|
140
|
+
consolidation: {
|
|
141
|
+
clusterThreshold: 0.82,
|
|
142
|
+
mergeThreshold: 0.92,
|
|
143
|
+
maxEntriesPerRun: 500,
|
|
144
|
+
},
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
const result = await maybeConsolidate(store, "project:test", config);
|
|
148
|
+
expect(result.triggered).toBe(true);
|
|
149
|
+
expect(result.consolidation!.originalCount).toBe(60);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("respects custom thresholds", async () => {
|
|
153
|
+
const entries = Array.from({ length: 200 }, (_, i) =>
|
|
154
|
+
makeEntry(`id-${i}`, `memory ${i}`),
|
|
155
|
+
);
|
|
156
|
+
const store = createMockStore(entries);
|
|
157
|
+
|
|
158
|
+
const config: AutoConsolidationConfig = {
|
|
159
|
+
minNewMemories: 300, // higher than available
|
|
160
|
+
minHoursSinceLastRun: 0,
|
|
161
|
+
consolidation: DEFAULT_AUTO_CONSOLIDATION_CONFIG.consolidation,
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
const result = await maybeConsolidate(store, "project:test", config);
|
|
165
|
+
expect(result.triggered).toBe(false);
|
|
166
|
+
expect(result.reason).toBe("insufficient_new_memories");
|
|
167
|
+
});
|
|
168
|
+
});
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import { runAutoRecall } from "../auto-recall.js";
|
|
4
|
+
import type { RetrievalContext, RetrievalResult } from "../retriever.js";
|
|
5
|
+
import type { SessionCheckpointRecord } from "../session-schema.js";
|
|
6
|
+
|
|
7
|
+
function buildResult(id: string, category: "profile" | "preferences" | "entities" | "patterns" | "cases" | "events", text: string, scope = "project:test"): RetrievalResult {
|
|
8
|
+
return {
|
|
9
|
+
entry: {
|
|
10
|
+
id,
|
|
11
|
+
text,
|
|
12
|
+
vector: [],
|
|
13
|
+
category,
|
|
14
|
+
scope,
|
|
15
|
+
importance: 0.8,
|
|
16
|
+
timestamp: Date.parse("2026-03-17T00:00:00.000Z"),
|
|
17
|
+
metadata: "{}",
|
|
18
|
+
},
|
|
19
|
+
score: 0.9,
|
|
20
|
+
sources: {
|
|
21
|
+
fused: { score: 0.9 },
|
|
22
|
+
},
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
describe("runAutoRecall", () => {
|
|
27
|
+
it("reuses an explicit scope for focused recall after composing resume context", async () => {
|
|
28
|
+
const calls: RetrievalContext[] = [];
|
|
29
|
+
const retriever = {
|
|
30
|
+
async retrieve(context: RetrievalContext): Promise<RetrievalResult[]> {
|
|
31
|
+
calls.push(context);
|
|
32
|
+
if (context.category === "profile") {
|
|
33
|
+
return [buildResult("profile-1", "profile", "Profile: RecallNest maintainer.", "project:alpha")];
|
|
34
|
+
}
|
|
35
|
+
if (!context.category) {
|
|
36
|
+
return [buildResult("event-1", "events", "RecallNest uses Weibull decay.", "project:alpha")];
|
|
37
|
+
}
|
|
38
|
+
return [];
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const response = await runAutoRecall({
|
|
43
|
+
retriever,
|
|
44
|
+
checkpointStore: { async getLatest() { return null; } },
|
|
45
|
+
listPins: () => [],
|
|
46
|
+
}, {
|
|
47
|
+
message: "那个衰减曲线用的什么公式来着",
|
|
48
|
+
scope: "project:alpha",
|
|
49
|
+
limit: 5,
|
|
50
|
+
profile: "default",
|
|
51
|
+
operation: "test:auto-recall",
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
expect(response.mode).toBe("resume+search");
|
|
55
|
+
expect(response.resolvedScope).toBe("project:alpha");
|
|
56
|
+
expect(response.results[0]?.entry.id).toBe("event-1");
|
|
57
|
+
const searchCall = calls.find((call) => !call.category);
|
|
58
|
+
expect(searchCall?.scopeFilter).toEqual(["project:alpha"]);
|
|
59
|
+
expect(searchCall?.source).toBe("auto-recall");
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("uses the checkpoint-resolved scope when the request only carries sessionId", async () => {
|
|
63
|
+
const calls: RetrievalContext[] = [];
|
|
64
|
+
const checkpoint: SessionCheckpointRecord = {
|
|
65
|
+
checkpointId: "checkpoint-1",
|
|
66
|
+
sessionId: "session-123",
|
|
67
|
+
resolvedScope: "project:recallnest",
|
|
68
|
+
summary: "Continue proactive recall work.",
|
|
69
|
+
task: "Implement auto recall route",
|
|
70
|
+
decisions: [],
|
|
71
|
+
openLoops: [],
|
|
72
|
+
nextActions: [],
|
|
73
|
+
entities: [],
|
|
74
|
+
files: [],
|
|
75
|
+
updatedAt: "2026-03-17T10:00:00.000Z",
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const retriever = {
|
|
79
|
+
async retrieve(context: RetrievalContext): Promise<RetrievalResult[]> {
|
|
80
|
+
calls.push(context);
|
|
81
|
+
if (!context.category) {
|
|
82
|
+
return [buildResult("event-1", "events", "Add a true auto recall route.", "project:recallnest")];
|
|
83
|
+
}
|
|
84
|
+
return [];
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
const response = await runAutoRecall({
|
|
89
|
+
retriever,
|
|
90
|
+
checkpointStore: {
|
|
91
|
+
async getLatest(query?: { sessionId?: string }) {
|
|
92
|
+
return query?.sessionId === "session-123" ? checkpoint : null;
|
|
93
|
+
},
|
|
94
|
+
},
|
|
95
|
+
listPins: () => [],
|
|
96
|
+
}, {
|
|
97
|
+
message: "继续做主动 recall",
|
|
98
|
+
sessionId: "session-123",
|
|
99
|
+
operation: "test:auto-recall",
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
expect(response.resolvedScope).toBe("project:recallnest");
|
|
103
|
+
expect(response.resume.latestCheckpoint?.resolvedScope).toBe("project:recallnest");
|
|
104
|
+
const searchCall = calls.find((call) => !call.category);
|
|
105
|
+
expect(searchCall?.scopeFilter).toEqual(["project:recallnest"]);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("falls back to resume-only mode when no scope can be inferred for focused search", async () => {
|
|
109
|
+
const calls: RetrievalContext[] = [];
|
|
110
|
+
const retriever = {
|
|
111
|
+
async retrieve(context: RetrievalContext): Promise<RetrievalResult[]> {
|
|
112
|
+
calls.push(context);
|
|
113
|
+
return [];
|
|
114
|
+
},
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
const response = await runAutoRecall({
|
|
118
|
+
retriever,
|
|
119
|
+
checkpointStore: { async getLatest() { return null; } },
|
|
120
|
+
listPins: () => [],
|
|
121
|
+
}, {
|
|
122
|
+
message: "what do you remember about this",
|
|
123
|
+
operation: "test:auto-recall",
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
expect(response.mode).toBe("resume-only");
|
|
127
|
+
expect(response.results).toHaveLength(0);
|
|
128
|
+
expect(response.searchSkippedReason).toContain("No explicit or inferred scope");
|
|
129
|
+
expect(calls.some((call) => !call.category)).toBe(false);
|
|
130
|
+
});
|
|
131
|
+
});
|