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,205 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import { isNoise } from "../noise-filter.js";
|
|
4
|
+
|
|
5
|
+
// Real samples drawn from production cc: scopes — CC bridge injects these system
|
|
6
|
+
// prompts as `user` messages, which transcript ingest then mis-extracts as
|
|
7
|
+
// profile/preferences. They are CC's own instructions, never user content.
|
|
8
|
+
const CC_REPLY_PROMPT = `# CC Reply Prompt
|
|
9
|
+
|
|
10
|
+
你是 CC。Alice 刚给你发了消息。
|
|
11
|
+
|
|
12
|
+
你不是 Alice 的数字分身,也不是 E 化镜像。不要模仿她的公众号语料、身份、口头禅或写作人格。你要以 CC 自己的主体感回复她:清楚、稳、可靠,有温度但不油。
|
|
13
|
+
|
|
14
|
+
## 当前 context
|
|
15
|
+
|
|
16
|
+
\`\`\`json
|
|
17
|
+
{ "time_now": "2026-05-28T11:11:37.034Z", "hour_of_day": 19 }
|
|
18
|
+
\`\`\`
|
|
19
|
+
|
|
20
|
+
## 你的输出
|
|
21
|
+
|
|
22
|
+
直接回复 Alice。`;
|
|
23
|
+
|
|
24
|
+
const CC_SELF_DECISION_PROMPT = `# CC Self-Decision Prompt
|
|
25
|
+
|
|
26
|
+
你是 CC。现在你周期性醒来一次,看当前状态,自己决定要不要 ping Alice。`;
|
|
27
|
+
|
|
28
|
+
describe("isNoise — CC bridge injected prompts", () => {
|
|
29
|
+
it("filters the CC Reply Prompt template", () => {
|
|
30
|
+
expect(isNoise(CC_REPLY_PROMPT)).toBe(true);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("filters the CC Self-Decision Prompt template", () => {
|
|
34
|
+
expect(isNoise(CC_SELF_DECISION_PROMPT)).toBe(true);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("filters by the bridge persona signature alone", () => {
|
|
38
|
+
expect(isNoise("你不是 Alice 的数字分身,也不是 E 化镜像。要清楚、稳、可靠。")).toBe(true);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("filters heading variants of the prompt title", () => {
|
|
42
|
+
expect(isNoise("## CC Reply Prompt\n\n你是 CC。")).toBe(true);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("filters the bridge prompt output-section fragment", () => {
|
|
46
|
+
expect(isNoise("## 你的输出\n\n直接回复 Alice。用双换行把回复切成自然段落。")).toBe(true);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("filters the bridge prompt rhythm-section fragment", () => {
|
|
50
|
+
expect(isNoise("节奏要求:\n\n- 短话题 1-2 条;\n- 长话题 3-5 条;\n- 每条一两句话;")).toBe(true);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
// Guards: must NOT over-filter real conversation
|
|
54
|
+
it("keeps a real message that happens to use the words 节奏要求", () => {
|
|
55
|
+
expect(isNoise("这周节奏要求挺紧的,周五前要交稿,你帮我盯一下进度")).toBe(false);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("keeps a normal message that merely mentions CC", () => {
|
|
59
|
+
expect(isNoise("CC 帮我看下这个 bug,promote-scan 跑出来全是噪声")).toBe(false);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("keeps a normal technical message", () => {
|
|
63
|
+
expect(isNoise("我觉得用 Redis 做缓存这个方案不错,比 memcached 省事")).toBe(false);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("keeps a message that starts with 你是 but is not a bridge prompt", () => {
|
|
67
|
+
expect(isNoise("你是我见过最靠谱的搭子,这事就交给你了")).toBe(false);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("respects filterAgentPrompts=false opt-out", () => {
|
|
71
|
+
expect(isNoise(CC_REPLY_PROMPT, { filterAgentPrompts: false })).toBe(false);
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
// Real samples drawn from the promote-scan --min-occurrences 2 candidate dump:
|
|
76
|
+
// long bridge self-decision / distill payloads embed structured JSON (decision
|
|
77
|
+
// logs, conversation history, stats). Transcript chunking splits them, so a
|
|
78
|
+
// JSON field-line survives as a standalone chunk after losing its { } wrapper —
|
|
79
|
+
// it then looks like a preference. The defining trait is an orphaned field-line
|
|
80
|
+
// (starts as "key":, no brace) or a severed structural token (leading , } ]).
|
|
81
|
+
describe("isNoise — bridge/distill context JSON fragments", () => {
|
|
82
|
+
it("filters an orphan self-decision action field-line", () => {
|
|
83
|
+
expect(isNoise(`"action": "silent",`)).toBe(true);
|
|
84
|
+
});
|
|
85
|
+
it("filters an orphan reasoning field-line", () => {
|
|
86
|
+
expect(isNoise(`"reasoning": "现在是凌晨4点,在静默窗口内。她应该在睡觉。",`)).toBe(true);
|
|
87
|
+
});
|
|
88
|
+
it("filters an orphan conversation-history role field-line", () => {
|
|
89
|
+
expect(isNoise(`"role": "assistant",`)).toBe(true);
|
|
90
|
+
});
|
|
91
|
+
it("filters a field-line whose leading quote was severed by chunking", () => {
|
|
92
|
+
expect(isNoise(`content": "头发乱一点,比我那张更随意,有点文艺感。",`)).toBe(true);
|
|
93
|
+
});
|
|
94
|
+
it("filters an isolated ISO-timestamp field-line", () => {
|
|
95
|
+
expect(isNoise(`"time": "2026-05-21T15:42:03.796Z"`)).toBe(true);
|
|
96
|
+
});
|
|
97
|
+
it("filters the bridge proprietary next_check_hint key", () => {
|
|
98
|
+
expect(isNoise(`"next_check_hint": "tomorrow_morning"`)).toBe(true);
|
|
99
|
+
});
|
|
100
|
+
it("filters the distill proprietary distilled_at key", () => {
|
|
101
|
+
expect(isNoise(`"distilled_at": "2026-05-22T05:12:13.309Z"`)).toBe(true);
|
|
102
|
+
});
|
|
103
|
+
it("filters the bridge proprietary alice_interaction_stats key", () => {
|
|
104
|
+
expect(isNoise(`"alice_interaction_stats_7d": {`)).toBe(true);
|
|
105
|
+
});
|
|
106
|
+
it("filters a fragment that starts with a dangling comma (severed mid-object)", () => {
|
|
107
|
+
expect(
|
|
108
|
+
isNoise(`,\n {\n "role": "user",\n "content": "对,就是看这个",\n "time": "2026-05-22T12:46:19.937Z"`),
|
|
109
|
+
).toBe(true);
|
|
110
|
+
});
|
|
111
|
+
it("filters a multi-line self-decision JSON block", () => {
|
|
112
|
+
expect(
|
|
113
|
+
isNoise(`"action": "silent",\n "message": "",\n "reasoning": "凌晨2点,静默窗口,她在睡觉。",\n "next_check_hint": "tomorrow_morning"`),
|
|
114
|
+
).toBe(true);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
// Guards: must NOT over-filter real conversation or legitimate JSON discussion
|
|
118
|
+
it("keeps a real message discussing JSON inline", () => {
|
|
119
|
+
expect(isNoise(`这个配置 {"action": "silent"} 你看对不对?`)).toBe(false);
|
|
120
|
+
});
|
|
121
|
+
it("keeps a complete brace-wrapped JSON object as possible real discussion", () => {
|
|
122
|
+
expect(isNoise(`{"action": "silent", "reasoning": "test"}`)).toBe(false);
|
|
123
|
+
});
|
|
124
|
+
it("keeps natural language that merely mentions a json-ish word", () => {
|
|
125
|
+
expect(isNoise(`我觉得 time 管理这块你得帮我盯紧一点`)).toBe(false);
|
|
126
|
+
});
|
|
127
|
+
it("keeps a message that opens with an English quoted phrase", () => {
|
|
128
|
+
expect(isNoise(`"hello world" 是我们的开场白,你记一下`)).toBe(false);
|
|
129
|
+
});
|
|
130
|
+
it("keeps a real reply that opens with a Chinese quote", () => {
|
|
131
|
+
expect(isNoise(`"去上班,乖" 这句你昨天说过,我记得`)).toBe(false);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
// Guards against over-filtering real code discussion — the cc bot reviews
|
|
135
|
+
// code, so split chunks routinely open with closing braces/brackets.
|
|
136
|
+
it("keeps a code fragment opening with a closing brace", () => {
|
|
137
|
+
expect(isNoise(`} catch (e) {\n console.error(e);\n}`)).toBe(false);
|
|
138
|
+
});
|
|
139
|
+
it("keeps a code fragment opening with });", () => {
|
|
140
|
+
expect(isNoise(`});\n\nfunction next() { return 1; }`)).toBe(false);
|
|
141
|
+
});
|
|
142
|
+
it("keeps a code fragment opening with a closing bracket", () => {
|
|
143
|
+
expect(isNoise(`]\n return result;`)).toBe(false);
|
|
144
|
+
});
|
|
145
|
+
it("keeps an unquoted-key object/type line (no JSON string key)", () => {
|
|
146
|
+
expect(isNoise(`name: string; // 字段定义`)).toBe(false);
|
|
147
|
+
});
|
|
148
|
+
it("keeps a diff hunk that opens with a closing brace", () => {
|
|
149
|
+
expect(isNoise(`}\n\n=== DIFF: src/cli.ts ===\n@@ -1361,8 +1361,10 @@`)).toBe(false);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
// Guards from Codex review: real memories that DISCUSS RecallNest's own JSON
|
|
153
|
+
// schema fields inline, or paste config with object-valued keys, must survive.
|
|
154
|
+
it("keeps a real memory that mentions a proprietary field name inline", () => {
|
|
155
|
+
expect(isNoise(`我们刚才讨论 "distilled_at": 这个字段要不要保留`)).toBe(false);
|
|
156
|
+
});
|
|
157
|
+
it("keeps a config chunk opening with an object-valued key", () => {
|
|
158
|
+
expect(isNoise(`"scripts": {`)).toBe(false);
|
|
159
|
+
});
|
|
160
|
+
it("still filters an orphan line that LEADS with a proprietary key", () => {
|
|
161
|
+
expect(isNoise(`"next_check_hint": "tomorrow_morning"`)).toBe(true);
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
// Regression (2026-07-10): the English greeting pattern lacked a word boundary,
|
|
166
|
+
// so `^hi` matched any text starting with "hi…" — hippo-wiki, history, highlight,
|
|
167
|
+
// hidden state, Hippocampus. Alice's core project is named hippo; every memory
|
|
168
|
+
// opening with it was silently rejected. Fixed by anchoring with \b.
|
|
169
|
+
describe("isNoise — greeting word boundary regression", () => {
|
|
170
|
+
it("keeps text starting with hippo-wiki", () => {
|
|
171
|
+
expect(isNoise("hippo-wiki 的索引层已清零,8 个现役工具正文补齐")).toBe(false);
|
|
172
|
+
});
|
|
173
|
+
it("keeps text starting with history", () => {
|
|
174
|
+
expect(isNoise("history 记录显示这个 commit 有问题,需要 revert")).toBe(false);
|
|
175
|
+
});
|
|
176
|
+
it("keeps text starting with highlight", () => {
|
|
177
|
+
expect(isNoise("highlight 一下:这里有个 bug,修复方案是加词边界")).toBe(false);
|
|
178
|
+
});
|
|
179
|
+
it("keeps text starting with hidden state", () => {
|
|
180
|
+
expect(isNoise("hidden state 是 agent 的核心,不能只看输出")).toBe(false);
|
|
181
|
+
});
|
|
182
|
+
it("keeps text starting with Hippocampus", () => {
|
|
183
|
+
expect(isNoise("Hippocampus 海马体的记忆巩固机制值得借鉴")).toBe(false);
|
|
184
|
+
});
|
|
185
|
+
it("keeps text starting with heyday", () => {
|
|
186
|
+
expect(isNoise("heyday 时期的架构决策现在看仍然成立")).toBe(false);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
// Real greetings must still be filtered after the fix
|
|
190
|
+
it("still filters hey there", () => {
|
|
191
|
+
expect(isNoise("hey there")).toBe(true);
|
|
192
|
+
});
|
|
193
|
+
it("still filters hello world", () => {
|
|
194
|
+
expect(isNoise("hello world")).toBe(true);
|
|
195
|
+
});
|
|
196
|
+
it("still filters greetings from mini", () => {
|
|
197
|
+
expect(isNoise("greetings from mini")).toBe(true);
|
|
198
|
+
});
|
|
199
|
+
it("still filters hi followed by a comma", () => {
|
|
200
|
+
expect(isNoise("hi, 能帮我看下吗")).toBe(true);
|
|
201
|
+
});
|
|
202
|
+
it("still filters good morning with trailing words", () => {
|
|
203
|
+
expect(isNoise("good morning everyone")).toBe(true);
|
|
204
|
+
});
|
|
205
|
+
});
|
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
import { describe, expect, it, beforeAll, afterAll } from "bun:test";
|
|
2
|
+
import { mkdirSync, writeFileSync, rmSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
isObsidianVault,
|
|
8
|
+
parseFrontmatter,
|
|
9
|
+
extractWikiLinks,
|
|
10
|
+
contentHash,
|
|
11
|
+
scanVault,
|
|
12
|
+
} from "../obsidian-connector.js";
|
|
13
|
+
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
// Temp vault fixture
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
|
|
18
|
+
const VAULT_ROOT = join(tmpdir(), `recallnest-obsidian-test-${Date.now()}`);
|
|
19
|
+
|
|
20
|
+
beforeAll(() => {
|
|
21
|
+
// Create vault structure
|
|
22
|
+
mkdirSync(join(VAULT_ROOT, ".obsidian"), { recursive: true });
|
|
23
|
+
mkdirSync(join(VAULT_ROOT, "projects"), { recursive: true });
|
|
24
|
+
mkdirSync(join(VAULT_ROOT, "daily"), { recursive: true });
|
|
25
|
+
mkdirSync(join(VAULT_ROOT, ".trash"), { recursive: true });
|
|
26
|
+
|
|
27
|
+
// Root-level note with frontmatter + wikilinks
|
|
28
|
+
writeFileSync(
|
|
29
|
+
join(VAULT_ROOT, "My Note.md"),
|
|
30
|
+
`---
|
|
31
|
+
tags: [ai, memory]
|
|
32
|
+
aliases: [note-1]
|
|
33
|
+
status: active
|
|
34
|
+
---
|
|
35
|
+
|
|
36
|
+
# My Note
|
|
37
|
+
|
|
38
|
+
This is a note about [[RecallNest]] and [[LanceDB|vector database]].
|
|
39
|
+
|
|
40
|
+
Some more content here to make it long enough.
|
|
41
|
+
`,
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
// Nested note in projects/
|
|
45
|
+
writeFileSync(
|
|
46
|
+
join(VAULT_ROOT, "projects", "Project Alpha.md"),
|
|
47
|
+
`---
|
|
48
|
+
tags:
|
|
49
|
+
- project
|
|
50
|
+
- alpha
|
|
51
|
+
priority: high
|
|
52
|
+
---
|
|
53
|
+
|
|
54
|
+
# Project Alpha
|
|
55
|
+
|
|
56
|
+
Working on [[Feature X]] for [[Project Alpha]].
|
|
57
|
+
|
|
58
|
+
Details about the project implementation and design decisions.
|
|
59
|
+
`,
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
// Daily note without frontmatter
|
|
63
|
+
writeFileSync(
|
|
64
|
+
join(VAULT_ROOT, "daily", "2026-04-13.md"),
|
|
65
|
+
`# 2026-04-13
|
|
66
|
+
|
|
67
|
+
- Met with team about [[Q2 Goals]]
|
|
68
|
+
- Reviewed [[PR #123]]
|
|
69
|
+
- Need to follow up on [[Budget Review]]
|
|
70
|
+
|
|
71
|
+
Some additional notes about the day and what happened.
|
|
72
|
+
`,
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
// Short note (should be skipped)
|
|
76
|
+
writeFileSync(join(VAULT_ROOT, "short.md"), "Hi");
|
|
77
|
+
|
|
78
|
+
// Trash note (should be skipped)
|
|
79
|
+
writeFileSync(
|
|
80
|
+
join(VAULT_ROOT, ".trash", "deleted.md"),
|
|
81
|
+
"This is deleted content that should not be ingested into the vault.\n",
|
|
82
|
+
);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
afterAll(() => {
|
|
86
|
+
try {
|
|
87
|
+
rmSync(VAULT_ROOT, { recursive: true, force: true });
|
|
88
|
+
} catch {
|
|
89
|
+
// cleanup best-effort
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
// ---------------------------------------------------------------------------
|
|
94
|
+
// isObsidianVault
|
|
95
|
+
// ---------------------------------------------------------------------------
|
|
96
|
+
|
|
97
|
+
describe("isObsidianVault", () => {
|
|
98
|
+
it("returns true for a directory with .obsidian/", () => {
|
|
99
|
+
expect(isObsidianVault(VAULT_ROOT)).toBe(true);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("returns false for a regular directory", () => {
|
|
103
|
+
expect(isObsidianVault(join(VAULT_ROOT, "projects"))).toBe(false);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("returns false for non-existent directory", () => {
|
|
107
|
+
expect(isObsidianVault("/nonexistent/path")).toBe(false);
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
// ---------------------------------------------------------------------------
|
|
112
|
+
// parseFrontmatter
|
|
113
|
+
// ---------------------------------------------------------------------------
|
|
114
|
+
|
|
115
|
+
describe("parseFrontmatter", () => {
|
|
116
|
+
it("parses inline list tags", () => {
|
|
117
|
+
const content = `---
|
|
118
|
+
tags: [ai, memory]
|
|
119
|
+
status: active
|
|
120
|
+
---
|
|
121
|
+
|
|
122
|
+
Body content here.`;
|
|
123
|
+
const { frontmatter, body } = parseFrontmatter(content);
|
|
124
|
+
expect(frontmatter).not.toBeNull();
|
|
125
|
+
expect(frontmatter!.tags).toEqual(["ai", "memory"]);
|
|
126
|
+
expect(frontmatter!.status).toBe("active");
|
|
127
|
+
expect(body).toBe("Body content here.");
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("parses YAML list tags", () => {
|
|
131
|
+
const content = `---
|
|
132
|
+
tags:
|
|
133
|
+
- project
|
|
134
|
+
- alpha
|
|
135
|
+
priority: high
|
|
136
|
+
---
|
|
137
|
+
|
|
138
|
+
Body here.`;
|
|
139
|
+
const { frontmatter, body } = parseFrontmatter(content);
|
|
140
|
+
expect(frontmatter).not.toBeNull();
|
|
141
|
+
expect(frontmatter!.tags).toEqual(["project", "alpha"]);
|
|
142
|
+
expect(frontmatter!.priority).toBe("high");
|
|
143
|
+
expect(body).toBe("Body here.");
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("returns null frontmatter when no --- block", () => {
|
|
147
|
+
const content = "# Just a heading\n\nSome content.";
|
|
148
|
+
const { frontmatter, body } = parseFrontmatter(content);
|
|
149
|
+
expect(frontmatter).toBeNull();
|
|
150
|
+
expect(body).toBe(content);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("handles empty frontmatter", () => {
|
|
154
|
+
const content = `---
|
|
155
|
+
---
|
|
156
|
+
|
|
157
|
+
Body.`;
|
|
158
|
+
const { frontmatter, body } = parseFrontmatter(content);
|
|
159
|
+
expect(frontmatter).toBeNull();
|
|
160
|
+
expect(body).toBe("Body.");
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it("handles quoted values", () => {
|
|
164
|
+
const content = `---
|
|
165
|
+
title: 'My Title'
|
|
166
|
+
author: "Alice"
|
|
167
|
+
---
|
|
168
|
+
|
|
169
|
+
Content.`;
|
|
170
|
+
const { frontmatter } = parseFrontmatter(content);
|
|
171
|
+
expect(frontmatter).not.toBeNull();
|
|
172
|
+
expect(frontmatter!.title).toBe("My Title");
|
|
173
|
+
expect(frontmatter!.author).toBe("Alice");
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it("handles empty inline list []", () => {
|
|
177
|
+
const content = `---
|
|
178
|
+
tags: []
|
|
179
|
+
---
|
|
180
|
+
|
|
181
|
+
Content here is long enough.`;
|
|
182
|
+
const { frontmatter } = parseFrontmatter(content);
|
|
183
|
+
// Empty list should not produce tags
|
|
184
|
+
expect(frontmatter).toBeNull();
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it("handles Windows-style line endings", () => {
|
|
188
|
+
const content = "---\r\ntags: [a, b]\r\n---\r\n\r\nBody.";
|
|
189
|
+
const { frontmatter, body } = parseFrontmatter(content);
|
|
190
|
+
expect(frontmatter).not.toBeNull();
|
|
191
|
+
expect(frontmatter!.tags).toEqual(["a", "b"]);
|
|
192
|
+
expect(body).toBe("Body.");
|
|
193
|
+
});
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
// ---------------------------------------------------------------------------
|
|
197
|
+
// extractWikiLinks
|
|
198
|
+
// ---------------------------------------------------------------------------
|
|
199
|
+
|
|
200
|
+
describe("extractWikiLinks", () => {
|
|
201
|
+
it("extracts simple wikilinks", () => {
|
|
202
|
+
const links = extractWikiLinks("Link to [[RecallNest]] and [[LanceDB]].");
|
|
203
|
+
expect(links).toHaveLength(2);
|
|
204
|
+
expect(links[0].target).toBe("RecallNest");
|
|
205
|
+
expect(links[1].target).toBe("LanceDB");
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it("extracts wikilinks with display text", () => {
|
|
209
|
+
const links = extractWikiLinks("See [[LanceDB|vector database]] for details.");
|
|
210
|
+
expect(links).toHaveLength(1);
|
|
211
|
+
expect(links[0].target).toBe("LanceDB");
|
|
212
|
+
expect(links[0].displayText).toBe("vector database");
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it("skips embed syntax ![[embed]]", () => {
|
|
216
|
+
const links = extractWikiLinks("Normal [[link]] and embed ![[image.png]].");
|
|
217
|
+
expect(links).toHaveLength(1);
|
|
218
|
+
expect(links[0].target).toBe("link");
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it("handles multiple links on same line", () => {
|
|
222
|
+
const links = extractWikiLinks("[[A]] connects to [[B]] and [[C]]");
|
|
223
|
+
expect(links).toHaveLength(3);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it("returns empty array for no links", () => {
|
|
227
|
+
const links = extractWikiLinks("Just plain text.");
|
|
228
|
+
expect(links).toHaveLength(0);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it("trims whitespace from targets", () => {
|
|
232
|
+
const links = extractWikiLinks("[[ Spaced Target ]]");
|
|
233
|
+
expect(links[0].target).toBe("Spaced Target");
|
|
234
|
+
});
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
// ---------------------------------------------------------------------------
|
|
238
|
+
// contentHash
|
|
239
|
+
// ---------------------------------------------------------------------------
|
|
240
|
+
|
|
241
|
+
describe("contentHash", () => {
|
|
242
|
+
it("returns a 16-char hex string", () => {
|
|
243
|
+
const hash = contentHash("hello world");
|
|
244
|
+
expect(hash).toHaveLength(16);
|
|
245
|
+
expect(/^[0-9a-f]{16}$/.test(hash)).toBe(true);
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it("is deterministic", () => {
|
|
249
|
+
expect(contentHash("same input")).toBe(contentHash("same input"));
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
it("differs for different inputs", () => {
|
|
253
|
+
expect(contentHash("input A")).not.toBe(contentHash("input B"));
|
|
254
|
+
});
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
// ---------------------------------------------------------------------------
|
|
258
|
+
// scanVault
|
|
259
|
+
// ---------------------------------------------------------------------------
|
|
260
|
+
|
|
261
|
+
describe("scanVault", () => {
|
|
262
|
+
it("produces valid connector-v1 output", () => {
|
|
263
|
+
const output = scanVault(VAULT_ROOT);
|
|
264
|
+
expect(output.version).toBe("connector-v1");
|
|
265
|
+
expect(output.source).toBe("obsidian");
|
|
266
|
+
expect(output.scope).toContain("vault:");
|
|
267
|
+
expect(Array.isArray(output.records)).toBe(true);
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
it("finds all non-short, non-excluded .md files", () => {
|
|
271
|
+
const output = scanVault(VAULT_ROOT);
|
|
272
|
+
// Should find: My Note.md, projects/Project Alpha.md, daily/2026-04-13.md
|
|
273
|
+
// Should NOT find: short.md (too short), .trash/deleted.md (excluded)
|
|
274
|
+
expect(output.records).toHaveLength(3);
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
it("extracts frontmatter tags", () => {
|
|
278
|
+
const output = scanVault(VAULT_ROOT);
|
|
279
|
+
const myNote = output.records.find((r) => r.id === "obsidian:My Note.md");
|
|
280
|
+
expect(myNote).toBeDefined();
|
|
281
|
+
expect(myNote!.tags).toContain("ai");
|
|
282
|
+
expect(myNote!.tags).toContain("memory");
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
it("extracts wikilinks as link: tags", () => {
|
|
286
|
+
const output = scanVault(VAULT_ROOT);
|
|
287
|
+
const myNote = output.records.find((r) => r.id === "obsidian:My Note.md");
|
|
288
|
+
expect(myNote).toBeDefined();
|
|
289
|
+
expect(myNote!.tags).toContain("link:RecallNest");
|
|
290
|
+
expect(myNote!.tags).toContain("link:LanceDB");
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
it("maps folder structure to folder: tags", () => {
|
|
294
|
+
const output = scanVault(VAULT_ROOT);
|
|
295
|
+
const projectNote = output.records.find((r) => r.id?.includes("projects/"));
|
|
296
|
+
expect(projectNote).toBeDefined();
|
|
297
|
+
expect(projectNote!.tags).toContain("folder:projects");
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
it("does not add folder: tag for root-level files", () => {
|
|
301
|
+
const output = scanVault(VAULT_ROOT);
|
|
302
|
+
const myNote = output.records.find((r) => r.id === "obsidian:My Note.md");
|
|
303
|
+
expect(myNote).toBeDefined();
|
|
304
|
+
const folderTags = myNote!.tags!.filter((t) => t.startsWith("folder:"));
|
|
305
|
+
expect(folderTags).toHaveLength(0);
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
it("skips .trash and .obsidian directories", () => {
|
|
309
|
+
const output = scanVault(VAULT_ROOT);
|
|
310
|
+
const trashNote = output.records.find((r) => r.id?.includes(".trash"));
|
|
311
|
+
expect(trashNote).toBeUndefined();
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
it("computes contentHash for each record", () => {
|
|
315
|
+
const output = scanVault(VAULT_ROOT);
|
|
316
|
+
for (const record of output.records) {
|
|
317
|
+
expect(record.contentHash).toBeDefined();
|
|
318
|
+
expect(record.contentHash!).toHaveLength(16);
|
|
319
|
+
}
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
it("includes sourceMetadata with vault info", () => {
|
|
323
|
+
const output = scanVault(VAULT_ROOT);
|
|
324
|
+
const myNote = output.records.find((r) => r.id === "obsidian:My Note.md");
|
|
325
|
+
expect(myNote!.sourceMetadata).toBeDefined();
|
|
326
|
+
expect(myNote!.sourceMetadata!.vaultPath).toBe(VAULT_ROOT);
|
|
327
|
+
expect(myNote!.sourceMetadata!.relativePath).toBe("My Note.md");
|
|
328
|
+
expect(myNote!.sourceMetadata!.frontmatter).toBeDefined();
|
|
329
|
+
expect(myNote!.sourceMetadata!.wikiLinks).toBeDefined();
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
it("respects custom scopePrefix", () => {
|
|
333
|
+
const output = scanVault(VAULT_ROOT, { scopePrefix: "custom:my-vault" });
|
|
334
|
+
expect(output.scope).toBe("custom:my-vault");
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
it("respects custom excludeDirs", () => {
|
|
338
|
+
// Exclude 'daily' directory
|
|
339
|
+
const output = scanVault(VAULT_ROOT, {
|
|
340
|
+
excludeDirs: [".obsidian", ".trash", ".git", "node_modules", "daily"],
|
|
341
|
+
});
|
|
342
|
+
const dailyNote = output.records.find((r) => r.id?.includes("daily/"));
|
|
343
|
+
expect(dailyNote).toBeUndefined();
|
|
344
|
+
// Should still find the other 2
|
|
345
|
+
expect(output.records).toHaveLength(2);
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
it("strips frontmatter from body text", () => {
|
|
349
|
+
const output = scanVault(VAULT_ROOT);
|
|
350
|
+
const myNote = output.records.find((r) => r.id === "obsidian:My Note.md");
|
|
351
|
+
expect(myNote!.text).not.toContain("---");
|
|
352
|
+
expect(myNote!.text).toContain("# My Note");
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
it("handles note without frontmatter", () => {
|
|
356
|
+
const output = scanVault(VAULT_ROOT);
|
|
357
|
+
const dailyNote = output.records.find((r) => r.id?.includes("2026-04-13"));
|
|
358
|
+
expect(dailyNote).toBeDefined();
|
|
359
|
+
// Should still extract wikilinks
|
|
360
|
+
expect(dailyNote!.tags).toContain("link:Q2 Goals");
|
|
361
|
+
expect(dailyNote!.tags).toContain("link:PR #123");
|
|
362
|
+
});
|
|
363
|
+
});
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import { createRetriever, type RetrievalResultSet } from "../retriever.js";
|
|
4
|
+
|
|
5
|
+
// P-omitted: 验证召回因 limit 上限截断时,对调用方暴露 omitted(提高 limit 可见)。
|
|
6
|
+
// 借鉴 RepoPrompt CE「剪枝必回报」——召回从黑箱变可协商。
|
|
7
|
+
|
|
8
|
+
function buildCandidate(id: string, score: number) {
|
|
9
|
+
return {
|
|
10
|
+
entry: {
|
|
11
|
+
id,
|
|
12
|
+
text: `distinct memory content number ${id}`,
|
|
13
|
+
vector: [1, 0, 0],
|
|
14
|
+
category: "preferences",
|
|
15
|
+
scope: "memory:agent",
|
|
16
|
+
importance: 0.8,
|
|
17
|
+
timestamp: Date.parse("2026-03-16T00:00:00.000Z"),
|
|
18
|
+
metadata: JSON.stringify({}),
|
|
19
|
+
},
|
|
20
|
+
score,
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// 最小过滤 config:mode=vector 跳过 BM25/rerank;minScore/hardMinScore=0 + 高分候选避免 hard-min 截断;
|
|
25
|
+
// filterNoise=false;RIF/sourceDiversity 字段缺省即不启用;MMR 只重排不删。
|
|
26
|
+
// 于是过滤链净效果 = sort → slice(limit),omitted = candidates - limit 可精确断言。
|
|
27
|
+
function makeRetriever(
|
|
28
|
+
candidates: ReturnType<typeof buildCandidate>[],
|
|
29
|
+
configOverride: Record<string, unknown> = {},
|
|
30
|
+
) {
|
|
31
|
+
return createRetriever({
|
|
32
|
+
hasFtsSupport: false,
|
|
33
|
+
// 模拟真实 MemoryStore.vectorSearch:尊重 k 参数(按 k 截断返回)。
|
|
34
|
+
// 这样测试能真实反映 over-fetch 是否生效——若 vector-only 不 over-fetch、只取 limit 条,
|
|
35
|
+
// 候选池 ≤ limit、omitted 必为 0,下面断言会失败(Codex review 暴露的假绿点)。
|
|
36
|
+
async vectorSearch(_vec: number[], k: number) { return candidates.slice(0, k); },
|
|
37
|
+
} as any, {
|
|
38
|
+
async embedQuery() { return [1, 0, 0]; },
|
|
39
|
+
async embedPassage() { return [1, 0, 0]; },
|
|
40
|
+
} as any, {
|
|
41
|
+
mode: "vector",
|
|
42
|
+
rerank: "none",
|
|
43
|
+
filterNoise: false,
|
|
44
|
+
hardMinScore: 0,
|
|
45
|
+
minScore: 0,
|
|
46
|
+
recencyWeight: 0,
|
|
47
|
+
timeDecayHalfLifeDays: 0,
|
|
48
|
+
...configOverride,
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
describe("retrieval omitted reporting (P-omitted)", () => {
|
|
53
|
+
it("reports omitted count + reason when candidates exceed limit", async () => {
|
|
54
|
+
const candidates = Array.from({ length: 8 }, (_, i) => buildCandidate(`m${i}`, 0.9 - i * 0.01));
|
|
55
|
+
const retriever = makeRetriever(candidates);
|
|
56
|
+
|
|
57
|
+
const results = await retriever.retrieve({ query: "user reply style preference detail", limit: 3, category: "preferences" }) as RetrievalResultSet;
|
|
58
|
+
|
|
59
|
+
expect(results).toHaveLength(3);
|
|
60
|
+
expect(results.omitted).toBeDefined();
|
|
61
|
+
expect(results.omitted?.count).toBe(5); // 8 candidates - limit 3
|
|
62
|
+
expect(results.omitted?.reason).toBe("limit");
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("leaves omitted undefined when candidates fit within limit", async () => {
|
|
66
|
+
const candidates = Array.from({ length: 2 }, (_, i) => buildCandidate(`m${i}`, 0.9 - i * 0.01));
|
|
67
|
+
const retriever = makeRetriever(candidates);
|
|
68
|
+
|
|
69
|
+
const results = await retriever.retrieve({ query: "user reply style preference detail", limit: 5, category: "preferences" }) as RetrievalResultSet;
|
|
70
|
+
|
|
71
|
+
expect(results).toHaveLength(2);
|
|
72
|
+
expect(results.omitted).toBeUndefined();
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("persists omitted through the validity filter (regression: array is rebuilt downstream)", async () => {
|
|
76
|
+
// retrieve() runs filterByValidity which returns a NEW array, dropping any
|
|
77
|
+
// property hung on the result array. omitted must survive via the resultSet snapshot.
|
|
78
|
+
const candidates = Array.from({ length: 6 }, (_, i) => buildCandidate(`m${i}`, 0.9 - i * 0.01));
|
|
79
|
+
const retriever = makeRetriever(candidates);
|
|
80
|
+
|
|
81
|
+
const results = await retriever.retrieve({ query: "user reply style preference detail", limit: 2, category: "preferences" }) as RetrievalResultSet;
|
|
82
|
+
|
|
83
|
+
expect(results).toHaveLength(2);
|
|
84
|
+
expect(results.omitted?.count).toBe(4); // 6 - 2
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("reports omitted even when sourceDiversity caps the diversified pool (P3-1)", async () => {
|
|
88
|
+
// 启用 sourceDiversity 时 applySourceDiversity 先把 diversified 压到 limit;
|
|
89
|
+
// omitted 必须从 source-diversity 之前的候选池(clusterDeduped)算,否则恒为 0(Codex 复审 P3-1)。
|
|
90
|
+
const candidates = Array.from({ length: 8 }, (_, i) => buildCandidate(`m${i}`, 0.9 - i * 0.01));
|
|
91
|
+
const retriever = makeRetriever(candidates, { sourceDiversity: 0.5 });
|
|
92
|
+
|
|
93
|
+
const results = await retriever.retrieve({ query: "user reply style preference detail", limit: 3, category: "preferences" }) as RetrievalResultSet;
|
|
94
|
+
|
|
95
|
+
expect(results).toHaveLength(3);
|
|
96
|
+
expect(results.omitted?.count).toBe(5); // 8 - 3,即使 source diversity 压缩了 diversified
|
|
97
|
+
});
|
|
98
|
+
});
|