recallnest 2.3.0 → 2.6.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/CHANGELOG.md +187 -0
- package/README.md +49 -38
- package/README_CN.md +46 -31
- 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 +25 -4
- package/src/__tests__/access-tracker-novelty.test.ts +304 -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__/api-server-security.test.ts +102 -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__/belief-history.test.ts +220 -0
- package/src/__tests__/canary-eval.test.ts +114 -0
- package/src/__tests__/capture-engine.test.ts +1328 -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 +116 -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 +945 -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 +418 -0
- package/src/__tests__/connector-v1.test.ts +181 -0
- package/src/__tests__/consolidation-engine.test.ts +430 -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 +262 -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 +129 -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 +780 -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__/freshness.test.ts +186 -0
- package/src/__tests__/graph-export.test.ts +489 -0
- package/src/__tests__/hotness-blend.test.ts +283 -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-client-pivot.test.ts +628 -0
- package/src/__tests__/llm-consolidation.test.ts +226 -0
- package/src/__tests__/maintenance-locks.test.ts +189 -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 +282 -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__/noise-prototype-bank.test.ts +280 -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 +333 -0
- package/src/__tests__/pivot-apply.test.ts +373 -0
- package/src/__tests__/pivot-distill-supervisor.test.ts +457 -0
- package/src/__tests__/pivot-distill.test.ts +1178 -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 +282 -0
- package/src/__tests__/preference-slots.test.ts +205 -0
- package/src/__tests__/prospective-memory.test.ts +283 -0
- package/src/__tests__/pull-from-macbook-sync-only.test.ts +679 -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-gate.test.ts +266 -0
- package/src/__tests__/recall-governor.test.ts +227 -0
- package/src/__tests__/recall-mode.test.ts +64 -0
- package/src/__tests__/resolve-db-path.test.ts +130 -0
- package/src/__tests__/resolve-tier.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-eval.test.ts +163 -0
- package/src/__tests__/retrieval-trace.test.ts +213 -0
- package/src/__tests__/retriever-boundary-weight.test.ts +136 -0
- package/src/__tests__/retriever-lazy-init-dispatch.test.ts +123 -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 +654 -0
- package/src/__tests__/skill-verifier.test.ts +122 -0
- package/src/__tests__/source-heartbeat.test.ts +250 -0
- package/src/__tests__/stable-context-length-contract.test.ts +62 -0
- package/src/__tests__/store-patch-metadata.test.ts +205 -0
- package/src/__tests__/store-read-consistency.test.ts +122 -0
- package/src/__tests__/store-schema-migration.test.ts +87 -0
- package/src/__tests__/store-scope-audit.test.ts +81 -0
- package/src/__tests__/store-upsert.test.ts +201 -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-terminal-roster.test.ts +127 -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__/ui-server-security.test.ts +93 -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 +90 -34
- package/src/activity-counter.ts +46 -18
- package/src/adaptive-retrieval.ts +11 -0
- package/src/aliases.ts +133 -0
- package/src/api-server.ts +36 -9
- package/src/audit-log.ts +4 -2
- package/src/auto-consolidation.ts +8 -3
- package/src/auto-gc.ts +85 -21
- package/src/auto-recall.ts +61 -1
- package/src/belief-history.ts +164 -0
- package/src/capture-engine.ts +142 -32
- package/src/cli.ts +268 -41
- package/src/confidence-tracker.ts +36 -18
- package/src/conflict-engine.ts +24 -3
- package/src/consolidation-engine.ts +255 -23
- package/src/context-composer-stable-selection.ts +7 -1
- package/src/context-composer.ts +8 -1
- package/src/data-checkup.ts +96 -7
- package/src/decay-engine.ts +59 -7
- package/src/distill-lock.ts +194 -15
- package/src/doctor.ts +13 -4
- package/src/dream-pipeline.ts +473 -41
- package/src/env-config.ts +118 -0
- package/src/error-signature.ts +153 -0
- package/src/error-taxonomy.ts +115 -0
- package/src/eval.ts +402 -14
- package/src/freshness.ts +192 -0
- package/src/http-request-policy.ts +148 -0
- package/src/idempotency.ts +8 -0
- package/src/ingest.ts +308 -12
- package/src/kg-extractor.ts +13 -3
- package/src/kg-store.ts +236 -44
- package/src/llm-client.ts +106 -11
- package/src/llm-consolidation.ts +2 -1
- package/src/mcp-server.ts +87 -1800
- package/src/mcp-tool-deps.ts +48 -0
- package/src/mcp-tools-advanced.ts +954 -0
- package/src/mcp-tools-core.ts +637 -0
- package/src/mcp-tools-governance.ts +475 -0
- package/src/memory-boundaries.ts +26 -3
- package/src/memory-evolution.ts +73 -1
- package/src/memory-health-rebalance.ts +4 -0
- package/src/memory-lint.ts +183 -10
- package/src/memory-output.ts +129 -4
- package/src/memory-promotion.ts +246 -0
- package/src/memory-schema.ts +25 -2
- package/src/memory-store-port.ts +46 -0
- package/src/multi-vector.ts +2 -1
- package/src/narrative-schema.ts +2 -1
- package/src/noise-filter.ts +57 -6
- package/src/noise-prototype-bank.ts +408 -0
- package/src/pii-detector.ts +207 -5
- package/src/pivot-apply.ts +399 -0
- package/src/preference-matcher.ts +37 -3
- package/src/query-expander.ts +90 -9
- package/src/recall-gate.ts +305 -0
- package/src/result-synthesizer.ts +2 -1
- package/src/retention-policy.ts +2 -1
- package/src/retrieval-profiles.ts +46 -4
- package/src/retriever.ts +354 -157
- package/src/retroactive-boost.ts +6 -2
- package/src/runtime-config.ts +78 -6
- 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 +304 -48
- package/src/skill-schema.ts +16 -3
- package/src/skill-verifier.ts +152 -0
- package/src/store.ts +321 -57
- package/src/term-registry.ts +35 -0
- package/src/tool-output-compressor.ts +3 -1
- package/src/ui-server.ts +9 -1
- package/src/usage-tracker.ts +242 -0
- package/src/version-manager.ts +3 -2
- package/src/version.ts +19 -0
- 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,280 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from "bun:test";
|
|
2
|
+
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
|
|
6
|
+
import { persistMemory } from "../capture-engine.js";
|
|
7
|
+
import {
|
|
8
|
+
NoisePrototypeBank,
|
|
9
|
+
isProtectedContent,
|
|
10
|
+
resetSharedNoisePrototypeBank,
|
|
11
|
+
resolveNoisePrototypeBank,
|
|
12
|
+
resolveNoisePrototypeMode,
|
|
13
|
+
} from "../noise-prototype-bank.js";
|
|
14
|
+
|
|
15
|
+
const cleanupPaths: string[] = [];
|
|
16
|
+
|
|
17
|
+
afterEach(() => {
|
|
18
|
+
resetSharedNoisePrototypeBank();
|
|
19
|
+
while (cleanupPaths.length > 0) {
|
|
20
|
+
const target = cleanupPaths.pop();
|
|
21
|
+
if (target) rmSync(target, { recursive: true, force: true });
|
|
22
|
+
}
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
function tempDir(): string {
|
|
26
|
+
const dir = mkdtempSync(join(tmpdir(), "recallnest-npb-"));
|
|
27
|
+
cleanupPaths.push(dir);
|
|
28
|
+
return dir;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function makeBank(overrides: Partial<ConstructorParameters<typeof NoisePrototypeBank>[0]> = {}) {
|
|
32
|
+
const dir = tempDir();
|
|
33
|
+
const bank = new NoisePrototypeBank({
|
|
34
|
+
filePath: join(dir, "bank.json"),
|
|
35
|
+
shadowLogPath: join(dir, "shadow.jsonl"),
|
|
36
|
+
...overrides,
|
|
37
|
+
});
|
|
38
|
+
return { bank, dir };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Deterministic fake embedder: unit vectors along an axis picked by seed.
|
|
42
|
+
function axisVector(axis: number, dim = 8): number[] {
|
|
43
|
+
const v = new Array(dim).fill(0);
|
|
44
|
+
v[axis] = 1;
|
|
45
|
+
return v;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const embedAxis0 = async () => axisVector(0);
|
|
49
|
+
|
|
50
|
+
describe("NoisePrototypeBank — learning discipline", () => {
|
|
51
|
+
it("first rejection is only a pending candidate; the repeat sighting learns", async () => {
|
|
52
|
+
const { bank } = makeBank();
|
|
53
|
+
expect(await bank.learnFromRejection("嗯嗯嗯嗯好的好的", embedAxis0)).toBe("candidate_seen");
|
|
54
|
+
expect(bank.list().length).toBe(0);
|
|
55
|
+
expect(bank.pendingCount()).toBe(1);
|
|
56
|
+
|
|
57
|
+
expect(await bank.learnFromRejection("嗯嗯嗯嗯好的好的", embedAxis0)).toBe("learned");
|
|
58
|
+
expect(bank.list().length).toBe(1);
|
|
59
|
+
expect(bank.pendingCount()).toBe(0);
|
|
60
|
+
expect(bank.list()[0]?.seenCount).toBe(2);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("normalization dedupes near-identical rejections into one candidate", async () => {
|
|
64
|
+
const { bank } = makeBank();
|
|
65
|
+
await bank.learnFromRejection("嗯嗯嗯嗯好的好的", embedAxis0);
|
|
66
|
+
// Same content modulo whitespace/punctuation/case → same candidate.
|
|
67
|
+
expect(await bank.learnFromRejection("嗯嗯嗯嗯,好的 好的!!", embedAxis0)).toBe("learned");
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("already-learned content reports skipped_duplicate without re-embedding", async () => {
|
|
71
|
+
const { bank } = makeBank();
|
|
72
|
+
let embeds = 0;
|
|
73
|
+
const countingEmbed = async () => {
|
|
74
|
+
embeds += 1;
|
|
75
|
+
return axisVector(0);
|
|
76
|
+
};
|
|
77
|
+
await bank.learnFromRejection("嗯嗯嗯嗯好的好的", countingEmbed);
|
|
78
|
+
await bank.learnFromRejection("嗯嗯嗯嗯好的好的", countingEmbed);
|
|
79
|
+
expect(embeds).toBe(1);
|
|
80
|
+
expect(await bank.learnFromRejection("嗯嗯嗯嗯好的好的", countingEmbed)).toBe("skipped_duplicate");
|
|
81
|
+
expect(embeds).toBe(1);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("protected content is never learned, even when the noise filter rejected it", async () => {
|
|
85
|
+
const { bank } = makeBank();
|
|
86
|
+
expect(await bank.learnFromRejection("记住我喜欢的部署方式", embedAxis0)).toBe("skipped_protected");
|
|
87
|
+
expect(await bank.learnFromRejection("记住我喜欢的部署方式", embedAxis0)).toBe("skipped_protected");
|
|
88
|
+
expect(bank.list().length).toBe(0);
|
|
89
|
+
expect(bank.pendingCount()).toBe(0);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("the cap stops learning at maxPrototypes", async () => {
|
|
93
|
+
const { bank } = makeBank({ maxPrototypes: 1, minSeenCount: 1 });
|
|
94
|
+
expect(await bank.learnFromRejection("噪声内容甲甲甲甲", embedAxis0)).toBe("learned");
|
|
95
|
+
expect(await bank.learnFromRejection("噪声内容乙乙乙乙", embedAxis0)).toBe("skipped_full");
|
|
96
|
+
expect(bank.list().length).toBe(1);
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
describe("NoisePrototypeBank — shadow matching", () => {
|
|
101
|
+
async function seededBank() {
|
|
102
|
+
const made = makeBank({ minSeenCount: 1 });
|
|
103
|
+
await made.bank.learnFromRejection("嗯嗯嗯嗯好的好的", embedAxis0);
|
|
104
|
+
return made;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
it("similarity at/above threshold suggests rejection and logs, but only logs", async () => {
|
|
108
|
+
const { bank, dir } = await seededBank();
|
|
109
|
+
const match = bank.matchShadow("嗯嗯好的收到啦", axisVector(0), "mem-123");
|
|
110
|
+
expect(match.matched).toBe(true);
|
|
111
|
+
expect(match.similarity).toBeCloseTo(1, 5);
|
|
112
|
+
expect(bank.list()[0]?.hitCount).toBe(1);
|
|
113
|
+
|
|
114
|
+
const log = readFileSync(join(dir, "shadow.jsonl"), "utf8").trim().split("\n");
|
|
115
|
+
const suggest = log.map((l) => JSON.parse(l)).find((e) => e.event === "suggest_reject");
|
|
116
|
+
expect(suggest?.memoryId).toBe("mem-123");
|
|
117
|
+
expect(suggest?.prototypeId).toBe(bank.list()[0]?.id);
|
|
118
|
+
expect(JSON.stringify(suggest)).not.toContain("嗯嗯好的收到啦");
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("below-threshold similarity does not match", async () => {
|
|
122
|
+
const { bank } = await seededBank();
|
|
123
|
+
const match = bank.matchShadow("完全不同的正交内容", axisVector(1));
|
|
124
|
+
expect(match.matched).toBe(false);
|
|
125
|
+
expect(bank.list()[0]?.hitCount).toBe(0);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("protected content is never flagged regardless of similarity", async () => {
|
|
129
|
+
const { bank } = await seededBank();
|
|
130
|
+
const match = bank.matchShadow("记住我喜欢的部署方式", axisVector(0));
|
|
131
|
+
expect(match.matched).toBe(false);
|
|
132
|
+
expect(match.protected).toBe(true);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("dimension mismatch degrades to no match instead of throwing", async () => {
|
|
136
|
+
const { bank } = await seededBank();
|
|
137
|
+
const match = bank.matchShadow("维度不齐的向量", [1, 0]);
|
|
138
|
+
expect(match.matched).toBe(false);
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
describe("NoisePrototypeBank — correction & lifecycle", () => {
|
|
143
|
+
it("removePrototype surgically deletes one false positive", async () => {
|
|
144
|
+
const { bank } = makeBank({ minSeenCount: 1 });
|
|
145
|
+
await bank.learnFromRejection("噪声内容甲甲甲甲", embedAxis0);
|
|
146
|
+
const id = bank.list()[0]?.id ?? "";
|
|
147
|
+
expect(bank.removePrototype(id)).toBe(true);
|
|
148
|
+
expect(bank.list().length).toBe(0);
|
|
149
|
+
expect(bank.removePrototype(id)).toBe(false);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("clear resets everything", async () => {
|
|
153
|
+
const { bank } = makeBank({ minSeenCount: 1 });
|
|
154
|
+
await bank.learnFromRejection("噪声内容甲甲甲甲", embedAxis0);
|
|
155
|
+
bank.clear();
|
|
156
|
+
expect(bank.list().length).toBe(0);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it("state survives reconstruction from disk; expired prototypes are swept", async () => {
|
|
160
|
+
const { bank, dir } = makeBank({ minSeenCount: 1 });
|
|
161
|
+
await bank.learnFromRejection("噪声内容甲甲甲甲", embedAxis0);
|
|
162
|
+
|
|
163
|
+
const reloaded = new NoisePrototypeBank({
|
|
164
|
+
filePath: join(dir, "bank.json"),
|
|
165
|
+
shadowLogPath: join(dir, "shadow.jsonl"),
|
|
166
|
+
});
|
|
167
|
+
expect(reloaded.list().length).toBe(1);
|
|
168
|
+
|
|
169
|
+
// Age the stored prototype past the TTL, then reload.
|
|
170
|
+
const raw = JSON.parse(readFileSync(join(dir, "bank.json"), "utf8"));
|
|
171
|
+
raw.prototypes[0].learnedAt = "2020-01-01T00:00:00.000Z";
|
|
172
|
+
writeFileSync(join(dir, "bank.json"), JSON.stringify(raw), "utf8");
|
|
173
|
+
const expiring = new NoisePrototypeBank({
|
|
174
|
+
filePath: join(dir, "bank.json"),
|
|
175
|
+
shadowLogPath: join(dir, "shadow.jsonl"),
|
|
176
|
+
});
|
|
177
|
+
expect(expiring.list().length).toBe(0);
|
|
178
|
+
});
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
describe("isProtectedContent", () => {
|
|
182
|
+
it("covers memory verbs, corrections, decisions, identity, preferences", () => {
|
|
183
|
+
for (const text of ["记住这个", "帮我记一下", "纠正一下,不对", "我决定用方案B", "我是安闲静雅", "我喜欢深色主题", "i prefer dark mode", "remember this setup"]) {
|
|
184
|
+
expect(`${text}:${isProtectedContent(text)}`).toBe(`${text}:true`);
|
|
185
|
+
}
|
|
186
|
+
for (const text of ["嗯嗯好的", "git push", "今天天气不错"]) {
|
|
187
|
+
expect(`${text}:${isProtectedContent(text)}`).toBe(`${text}:false`);
|
|
188
|
+
}
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
describe("resolveNoisePrototypeMode / shared bank resolution", () => {
|
|
193
|
+
it("defaults to shadow in production, off under NODE_ENV=test unless explicit", () => {
|
|
194
|
+
expect(resolveNoisePrototypeMode({} as NodeJS.ProcessEnv)).toBe("shadow");
|
|
195
|
+
expect(resolveNoisePrototypeMode({ NODE_ENV: "test" } as NodeJS.ProcessEnv)).toBe("off");
|
|
196
|
+
expect(resolveNoisePrototypeMode({ NODE_ENV: "test", RECALLNEST_NOISE_PROTOTYPE: "shadow" } as NodeJS.ProcessEnv)).toBe("shadow");
|
|
197
|
+
expect(resolveNoisePrototypeMode({ RECALLNEST_NOISE_PROTOTYPE: "off" } as NodeJS.ProcessEnv)).toBe("off");
|
|
198
|
+
expect(resolveNoisePrototypeMode({ RECALLNEST_NOISE_PROTOTYPE: "banana" } as NodeJS.ProcessEnv)).toBe("shadow");
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it("explicit deps win over the env-governed shared instance", () => {
|
|
202
|
+
const { bank } = makeBank();
|
|
203
|
+
expect(resolveNoisePrototypeBank(bank)).toBe(bank);
|
|
204
|
+
expect(resolveNoisePrototypeBank(null)).toBeNull();
|
|
205
|
+
// Under bun test (NODE_ENV=test, no explicit env) the shared bank is off.
|
|
206
|
+
expect(resolveNoisePrototypeBank(undefined)).toBeNull();
|
|
207
|
+
});
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
// --- integration through persistMemory ---
|
|
211
|
+
|
|
212
|
+
function makePersistDeps(bank: NoisePrototypeBank | null) {
|
|
213
|
+
const storeCalls: unknown[] = [];
|
|
214
|
+
return {
|
|
215
|
+
storeCalls,
|
|
216
|
+
deps: {
|
|
217
|
+
embedder: {
|
|
218
|
+
async embedPassage() {
|
|
219
|
+
return axisVector(0);
|
|
220
|
+
},
|
|
221
|
+
},
|
|
222
|
+
store: {
|
|
223
|
+
async store(entry: Record<string, unknown>) {
|
|
224
|
+
storeCalls.push(entry);
|
|
225
|
+
return { ...entry, id: `mem-${storeCalls.length}`, timestamp: Date.now() };
|
|
226
|
+
},
|
|
227
|
+
},
|
|
228
|
+
noisePrototypeBank: bank,
|
|
229
|
+
},
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
describe("persistMemory × noise-prototype shadow", () => {
|
|
234
|
+
it("noise_detected rejection feeds the bank; the write is still rejected", async () => {
|
|
235
|
+
const { bank } = makeBank({ minSeenCount: 1 });
|
|
236
|
+
const { deps } = makePersistDeps(bank);
|
|
237
|
+
|
|
238
|
+
const result = await persistMemory(deps as never, {
|
|
239
|
+
text: "hello world, how are you today?",
|
|
240
|
+
category: "events",
|
|
241
|
+
importance: 0.7,
|
|
242
|
+
scope: "project:npb-test",
|
|
243
|
+
source: "manual",
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
expect(result.disposition).toBe("rejected");
|
|
247
|
+
expect(result.rejectionReason).toBe("noise_detected");
|
|
248
|
+
expect(bank.list().length).toBe(1); // minSeenCount 1 → learned immediately
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
it("accepted writes run the shadow match against the write-path vector", async () => {
|
|
252
|
+
const { bank, dir } = makeBank({ minSeenCount: 1 });
|
|
253
|
+
await bank.learnFromRejection("噪声原型种子内容", embedAxis0);
|
|
254
|
+
const { deps } = makePersistDeps(bank);
|
|
255
|
+
|
|
256
|
+
const result = await persistMemory(deps as never, {
|
|
257
|
+
text: "RecallNest 的 readConsistencyInterval 已经默认为 0",
|
|
258
|
+
category: "events",
|
|
259
|
+
importance: 0.7,
|
|
260
|
+
scope: "project:npb-test",
|
|
261
|
+
source: "manual",
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
expect(result.disposition).not.toBe("rejected");
|
|
265
|
+
const log = readFileSync(join(dir, "shadow.jsonl"), "utf8");
|
|
266
|
+
expect(log).toContain("suggest_reject"); // axis-0 fake vectors always collide
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
it("bank=null disables both sides completely", async () => {
|
|
270
|
+
const { deps } = makePersistDeps(null);
|
|
271
|
+
const result = await persistMemory(deps as never, {
|
|
272
|
+
text: "hello world, how are you today?",
|
|
273
|
+
category: "events",
|
|
274
|
+
importance: 0.7,
|
|
275
|
+
scope: "project:npb-test",
|
|
276
|
+
source: "manual",
|
|
277
|
+
});
|
|
278
|
+
expect(result.disposition).toBe("rejected"); // admission unchanged
|
|
279
|
+
});
|
|
280
|
+
});
|
|
@@ -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
|
+
});
|