@theglitchking/semantic-memory 1.2.2 → 1.5.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/.claude-plugin/marketplace.json +4 -4
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +137 -0
- package/ROADMAP.md +5 -2
- package/commands/healthcheck.md +13 -11
- package/dist/{chunk-UQSASB7Q.js → chunk-2EELGTLX.js} +32 -3
- package/dist/chunk-2EELGTLX.js.map +1 -0
- package/dist/chunk-5ZKNO6ZD.js +96 -0
- package/dist/chunk-5ZKNO6ZD.js.map +1 -0
- package/dist/chunk-7M3JZOYH.js +105 -0
- package/dist/chunk-7M3JZOYH.js.map +1 -0
- package/dist/chunk-A3KYHKB5.js +176 -0
- package/dist/chunk-A3KYHKB5.js.map +1 -0
- package/dist/{chunk-CYWPDFNI.js → chunk-CSXZ7QUU.js} +4 -203
- package/dist/chunk-CSXZ7QUU.js.map +1 -0
- package/dist/chunk-FM26NFTR.js +272 -0
- package/dist/chunk-FM26NFTR.js.map +1 -0
- package/dist/chunk-NTYYS53O.js +91 -0
- package/dist/chunk-NTYYS53O.js.map +1 -0
- package/dist/{chunk-E2Z4J4MK.js → chunk-OWZWMB4P.js} +70 -2
- package/dist/chunk-OWZWMB4P.js.map +1 -0
- package/dist/chunk-S4KW7VH4.js +203 -0
- package/dist/chunk-S4KW7VH4.js.map +1 -0
- package/dist/{chunk-HODDRXJK.js → chunk-V45NJIHI.js} +4 -2
- package/dist/chunk-V45NJIHI.js.map +1 -0
- package/dist/chunk-WSU4QIPB.js +311 -0
- package/dist/chunk-WSU4QIPB.js.map +1 -0
- package/dist/cli/index.js +265 -65
- package/dist/cli/index.js.map +1 -1
- package/dist/core/index.d.ts +39 -2
- package/dist/core/index.js +17 -11
- package/dist/decay-ELJUKVQG.js +14 -0
- package/dist/dossier-2UG724LB.js +39 -0
- package/dist/healthcheck-IMB47LZE.js +19 -0
- package/dist/indexer-YW4O3VVM.js +8 -0
- package/dist/lexicon-5PXQEMUF.js +26 -0
- package/dist/lexicon-5PXQEMUF.js.map +1 -0
- package/dist/lint-XUQ7JDYX.js +16 -0
- package/dist/lint-XUQ7JDYX.js.map +1 -0
- package/dist/mcp/server.js +478 -40
- package/dist/mcp/server.js.map +1 -1
- package/dist/profile-LXUEF7TX.js +20 -0
- package/dist/profile-LXUEF7TX.js.map +1 -0
- package/dist/{schema-F3RTY6SQ.js → schema-ATFCLC6Y.js} +2 -2
- package/dist/schema-ATFCLC6Y.js.map +1 -0
- package/dist/{search-27WYAI24.js → search-AHKEFI2B.js} +2 -2
- package/dist/telemetry-GB2TEHVE.js +21 -0
- package/dist/telemetry-GB2TEHVE.js.map +1 -0
- package/hooks/vault-context.js +270 -11
- package/package.json +2 -1
- package/skills/vault-first/SKILL.md +18 -0
- package/dist/chunk-CAN2GWUG.js +0 -88
- package/dist/chunk-CAN2GWUG.js.map +0 -1
- package/dist/chunk-CYWPDFNI.js.map +0 -1
- package/dist/chunk-E2Z4J4MK.js.map +0 -1
- package/dist/chunk-HODDRXJK.js.map +0 -1
- package/dist/chunk-UQSASB7Q.js.map +0 -1
- package/dist/healthcheck-E4GI5RMZ.js +0 -14
- package/dist/indexer-KD6UTXZB.js +0 -8
- package/dist/lint-GMXQF4RW.js +0 -11
- /package/dist/{healthcheck-E4GI5RMZ.js.map → decay-ELJUKVQG.js.map} +0 -0
- /package/dist/{indexer-KD6UTXZB.js.map → dossier-2UG724LB.js.map} +0 -0
- /package/dist/{lint-GMXQF4RW.js.map → healthcheck-IMB47LZE.js.map} +0 -0
- /package/dist/{schema-F3RTY6SQ.js.map → indexer-YW4O3VVM.js.map} +0 -0
- /package/dist/{search-27WYAI24.js.map → search-AHKEFI2B.js.map} +0 -0
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
import {
|
|
2
|
+
readSelectionLog
|
|
3
|
+
} from "./chunk-NTYYS53O.js";
|
|
4
|
+
import {
|
|
5
|
+
compileLexicon,
|
|
6
|
+
findAliasConflicts
|
|
7
|
+
} from "./chunk-A3KYHKB5.js";
|
|
8
|
+
import {
|
|
9
|
+
deriveProjectRoot
|
|
10
|
+
} from "./chunk-S4KW7VH4.js";
|
|
11
|
+
import {
|
|
12
|
+
loadSchema,
|
|
13
|
+
validateNote
|
|
14
|
+
} from "./chunk-OWZWMB4P.js";
|
|
15
|
+
import {
|
|
16
|
+
computeDecay,
|
|
17
|
+
loadDecayConfig,
|
|
18
|
+
normalizeVerifiedDate
|
|
19
|
+
} from "./chunk-5ZKNO6ZD.js";
|
|
20
|
+
|
|
21
|
+
// src/core/lint.ts
|
|
22
|
+
import { readFile } from "fs/promises";
|
|
23
|
+
import { existsSync } from "fs";
|
|
24
|
+
import { basename, join } from "path";
|
|
25
|
+
import { glob } from "glob";
|
|
26
|
+
import matter from "gray-matter";
|
|
27
|
+
async function lintVault(notesPath, opts = {}) {
|
|
28
|
+
const schema = opts.schemaOverride ?? await loadSchema(notesPath);
|
|
29
|
+
const files = await glob(opts.pathGlob ?? "**/*.md", {
|
|
30
|
+
cwd: notesPath,
|
|
31
|
+
ignore: ["node_modules/**", ".semantic-sidekick-index/**", "vault.schema.yml"]
|
|
32
|
+
});
|
|
33
|
+
const findings = [];
|
|
34
|
+
const knownNames = /* @__PURE__ */ new Set();
|
|
35
|
+
const rawByPath = /* @__PURE__ */ new Map();
|
|
36
|
+
for (const rel of files) {
|
|
37
|
+
try {
|
|
38
|
+
const raw = await readFile(join(notesPath, rel), "utf-8");
|
|
39
|
+
rawByPath.set(rel, raw);
|
|
40
|
+
knownNames.add(basename(rel).replace(/\.md$/, ""));
|
|
41
|
+
} catch {
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
const codeRoot = opts.checkCodeSymbols ? resolveCodeRoot(opts.projectRoot ?? deriveProjectRoot(notesPath)) : null;
|
|
45
|
+
for (const [rel, raw] of rawByPath) {
|
|
46
|
+
findings.push(...validateNote(rel, raw, schema, { todayIso: opts.todayIso }));
|
|
47
|
+
findings.push(...findBrokenLinks(rel, raw, knownNames));
|
|
48
|
+
if (codeRoot) findings.push(...findCodeSymbolDrift(rel, raw, codeRoot));
|
|
49
|
+
}
|
|
50
|
+
if (opts.checkDecayCandidates) {
|
|
51
|
+
findings.push(...await findDecayCandidates(notesPath, rawByPath, opts.todayIso));
|
|
52
|
+
}
|
|
53
|
+
if (opts.checkAliasConflicts) {
|
|
54
|
+
for (const c of findAliasConflicts(await compileLexicon(notesPath))) {
|
|
55
|
+
findings.push({
|
|
56
|
+
path: "lexicon",
|
|
57
|
+
rule: "alias_conflicts",
|
|
58
|
+
severity: "warn",
|
|
59
|
+
message: `phrase "${c.phrase}" maps to ${c.canonicals.length} targets: ${c.canonicals.join(", ")} \u2014 pick one`
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
if (opts.checkDecoys) {
|
|
64
|
+
findings.push(...await findDecoys(notesPath, rawByPath));
|
|
65
|
+
}
|
|
66
|
+
const byRule = {
|
|
67
|
+
schema_violations: findings.filter((f) => f.rule === "schema_violations"),
|
|
68
|
+
missing_provenance: findings.filter((f) => f.rule === "missing_provenance"),
|
|
69
|
+
stale: findings.filter((f) => f.rule === "stale"),
|
|
70
|
+
broken_links: findings.filter((f) => f.rule === "broken_links"),
|
|
71
|
+
code_symbols: findings.filter((f) => f.rule === "code_symbols"),
|
|
72
|
+
decay_candidates: findings.filter((f) => f.rule === "decay_candidates"),
|
|
73
|
+
alias_conflicts: findings.filter((f) => f.rule === "alias_conflicts"),
|
|
74
|
+
decoys: findings.filter((f) => f.rule === "decoys")
|
|
75
|
+
};
|
|
76
|
+
const errors = findings.filter((f) => f.severity === "error").length;
|
|
77
|
+
const warnings = findings.filter((f) => f.severity === "warn").length;
|
|
78
|
+
return {
|
|
79
|
+
schemaPath: join(notesPath, "vault.schema.yml"),
|
|
80
|
+
findings,
|
|
81
|
+
byRule,
|
|
82
|
+
counts: { errors, warnings, filesLinted: files.length }
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
function findBrokenLinks(relPath, rawFileContent, knownNames) {
|
|
86
|
+
const { content } = matter(rawFileContent);
|
|
87
|
+
const findings = [];
|
|
88
|
+
const stripped = content.replace(/```[\s\S]*?```/g, "").replace(/`[^`]+`/g, "");
|
|
89
|
+
const re = /\[\[([^\]\|]+)(?:\|[^\]]+)?\]\]/g;
|
|
90
|
+
const seen = /* @__PURE__ */ new Set();
|
|
91
|
+
let m;
|
|
92
|
+
while ((m = re.exec(stripped)) !== null) {
|
|
93
|
+
const target = m[1].trim().split("#")[0].split("/").pop().replace(/\.md$/, "");
|
|
94
|
+
if (seen.has(target)) continue;
|
|
95
|
+
seen.add(target);
|
|
96
|
+
if (!knownNames.has(target)) {
|
|
97
|
+
findings.push({
|
|
98
|
+
path: relPath,
|
|
99
|
+
rule: "broken_links",
|
|
100
|
+
severity: "warn",
|
|
101
|
+
message: `wikilink [[${target}]] has no matching note`
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return findings;
|
|
106
|
+
}
|
|
107
|
+
var CODE_EXTENSIONS = /* @__PURE__ */ new Set([
|
|
108
|
+
"ts",
|
|
109
|
+
"tsx",
|
|
110
|
+
"js",
|
|
111
|
+
"jsx",
|
|
112
|
+
"mjs",
|
|
113
|
+
"cjs",
|
|
114
|
+
"py",
|
|
115
|
+
"rb",
|
|
116
|
+
"go",
|
|
117
|
+
"rs",
|
|
118
|
+
"java",
|
|
119
|
+
"kt",
|
|
120
|
+
"c",
|
|
121
|
+
"h",
|
|
122
|
+
"cpp",
|
|
123
|
+
"hpp",
|
|
124
|
+
"cs",
|
|
125
|
+
"php",
|
|
126
|
+
"swift",
|
|
127
|
+
"scala",
|
|
128
|
+
"sh",
|
|
129
|
+
"bash",
|
|
130
|
+
"sql",
|
|
131
|
+
"json",
|
|
132
|
+
"yaml",
|
|
133
|
+
"yml",
|
|
134
|
+
"toml",
|
|
135
|
+
"css",
|
|
136
|
+
"scss",
|
|
137
|
+
"html",
|
|
138
|
+
"vue",
|
|
139
|
+
"svelte",
|
|
140
|
+
"md"
|
|
141
|
+
]);
|
|
142
|
+
function resolveCodeRoot(root) {
|
|
143
|
+
if (!root || !existsSync(root)) return null;
|
|
144
|
+
const markers = ["package.json", ".git", "src", "lib", "pyproject.toml", "go.mod", "Cargo.toml"];
|
|
145
|
+
return markers.some((m) => existsSync(join(root, m))) ? root : null;
|
|
146
|
+
}
|
|
147
|
+
function findCodeSymbolDrift(relPath, rawFileContent, codeRoot) {
|
|
148
|
+
const { content } = matter(rawFileContent);
|
|
149
|
+
const withoutFences = content.replace(/```[\s\S]*?```/g, "");
|
|
150
|
+
const findings = [];
|
|
151
|
+
const seen = /* @__PURE__ */ new Set();
|
|
152
|
+
const re = /`([^`\n]+)`/g;
|
|
153
|
+
let m;
|
|
154
|
+
while ((m = re.exec(withoutFences)) !== null) {
|
|
155
|
+
let token = m[1].trim();
|
|
156
|
+
if (!looksLikeRepoPath(token)) continue;
|
|
157
|
+
token = token.replace(/^\.\//, "");
|
|
158
|
+
if (seen.has(token)) continue;
|
|
159
|
+
seen.add(token);
|
|
160
|
+
const firstSeg = token.split("/")[0];
|
|
161
|
+
if (!existsSync(join(codeRoot, firstSeg))) continue;
|
|
162
|
+
if (!existsSync(join(codeRoot, token))) {
|
|
163
|
+
findings.push({
|
|
164
|
+
path: relPath,
|
|
165
|
+
rule: "code_symbols",
|
|
166
|
+
severity: "warn",
|
|
167
|
+
message: `inline code path \`${token}\` does not exist under the project root (stale reference?)`
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
return findings;
|
|
172
|
+
}
|
|
173
|
+
function looksLikeRepoPath(token) {
|
|
174
|
+
if (!token.includes("/")) return false;
|
|
175
|
+
if (/\s/.test(token)) return false;
|
|
176
|
+
if (token.includes("://")) return false;
|
|
177
|
+
if (/[*?\[\]{}()<>|]/.test(token)) return false;
|
|
178
|
+
if (token.startsWith("/") || token.startsWith("~")) return false;
|
|
179
|
+
const normalized = token.replace(/^\.\//, "");
|
|
180
|
+
if (normalized.startsWith("..")) return false;
|
|
181
|
+
const last = normalized.split("/").pop() ?? "";
|
|
182
|
+
const dot = last.lastIndexOf(".");
|
|
183
|
+
if (dot <= 0) return false;
|
|
184
|
+
const ext = last.slice(dot + 1).toLowerCase();
|
|
185
|
+
return CODE_EXTENSIONS.has(ext);
|
|
186
|
+
}
|
|
187
|
+
var DECAY_CANDIDATE_THRESHOLD = 0.5;
|
|
188
|
+
async function findDecayCandidates(notesPath, rawByPath, todayIso) {
|
|
189
|
+
const events = await readSelectionLog(notesPath);
|
|
190
|
+
if (events.length === 0) return [];
|
|
191
|
+
const retrieved = /* @__PURE__ */ new Map();
|
|
192
|
+
for (const e of events) {
|
|
193
|
+
if (e.kind === "search") {
|
|
194
|
+
for (const r of e.results) retrieved.set(r.path, (retrieved.get(r.path) ?? 0) + 1);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
if (retrieved.size === 0) return [];
|
|
198
|
+
const config = loadDecayConfig(notesPath);
|
|
199
|
+
if (!config.enabled) return [];
|
|
200
|
+
const nowMs = todayIso ? Date.parse(todayIso) : void 0;
|
|
201
|
+
const findings = [];
|
|
202
|
+
for (const [rel, count] of retrieved) {
|
|
203
|
+
const raw = rawByPath.get(rel);
|
|
204
|
+
if (!raw) continue;
|
|
205
|
+
const { data: fm } = matter(raw);
|
|
206
|
+
const decay = computeDecay({
|
|
207
|
+
type: typeof fm.type === "string" ? fm.type : void 0,
|
|
208
|
+
last_verified: normalizeVerifiedDate(fm.last_verified),
|
|
209
|
+
evergreen: fm.evergreen === true,
|
|
210
|
+
config,
|
|
211
|
+
nowMs
|
|
212
|
+
});
|
|
213
|
+
if (decay.multiplier <= DECAY_CANDIDATE_THRESHOLD) {
|
|
214
|
+
findings.push({
|
|
215
|
+
path: rel,
|
|
216
|
+
rule: "decay_candidates",
|
|
217
|
+
severity: "warn",
|
|
218
|
+
message: `retrieved ${count}\xD7 recently but decayed to ${decay.multiplier.toFixed(2)} (age ${Math.round(decay.age_days)}d) \u2014 verify_note or revise`
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
findings.sort((a, b) => (retrieved.get(b.path) ?? 0) - (retrieved.get(a.path) ?? 0));
|
|
223
|
+
return findings;
|
|
224
|
+
}
|
|
225
|
+
var DECOY_MIN_RETRIEVALS = 3;
|
|
226
|
+
async function findDecoys(notesPath, rawByPath) {
|
|
227
|
+
const events = await readSelectionLog(notesPath);
|
|
228
|
+
if (events.length === 0) return [];
|
|
229
|
+
const retrieved = /* @__PURE__ */ new Map();
|
|
230
|
+
const cited = /* @__PURE__ */ new Set();
|
|
231
|
+
for (const e of events) {
|
|
232
|
+
if (e.kind === "search") {
|
|
233
|
+
for (const r of e.results) retrieved.set(r.path, (retrieved.get(r.path) ?? 0) + 1);
|
|
234
|
+
} else if (e.kind === "selection") {
|
|
235
|
+
cited.add(e.note_path);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
const findings = [];
|
|
239
|
+
for (const [rel, count] of retrieved) {
|
|
240
|
+
if (count < DECOY_MIN_RETRIEVALS) continue;
|
|
241
|
+
if (cited.has(rel)) continue;
|
|
242
|
+
if (!rawByPath.has(rel)) continue;
|
|
243
|
+
findings.push({
|
|
244
|
+
path: rel,
|
|
245
|
+
rule: "decoys",
|
|
246
|
+
severity: "warn",
|
|
247
|
+
message: `retrieved ${count}\xD7 but never cited \u2014 decoy noise, or a note answers wrongly skip? (surfaced, not auto-down-ranked)`
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
findings.sort((a, b) => (retrieved.get(b.path) ?? 0) - (retrieved.get(a.path) ?? 0));
|
|
251
|
+
return findings;
|
|
252
|
+
}
|
|
253
|
+
function formatLintReport(report, opts = {}) {
|
|
254
|
+
const target = opts.rule ? report.byRule[opts.rule] : report.findings;
|
|
255
|
+
if (target.length === 0) {
|
|
256
|
+
return `clean: 0 findings across ${report.counts.filesLinted} files`;
|
|
257
|
+
}
|
|
258
|
+
const lines = [
|
|
259
|
+
`${target.length} finding${target.length === 1 ? "" : "s"} (errors=${report.counts.errors}, warnings=${report.counts.warnings}, files=${report.counts.filesLinted})`,
|
|
260
|
+
""
|
|
261
|
+
];
|
|
262
|
+
for (const f of target) {
|
|
263
|
+
lines.push(`[${f.severity}] ${f.rule}: ${f.path} \u2014 ${f.message}`);
|
|
264
|
+
}
|
|
265
|
+
return lines.join("\n");
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
export {
|
|
269
|
+
lintVault,
|
|
270
|
+
formatLintReport
|
|
271
|
+
};
|
|
272
|
+
//# sourceMappingURL=chunk-FM26NFTR.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/core/lint.ts"],"sourcesContent":["import { readFile } from \"node:fs/promises\";\nimport { existsSync } from \"node:fs\";\nimport { basename, join } from \"node:path\";\nimport { glob } from \"glob\";\nimport matter from \"gray-matter\";\nimport { loadSchema, validateNote, type LintFinding, type VaultSchema } from \"./schema.js\";\nimport { deriveProjectRoot } from \"./session.js\";\nimport { computeDecay, loadDecayConfig, normalizeVerifiedDate } from \"./decay.js\";\nimport { readSelectionLog } from \"./telemetry.js\";\nimport { compileLexicon, findAliasConflicts } from \"./lexicon.js\";\n\nexport interface LintReport {\n schemaPath: string;\n findings: LintFinding[];\n byRule: {\n schema_violations: LintFinding[];\n missing_provenance: LintFinding[];\n stale: LintFinding[];\n broken_links: LintFinding[];\n code_symbols: LintFinding[];\n decay_candidates: LintFinding[];\n alias_conflicts: LintFinding[];\n decoys: LintFinding[];\n };\n counts: {\n errors: number;\n warnings: number;\n filesLinted: number;\n };\n}\n\nexport async function lintVault(\n notesPath: string,\n opts: {\n pathGlob?: string;\n schemaOverride?: VaultSchema;\n todayIso?: string;\n /**\n * When true, run the code-symbol drift check: flag inline-code file-path\n * references in notes that no longer exist under the project root. Opt-in\n * (off by default) so the standard report and the healthcheck slow tier stay\n * quiet and byte-stable. Fails open when the project root is not a code repo.\n */\n checkCodeSymbols?: boolean;\n /** Override the project root for code-symbol resolution (default: derived from notesPath). */\n projectRoot?: string;\n /**\n * When true, run the decay-candidates check: cross-reference the selection\n * log (notes that appeared in recent search results) against each note's\n * current decay multiplier, and flag notes that are frequently retrieved yet\n * heavily decayed (\"relevant but going stale — verify or revise\"). Opt-in;\n * a silent no-op when no selection log exists.\n */\n checkDecayCandidates?: boolean;\n /**\n * When true, run the alias-conflict check: flag lexicon phrases that map to\n * more than one canonical target (a wrong binding misroutes queries). Opt-in;\n * no-op when the vault has no lexicon.\n */\n checkAliasConflicts?: boolean;\n /**\n * When true, run the decoys check: flag notes that were retrieved often (per\n * the selection log) but NEVER cited in an answer. Surfaced for a human to\n * judge — a decoy might be genuine noise OR a note the answers were wrong to\n * skip; usage-feedback ranking (v1.5) never auto-down-ranks on this ambiguous\n * signal (decision Q5). Opt-in; a no-op with no selection log.\n */\n checkDecoys?: boolean;\n } = {}\n): Promise<LintReport> {\n const schema = opts.schemaOverride ?? (await loadSchema(notesPath));\n const files = await glob(opts.pathGlob ?? \"**/*.md\", {\n cwd: notesPath,\n ignore: [\"node_modules/**\", \".semantic-sidekick-index/**\", \"vault.schema.yml\"],\n });\n\n const findings: LintFinding[] = [];\n // Build known-note set first, to make broken-link detection O(1) per link.\n const knownNames = new Set<string>();\n const rawByPath = new Map<string, string>();\n for (const rel of files) {\n try {\n const raw = await readFile(join(notesPath, rel), \"utf-8\");\n rawByPath.set(rel, raw);\n // Wikilink names match on basename-without-ext (obsidian-style)\n knownNames.add(basename(rel).replace(/\\.md$/, \"\"));\n } catch {\n /* skip */\n }\n }\n\n // Code-symbol drift is opt-in and only meaningful against a real code repo.\n const codeRoot = opts.checkCodeSymbols\n ? resolveCodeRoot(opts.projectRoot ?? deriveProjectRoot(notesPath))\n : null;\n\n for (const [rel, raw] of rawByPath) {\n findings.push(...validateNote(rel, raw, schema, { todayIso: opts.todayIso }));\n findings.push(...findBrokenLinks(rel, raw, knownNames));\n if (codeRoot) findings.push(...findCodeSymbolDrift(rel, raw, codeRoot));\n }\n\n if (opts.checkDecayCandidates) {\n findings.push(...(await findDecayCandidates(notesPath, rawByPath, opts.todayIso)));\n }\n\n if (opts.checkAliasConflicts) {\n for (const c of findAliasConflicts(await compileLexicon(notesPath))) {\n findings.push({\n path: \"lexicon\",\n rule: \"alias_conflicts\",\n severity: \"warn\",\n message: `phrase \"${c.phrase}\" maps to ${c.canonicals.length} targets: ${c.canonicals.join(\", \")} — pick one`,\n });\n }\n }\n\n if (opts.checkDecoys) {\n findings.push(...(await findDecoys(notesPath, rawByPath)));\n }\n\n const byRule = {\n schema_violations: findings.filter((f) => f.rule === \"schema_violations\"),\n missing_provenance: findings.filter((f) => f.rule === \"missing_provenance\"),\n stale: findings.filter((f) => f.rule === \"stale\"),\n broken_links: findings.filter((f) => f.rule === \"broken_links\"),\n code_symbols: findings.filter((f) => f.rule === \"code_symbols\"),\n decay_candidates: findings.filter((f) => f.rule === \"decay_candidates\"),\n alias_conflicts: findings.filter((f) => f.rule === \"alias_conflicts\"),\n decoys: findings.filter((f) => f.rule === \"decoys\"),\n };\n const errors = findings.filter((f) => f.severity === \"error\").length;\n const warnings = findings.filter((f) => f.severity === \"warn\").length;\n\n return {\n schemaPath: join(notesPath, \"vault.schema.yml\"),\n findings,\n byRule,\n counts: { errors, warnings, filesLinted: files.length },\n };\n}\n\nfunction findBrokenLinks(\n relPath: string,\n rawFileContent: string,\n knownNames: Set<string>\n): LintFinding[] {\n const { content } = matter(rawFileContent);\n const findings: LintFinding[] = [];\n // Match [[target]] and [[target|alias]] — skip fenced code and inline code.\n const stripped = content\n .replace(/```[\\s\\S]*?```/g, \"\")\n .replace(/`[^`]+`/g, \"\");\n const re = /\\[\\[([^\\]\\|]+)(?:\\|[^\\]]+)?\\]\\]/g;\n const seen = new Set<string>();\n let m: RegExpExecArray | null;\n while ((m = re.exec(stripped)) !== null) {\n const target = m[1].trim().split(\"#\")[0].split(\"/\").pop()!.replace(/\\.md$/, \"\");\n if (seen.has(target)) continue;\n seen.add(target);\n if (!knownNames.has(target)) {\n findings.push({\n path: relPath,\n rule: \"broken_links\",\n severity: \"warn\",\n message: `wikilink [[${target}]] has no matching note`,\n });\n }\n }\n return findings;\n}\n\n/** File extensions we treat as \"this looks like a source/doc file reference\". */\nconst CODE_EXTENSIONS = new Set([\n \"ts\", \"tsx\", \"js\", \"jsx\", \"mjs\", \"cjs\", \"py\", \"rb\", \"go\", \"rs\", \"java\", \"kt\",\n \"c\", \"h\", \"cpp\", \"hpp\", \"cs\", \"php\", \"swift\", \"scala\", \"sh\", \"bash\", \"sql\",\n \"json\", \"yaml\", \"yml\", \"toml\", \"css\", \"scss\", \"html\", \"vue\", \"svelte\", \"md\",\n]);\n\n/**\n * Decide whether `root` is a real code repository worth checking paths against.\n * Fails open (returns null) for non-code roots so the check is a silent no-op\n * outside a project — e.g. a standalone vault, or when babel-fish isn't present.\n */\nfunction resolveCodeRoot(root: string): string | null {\n if (!root || !existsSync(root)) return null;\n const markers = [\"package.json\", \".git\", \"src\", \"lib\", \"pyproject.toml\", \"go.mod\", \"Cargo.toml\"];\n return markers.some((m) => existsSync(join(root, m))) ? root : null;\n}\n\n/**\n * Code-symbol drift: scan a note's inline-code spans for repo-relative file-path\n * references and flag ones whose first path segment IS a real directory in the repo\n * but whose full path no longer exists — i.e. a stale reference to a moved/deleted\n * file. Anchoring on an existing first segment keeps false positives low: paths\n * belonging to other repos (whose top-level dir isn't present here) are skipped.\n *\n * Scope note: this validates *path* references. Fine-grained symbol-name checking\n * (function/class identifiers) needs a real symbol index and is planned for the\n * v1.4 lexicon arc, which will extend this same `code_symbols` rule.\n */\nfunction findCodeSymbolDrift(relPath: string, rawFileContent: string, codeRoot: string): LintFinding[] {\n const { content } = matter(rawFileContent);\n // Inline-code spans only (single backtick). Fenced blocks are illustrative and\n // over-match. Strip fenced blocks first so their backticks don't confuse the span regex.\n const withoutFences = content.replace(/```[\\s\\S]*?```/g, \"\");\n const findings: LintFinding[] = [];\n const seen = new Set<string>();\n const re = /`([^`\\n]+)`/g;\n let m: RegExpExecArray | null;\n while ((m = re.exec(withoutFences)) !== null) {\n let token = m[1].trim();\n if (!looksLikeRepoPath(token)) continue;\n token = token.replace(/^\\.\\//, \"\");\n if (seen.has(token)) continue;\n seen.add(token);\n const firstSeg = token.split(\"/\")[0];\n // Only judge paths anchored in a directory that actually exists in this repo.\n if (!existsSync(join(codeRoot, firstSeg))) continue;\n if (!existsSync(join(codeRoot, token))) {\n findings.push({\n path: relPath,\n rule: \"code_symbols\",\n severity: \"warn\",\n message: `inline code path \\`${token}\\` does not exist under the project root (stale reference?)`,\n });\n }\n }\n return findings;\n}\n\nfunction looksLikeRepoPath(token: string): boolean {\n if (!token.includes(\"/\")) return false;\n if (/\\s/.test(token)) return false; // commands, prose\n if (token.includes(\"://\")) return false; // URLs\n if (/[*?\\[\\]{}()<>|]/.test(token)) return false; // globs / shell / placeholders\n if (token.startsWith(\"/\") || token.startsWith(\"~\")) return false; // absolute / home\n const normalized = token.replace(/^\\.\\//, \"\");\n if (normalized.startsWith(\"..\")) return false; // escapes the repo\n const last = normalized.split(\"/\").pop() ?? \"\";\n const dot = last.lastIndexOf(\".\");\n if (dot <= 0) return false; // no extension → likely a dir or a symbol like a/b\n const ext = last.slice(dot + 1).toLowerCase();\n return CODE_EXTENSIONS.has(ext);\n}\n\n/** Multiplier at or below which a frequently-retrieved note is worth surfacing. */\nconst DECAY_CANDIDATE_THRESHOLD = 0.5;\n\n/**\n * Decay-candidates: notes that keep showing up in search results but have decayed.\n * Reads the selection log (search events record the paths each query returned),\n * computes each retrieved note's current decay multiplier from its frontmatter,\n * and flags those at/below the threshold — \"this keeps being relevant but is\n * going stale; verify or revise.\" Index-free (no embedder); a no-op when there's\n * no selection log yet.\n */\nasync function findDecayCandidates(\n notesPath: string,\n rawByPath: Map<string, string>,\n todayIso?: string\n): Promise<LintFinding[]> {\n const events = await readSelectionLog(notesPath);\n if (events.length === 0) return [];\n\n // Count how often each path was retrieved (appeared in a search's results).\n const retrieved = new Map<string, number>();\n for (const e of events) {\n if (e.kind === \"search\") {\n for (const r of e.results) retrieved.set(r.path, (retrieved.get(r.path) ?? 0) + 1);\n }\n }\n if (retrieved.size === 0) return [];\n\n const config = loadDecayConfig(notesPath);\n if (!config.enabled) return [];\n const nowMs = todayIso ? Date.parse(todayIso) : undefined;\n\n const findings: LintFinding[] = [];\n for (const [rel, count] of retrieved) {\n const raw = rawByPath.get(rel);\n if (!raw) continue; // retrieved path no longer in the vault — skip\n const { data: fm } = matter(raw);\n const decay = computeDecay({\n type: typeof fm.type === \"string\" ? fm.type : undefined,\n last_verified: normalizeVerifiedDate(fm.last_verified),\n evergreen: fm.evergreen === true,\n config,\n nowMs,\n });\n if (decay.multiplier <= DECAY_CANDIDATE_THRESHOLD) {\n findings.push({\n path: rel,\n rule: \"decay_candidates\",\n severity: \"warn\",\n message: `retrieved ${count}× recently but decayed to ${decay.multiplier.toFixed(2)} (age ${Math.round(decay.age_days)}d) — verify_note or revise`,\n });\n }\n }\n // Most-retrieved first — the ones worth re-verifying soonest.\n findings.sort((a, b) => (retrieved.get(b.path) ?? 0) - (retrieved.get(a.path) ?? 0));\n return findings;\n}\n\n/** A note must be retrieved at least this many times before \"never cited\" is signal. */\nconst DECOY_MIN_RETRIEVALS = 3;\n\n/**\n * Decoys (v1.5 Phase 9): notes retrieved ≥ DECOY_MIN_RETRIEVALS times that were\n * NEVER cited. Surfaced only — usage-feedback ranking never auto-down-ranks on\n * this ambiguous signal (a decoy might be genuine noise, or a note the answers\n * were wrong to skip). A human decides; the lint just points.\n */\nasync function findDecoys(notesPath: string, rawByPath: Map<string, string>): Promise<LintFinding[]> {\n const events = await readSelectionLog(notesPath);\n if (events.length === 0) return [];\n\n const retrieved = new Map<string, number>();\n const cited = new Set<string>();\n for (const e of events) {\n if (e.kind === \"search\") {\n for (const r of e.results) retrieved.set(r.path, (retrieved.get(r.path) ?? 0) + 1);\n } else if (e.kind === \"selection\") {\n cited.add(e.note_path);\n }\n }\n\n const findings: LintFinding[] = [];\n for (const [rel, count] of retrieved) {\n if (count < DECOY_MIN_RETRIEVALS) continue;\n if (cited.has(rel)) continue;\n if (!rawByPath.has(rel)) continue; // retrieved path no longer in the vault — skip\n findings.push({\n path: rel,\n rule: \"decoys\",\n severity: \"warn\",\n message: `retrieved ${count}× but never cited — decoy noise, or a note answers wrongly skip? (surfaced, not auto-down-ranked)`,\n });\n }\n findings.sort((a, b) => (retrieved.get(b.path) ?? 0) - (retrieved.get(a.path) ?? 0));\n return findings;\n}\n\nexport function formatLintReport(report: LintReport, opts: { rule?: keyof LintReport[\"byRule\"] } = {}): string {\n const target = opts.rule ? report.byRule[opts.rule] : report.findings;\n if (target.length === 0) {\n return `clean: 0 findings across ${report.counts.filesLinted} files`;\n }\n const lines = [\n `${target.length} finding${target.length === 1 ? \"\" : \"s\"} (errors=${report.counts.errors}, warnings=${report.counts.warnings}, files=${report.counts.filesLinted})`,\n \"\",\n ];\n for (const f of target) {\n lines.push(`[${f.severity}] ${f.rule}: ${f.path} — ${f.message}`);\n }\n return lines.join(\"\\n\");\n}\n"],"mappings":"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","names":[]}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import {
|
|
2
|
+
deriveSessionDir
|
|
3
|
+
} from "./chunk-S4KW7VH4.js";
|
|
4
|
+
|
|
5
|
+
// src/core/telemetry.ts
|
|
6
|
+
import { appendFile, readFile, mkdir } from "fs/promises";
|
|
7
|
+
import { existsSync, readFileSync } from "fs";
|
|
8
|
+
import { join } from "path";
|
|
9
|
+
import { parse as parseYaml } from "yaml";
|
|
10
|
+
var LOG_FILE = "selection.jsonl";
|
|
11
|
+
var CORRELATION_WINDOW_MS = 6e4;
|
|
12
|
+
function selectionLogPath(vaultPath) {
|
|
13
|
+
return join(deriveSessionDir(vaultPath), LOG_FILE);
|
|
14
|
+
}
|
|
15
|
+
function telemetryEnabled(vaultPath) {
|
|
16
|
+
try {
|
|
17
|
+
const schemaPath = join(vaultPath, "vault.schema.yml");
|
|
18
|
+
if (!existsSync(schemaPath)) return true;
|
|
19
|
+
const parsed = parseYaml(readFileSync(schemaPath, "utf-8"));
|
|
20
|
+
const e = parsed?.telemetry?.enabled;
|
|
21
|
+
return typeof e === "boolean" ? e : true;
|
|
22
|
+
} catch {
|
|
23
|
+
return true;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
async function appendEvent(vaultPath, event, nowIso) {
|
|
27
|
+
try {
|
|
28
|
+
if (!telemetryEnabled(vaultPath)) return;
|
|
29
|
+
const dir = deriveSessionDir(vaultPath);
|
|
30
|
+
await mkdir(dir, { recursive: true });
|
|
31
|
+
const line = JSON.stringify({ ts: nowIso ?? (/* @__PURE__ */ new Date()).toISOString(), ...event });
|
|
32
|
+
await appendFile(selectionLogPath(vaultPath), line + "\n", "utf-8");
|
|
33
|
+
} catch {
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
var recentSearch = /* @__PURE__ */ new Map();
|
|
37
|
+
function recordSearchForCorrelation(vaultPath, paths, nowMs) {
|
|
38
|
+
recentSearch.set(vaultPath, { paths: new Set(paths), atMs: nowMs ?? Date.now() });
|
|
39
|
+
}
|
|
40
|
+
function wasRecentlySearched(vaultPath, notePath, nowMs) {
|
|
41
|
+
const r = recentSearch.get(vaultPath);
|
|
42
|
+
if (!r) return false;
|
|
43
|
+
if ((nowMs ?? Date.now()) - r.atMs > CORRELATION_WINDOW_MS) return false;
|
|
44
|
+
return r.paths.has(notePath);
|
|
45
|
+
}
|
|
46
|
+
async function readSelectionLog(vaultPath, opts = {}) {
|
|
47
|
+
try {
|
|
48
|
+
const p = selectionLogPath(vaultPath);
|
|
49
|
+
if (!existsSync(p)) return [];
|
|
50
|
+
const raw = await readFile(p, "utf-8");
|
|
51
|
+
const events = raw.split("\n").filter((l) => l.trim()).map((l) => {
|
|
52
|
+
try {
|
|
53
|
+
return JSON.parse(l);
|
|
54
|
+
} catch {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
}).filter((e) => e !== null);
|
|
58
|
+
return opts.limit ? events.slice(-opts.limit) : events;
|
|
59
|
+
} catch {
|
|
60
|
+
return [];
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
async function computeSelectionStats(vaultPath) {
|
|
64
|
+
const events = await readSelectionLog(vaultPath);
|
|
65
|
+
const citedPaths = {};
|
|
66
|
+
const retrieved = /* @__PURE__ */ new Set();
|
|
67
|
+
let searches = 0;
|
|
68
|
+
let selections = 0;
|
|
69
|
+
for (const e of events) {
|
|
70
|
+
if (e.kind === "search") {
|
|
71
|
+
searches++;
|
|
72
|
+
for (const r of e.results) retrieved.add(r.path);
|
|
73
|
+
} else if (e.kind === "selection") {
|
|
74
|
+
selections++;
|
|
75
|
+
citedPaths[e.note_path] = (citedPaths[e.note_path] ?? 0) + 1;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
const retrievedNeverCited = [...retrieved].filter((p) => !(p in citedPaths)).sort();
|
|
79
|
+
return { searches, selections, citedPaths, retrievedNeverCited };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export {
|
|
83
|
+
selectionLogPath,
|
|
84
|
+
telemetryEnabled,
|
|
85
|
+
appendEvent,
|
|
86
|
+
recordSearchForCorrelation,
|
|
87
|
+
wasRecentlySearched,
|
|
88
|
+
readSelectionLog,
|
|
89
|
+
computeSelectionStats
|
|
90
|
+
};
|
|
91
|
+
//# sourceMappingURL=chunk-NTYYS53O.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/core/telemetry.ts"],"sourcesContent":["import { appendFile, readFile, mkdir } from \"node:fs/promises\";\nimport { existsSync, readFileSync } from \"node:fs\";\nimport { join } from \"node:path\";\nimport { parse as parseYaml } from \"yaml\";\nimport { deriveSessionDir } from \"./session.js\";\n\n/**\n * Selection-logging telemetry (v1.3.1).\n *\n * Records what retrieval actually returned and which note the answer used, so a\n * later release (v1.4 usage-feedback ranking) can learn which knowledge is\n * load-bearing. Strictly local, append-only JSONL, fire-and-forget, opt-out.\n *\n * NO NETWORK: this module performs local filesystem I/O only, and never opens a\n * socket or issues a remote request — a unit test greps this file to keep it that way.\n */\n\nconst LOG_FILE = \"selection.jsonl\";\nconst CORRELATION_WINDOW_MS = 60_000;\n\nexport interface SearchEvent {\n kind: \"search\";\n tool: string;\n query: string;\n results: { path: string; score: number; decay?: number }[];\n}\n\nexport interface SelectionEvent {\n kind: \"selection\";\n note_path: string;\n via: string;\n /** True when this selection followed a recent search that returned this path. */\n correlated?: boolean;\n}\n\nexport type TelemetryEvent = (SearchEvent | SelectionEvent) & { ts?: string };\n\n/** Absolute path to the selection log for a given vault. */\nexport function selectionLogPath(vaultPath: string): string {\n return join(deriveSessionDir(vaultPath), LOG_FILE);\n}\n\n/**\n * Whether telemetry is enabled. Reads `telemetry.enabled` from vault.schema.yml;\n * defaults true (opt-out, not opt-in). Fails open to enabled only if the schema\n * is unreadable — a missing `telemetry` block means \"use the default (true)\".\n */\nexport function telemetryEnabled(vaultPath: string): boolean {\n try {\n const schemaPath = join(vaultPath, \"vault.schema.yml\");\n if (!existsSync(schemaPath)) return true;\n const parsed = parseYaml(readFileSync(schemaPath, \"utf-8\")) as { telemetry?: { enabled?: boolean } } | null;\n const e = parsed?.telemetry?.enabled;\n return typeof e === \"boolean\" ? e : true;\n } catch {\n return true;\n }\n}\n\n/**\n * Append one event. Fire-and-forget: respects the opt-out and NEVER throws — a\n * telemetry failure must not affect the tool call that triggered it.\n */\nexport async function appendEvent(vaultPath: string, event: TelemetryEvent, nowIso?: string): Promise<void> {\n try {\n if (!telemetryEnabled(vaultPath)) return;\n const dir = deriveSessionDir(vaultPath);\n await mkdir(dir, { recursive: true });\n const line = JSON.stringify({ ts: nowIso ?? new Date().toISOString(), ...event });\n await appendFile(selectionLogPath(vaultPath), line + \"\\n\", \"utf-8\");\n } catch {\n // fire-and-forget\n }\n}\n\n// --- In-process correlation ring ---\n// Remembers the most recent search's top-k paths per vault so a subsequent\n// read/synthesize can be attributed to it (best-effort, time-bounded).\n\nconst recentSearch = new Map<string, { paths: Set<string>; atMs: number }>();\n\nexport function recordSearchForCorrelation(vaultPath: string, paths: string[], nowMs?: number): void {\n recentSearch.set(vaultPath, { paths: new Set(paths), atMs: nowMs ?? Date.now() });\n}\n\nexport function wasRecentlySearched(vaultPath: string, notePath: string, nowMs?: number): boolean {\n const r = recentSearch.get(vaultPath);\n if (!r) return false;\n if ((nowMs ?? Date.now()) - r.atMs > CORRELATION_WINDOW_MS) return false;\n return r.paths.has(notePath);\n}\n\n/** Read the selection log (oldest→newest). Returns [] if absent/unreadable. */\nexport async function readSelectionLog(vaultPath: string, opts: { limit?: number } = {}): Promise<TelemetryEvent[]> {\n try {\n const p = selectionLogPath(vaultPath);\n if (!existsSync(p)) return [];\n const raw = await readFile(p, \"utf-8\");\n const events = raw\n .split(\"\\n\")\n .filter((l) => l.trim())\n .map((l) => {\n try {\n return JSON.parse(l) as TelemetryEvent;\n } catch {\n return null;\n }\n })\n .filter((e): e is TelemetryEvent => e !== null);\n return opts.limit ? events.slice(-opts.limit) : events;\n } catch {\n return [];\n }\n}\n\n/** For CLI/analysis: cited-vs-retrieved rollup over the log. */\nexport interface SelectionStats {\n searches: number;\n selections: number;\n citedPaths: Record<string, number>;\n retrievedNeverCited: string[];\n}\n\nexport async function computeSelectionStats(vaultPath: string): Promise<SelectionStats> {\n const events = await readSelectionLog(vaultPath);\n const citedPaths: Record<string, number> = {};\n const retrieved = new Set<string>();\n let searches = 0;\n let selections = 0;\n for (const e of events) {\n if (e.kind === \"search\") {\n searches++;\n for (const r of e.results) retrieved.add(r.path);\n } else if (e.kind === \"selection\") {\n selections++;\n citedPaths[e.note_path] = (citedPaths[e.note_path] ?? 0) + 1;\n }\n }\n const retrievedNeverCited = [...retrieved].filter((p) => !(p in citedPaths)).sort();\n return { searches, selections, citedPaths, retrievedNeverCited };\n}\n"],"mappings":";;;;;AAAA,SAAS,YAAY,UAAU,aAAa;AAC5C,SAAS,YAAY,oBAAoB;AACzC,SAAS,YAAY;AACrB,SAAS,SAAS,iBAAiB;AAcnC,IAAM,WAAW;AACjB,IAAM,wBAAwB;AAoBvB,SAAS,iBAAiB,WAA2B;AAC1D,SAAO,KAAK,iBAAiB,SAAS,GAAG,QAAQ;AACnD;AAOO,SAAS,iBAAiB,WAA4B;AAC3D,MAAI;AACF,UAAM,aAAa,KAAK,WAAW,kBAAkB;AACrD,QAAI,CAAC,WAAW,UAAU,EAAG,QAAO;AACpC,UAAM,SAAS,UAAU,aAAa,YAAY,OAAO,CAAC;AAC1D,UAAM,IAAI,QAAQ,WAAW;AAC7B,WAAO,OAAO,MAAM,YAAY,IAAI;AAAA,EACtC,QAAQ;AACN,WAAO;AAAA,EACT;AACF;AAMA,eAAsB,YAAY,WAAmB,OAAuB,QAAgC;AAC1G,MAAI;AACF,QAAI,CAAC,iBAAiB,SAAS,EAAG;AAClC,UAAM,MAAM,iBAAiB,SAAS;AACtC,UAAM,MAAM,KAAK,EAAE,WAAW,KAAK,CAAC;AACpC,UAAM,OAAO,KAAK,UAAU,EAAE,IAAI,WAAU,oBAAI,KAAK,GAAE,YAAY,GAAG,GAAG,MAAM,CAAC;AAChF,UAAM,WAAW,iBAAiB,SAAS,GAAG,OAAO,MAAM,OAAO;AAAA,EACpE,QAAQ;AAAA,EAER;AACF;AAMA,IAAM,eAAe,oBAAI,IAAkD;AAEpE,SAAS,2BAA2B,WAAmB,OAAiB,OAAsB;AACnG,eAAa,IAAI,WAAW,EAAE,OAAO,IAAI,IAAI,KAAK,GAAG,MAAM,SAAS,KAAK,IAAI,EAAE,CAAC;AAClF;AAEO,SAAS,oBAAoB,WAAmB,UAAkB,OAAyB;AAChG,QAAM,IAAI,aAAa,IAAI,SAAS;AACpC,MAAI,CAAC,EAAG,QAAO;AACf,OAAK,SAAS,KAAK,IAAI,KAAK,EAAE,OAAO,sBAAuB,QAAO;AACnE,SAAO,EAAE,MAAM,IAAI,QAAQ;AAC7B;AAGA,eAAsB,iBAAiB,WAAmB,OAA2B,CAAC,GAA8B;AAClH,MAAI;AACF,UAAM,IAAI,iBAAiB,SAAS;AACpC,QAAI,CAAC,WAAW,CAAC,EAAG,QAAO,CAAC;AAC5B,UAAM,MAAM,MAAM,SAAS,GAAG,OAAO;AACrC,UAAM,SAAS,IACZ,MAAM,IAAI,EACV,OAAO,CAAC,MAAM,EAAE,KAAK,CAAC,EACtB,IAAI,CAAC,MAAM;AACV,UAAI;AACF,eAAO,KAAK,MAAM,CAAC;AAAA,MACrB,QAAQ;AACN,eAAO;AAAA,MACT;AAAA,IACF,CAAC,EACA,OAAO,CAAC,MAA2B,MAAM,IAAI;AAChD,WAAO,KAAK,QAAQ,OAAO,MAAM,CAAC,KAAK,KAAK,IAAI;AAAA,EAClD,QAAQ;AACN,WAAO,CAAC;AAAA,EACV;AACF;AAUA,eAAsB,sBAAsB,WAA4C;AACtF,QAAM,SAAS,MAAM,iBAAiB,SAAS;AAC/C,QAAM,aAAqC,CAAC;AAC5C,QAAM,YAAY,oBAAI,IAAY;AAClC,MAAI,WAAW;AACf,MAAI,aAAa;AACjB,aAAW,KAAK,QAAQ;AACtB,QAAI,EAAE,SAAS,UAAU;AACvB;AACA,iBAAW,KAAK,EAAE,QAAS,WAAU,IAAI,EAAE,IAAI;AAAA,IACjD,WAAW,EAAE,SAAS,aAAa;AACjC;AACA,iBAAW,EAAE,SAAS,KAAK,WAAW,EAAE,SAAS,KAAK,KAAK;AAAA,IAC7D;AAAA,EACF;AACA,QAAM,sBAAsB,CAAC,GAAG,SAAS,EAAE,OAAO,CAAC,MAAM,EAAE,KAAK,WAAW,EAAE,KAAK;AAClF,SAAO,EAAE,UAAU,YAAY,YAAY,oBAAoB;AACjE;","names":[]}
|
|
@@ -14,7 +14,11 @@ version: 1
|
|
|
14
14
|
# Provenance fields \u2014 apply to all types, enforced by lint.find_missing_provenance.
|
|
15
15
|
# sources: URLs or filesystem paths the note was derived from
|
|
16
16
|
# derived_from: wikilinks to other notes this one synthesizes
|
|
17
|
-
# last_verified: ISO date this note was last confirmed true (YYYY-MM-DD)
|
|
17
|
+
# last_verified: ISO date this note was last confirmed true (YYYY-MM-DD).
|
|
18
|
+
# Set at creation and by verify_note; NOT bumped on ordinary edits.
|
|
19
|
+
# Drives confidence decay (see the decay: block below).
|
|
20
|
+
# evergreen: true \u2192 the note is pinned against decay while last_verified is
|
|
21
|
+
# within 365 days; after that it decays normally (re-affirm to reset).
|
|
18
22
|
# status: one of: draft | active | stale | deprecated | archived
|
|
19
23
|
# confidence: one of: high | medium | low
|
|
20
24
|
provenance_fields:
|
|
@@ -47,6 +51,18 @@ types:
|
|
|
47
51
|
description: External reference (paper, blog post, doc) used as provenance
|
|
48
52
|
required: [title, status, source]
|
|
49
53
|
|
|
54
|
+
alias:
|
|
55
|
+
description: Learned human\u2192artifact vocabulary entry (v1.4 lexicon). Maps the human's phrasing to a concrete path/symbol.
|
|
56
|
+
required: [canonical]
|
|
57
|
+
|
|
58
|
+
dossier:
|
|
59
|
+
description: Entity-centric living note (v1.5). One per critical component \u2014 purpose, failure modes, knobs, incident log, current state. Accretes in place.
|
|
60
|
+
required: [title, status, entity]
|
|
61
|
+
|
|
62
|
+
profile:
|
|
63
|
+
description: Speaker profile (v1.5). Models how THIS human communicates \u2014 severity calibration, chronic omissions, verbosity, shorthand. One human, one profile.
|
|
64
|
+
required: [title, status]
|
|
65
|
+
|
|
50
66
|
# Severity values for gotcha type.
|
|
51
67
|
severity_enum: [low, medium, high, critical]
|
|
52
68
|
|
|
@@ -63,6 +79,58 @@ lint:
|
|
|
63
79
|
schema_violations:
|
|
64
80
|
# Triggers on missing required fields, unknown type, or enum mismatch.
|
|
65
81
|
severity: error
|
|
82
|
+
|
|
83
|
+
# Confidence decay (v1.3). Down-weights search results by time since last_verified.
|
|
84
|
+
# Composes multiplicatively with load_priority \u2014 it never removes notes, only ranks
|
|
85
|
+
# them lower (floored). Set enabled: false for byte-identical pre-v1.3 ranking.
|
|
86
|
+
decay:
|
|
87
|
+
enabled: true
|
|
88
|
+
default_half_life_days: 365 # multiplier = 0.5 ^ (age_days / half_life)
|
|
89
|
+
per_type:
|
|
90
|
+
decision: 365
|
|
91
|
+
note: 365
|
|
92
|
+
gotcha: 180
|
|
93
|
+
source: null # null = never decays
|
|
94
|
+
proposal: 14
|
|
95
|
+
floor: 0.1 # notes never drop below this multiplier
|
|
96
|
+
hotness_boost:
|
|
97
|
+
enabled: false # extend half-life for heavily-backlinked notes (opt-in)
|
|
98
|
+
cap: 2.0
|
|
99
|
+
|
|
100
|
+
# Path-class ranking (v1.4). Down-weights whole regions of the vault by path glob
|
|
101
|
+
# (composes multiplicatively with decay + load_priority). First matching rule wins.
|
|
102
|
+
# The default down-weights archived notes so retired docs stop dominating results.
|
|
103
|
+
path_class:
|
|
104
|
+
enabled: true
|
|
105
|
+
rules:
|
|
106
|
+
- glob: "archive/**"
|
|
107
|
+
multiplier: 0.3
|
|
108
|
+
|
|
109
|
+
# Usage-feedback ranking (v1.5). Boosts notes that get CITED after retrieval (per
|
|
110
|
+
# the local selection log) so load-bearing knowledge floats up. Bounded by cap to
|
|
111
|
+
# stop a feedback runaway; composes multiplicatively with decay + path_class +
|
|
112
|
+
# load_priority. A no-op until the selection log has citations. The complementary
|
|
113
|
+
# signal (retrieved-but-never-cited "decoys") is surfaced by lint_vault, NEVER
|
|
114
|
+
# auto-down-ranked. Set enabled: false for byte-identical pre-v1.5 ranking.
|
|
115
|
+
usage_boost:
|
|
116
|
+
enabled: true
|
|
117
|
+
cap: 1.5 # max multiplier a note can earn from citations
|
|
118
|
+
per_citation: 0.1 # multiplier += per_citation \xD7 citation_count, then capped
|
|
119
|
+
|
|
120
|
+
# Token-frugal tool surface (v1.4). When enabled, the server registers a reduced
|
|
121
|
+
# tool set in modes that don't need the full surface (outage-silence \u2192 search/read
|
|
122
|
+
# only). DEFAULT OFF \u2014 mid-session mode switches don't re-register, so opt in and
|
|
123
|
+
# dogfood before relying on it.
|
|
124
|
+
tools:
|
|
125
|
+
conditional: false
|
|
126
|
+
|
|
127
|
+
# Selection-logging telemetry (v1.3.1). Records what search returned and which
|
|
128
|
+
# note the answer used, to a LOCAL append-only JSONL at
|
|
129
|
+
# .claude/.semantic-memory/selection.jsonl (gitignored). Never leaves the machine.
|
|
130
|
+
# Feeds the decay_candidates lint and (later) usage-feedback ranking. Set
|
|
131
|
+
# enabled: false to disable all logging.
|
|
132
|
+
telemetry:
|
|
133
|
+
enabled: true
|
|
66
134
|
`;
|
|
67
135
|
|
|
68
136
|
// src/core/schema.ts
|
|
@@ -177,4 +245,4 @@ export {
|
|
|
177
245
|
installDefaultSchema,
|
|
178
246
|
validateNote
|
|
179
247
|
};
|
|
180
|
-
//# sourceMappingURL=chunk-
|
|
248
|
+
//# sourceMappingURL=chunk-OWZWMB4P.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/core/schema.ts","../src/core/schema-default.ts"],"sourcesContent":["import { readFile, writeFile, access } from \"node:fs/promises\";\nimport { constants } from \"node:fs\";\nimport { join } from \"node:path\";\nimport { parse as parseYaml } from \"yaml\";\nimport matter from \"gray-matter\";\nimport { DEFAULT_SCHEMA_YAML } from \"./schema-default.js\";\n\nexport interface VaultSchema {\n version: number;\n provenance_fields: string[];\n status_enum: string[];\n confidence_enum: string[];\n severity_enum: string[];\n types: Record<string, { description?: string; required: string[] }>;\n lint: {\n missing_provenance: { applies_to: string[]; severity: \"warn\" | \"error\" };\n stale: { max_age_days: number; severity: \"warn\" | \"error\" };\n schema_violations: { severity: \"warn\" | \"error\" };\n };\n}\n\nexport type Severity = \"warn\" | \"error\";\n\nexport interface LintFinding {\n path: string;\n rule: \"missing_provenance\" | \"stale\" | \"schema_violations\" | \"broken_links\" | \"code_symbols\" | \"decay_candidates\" | \"alias_conflicts\" | \"decoys\";\n severity: Severity;\n message: string;\n}\n\nexport async function loadSchema(vaultPath: string): Promise<VaultSchema> {\n const schemaPath = join(vaultPath, \"vault.schema.yml\");\n let raw: string;\n try {\n await access(schemaPath, constants.R_OK);\n raw = await readFile(schemaPath, \"utf-8\");\n } catch {\n raw = DEFAULT_SCHEMA_YAML;\n }\n const parsed = parseYaml(raw) as VaultSchema;\n return parsed;\n}\n\n/**\n * Bootstrap the schema file in a vault by writing the default if none exists.\n * Used by the `--install-schema` CLI flag. Returns the path that was (or would be) written.\n */\nexport async function installDefaultSchema(vaultPath: string, force = false): Promise<{ written: boolean; path: string }> {\n const schemaPath = join(vaultPath, \"vault.schema.yml\");\n if (!force) {\n try {\n await access(schemaPath, constants.F_OK);\n return { written: false, path: schemaPath };\n } catch {\n // not present → write it\n }\n }\n await writeFile(schemaPath, DEFAULT_SCHEMA_YAML, \"utf-8\");\n return { written: true, path: schemaPath };\n}\n\n/**\n * Validate a single note's frontmatter + content against the schema.\n * Returns findings (possibly empty). Does NOT throw — lint never throws.\n */\nexport function validateNote(\n relativePath: string,\n rawFileContent: string,\n schema: VaultSchema,\n opts: { todayIso?: string } = {}\n): LintFinding[] {\n const findings: LintFinding[] = [];\n const { data: fm } = matter(rawFileContent);\n\n const typeName = typeof fm.type === \"string\" ? fm.type : \"note\";\n const typeDef = schema.types[typeName];\n\n if (!typeDef) {\n findings.push({\n path: relativePath,\n rule: \"schema_violations\",\n severity: schema.lint.schema_violations.severity,\n message: `unknown type \"${typeName}\" — allowed: ${Object.keys(schema.types).join(\", \")}`,\n });\n return findings; // can't validate required fields if type is unknown\n }\n\n // Required field check\n for (const field of typeDef.required) {\n if (fm[field] === undefined || fm[field] === null || fm[field] === \"\") {\n findings.push({\n path: relativePath,\n rule: \"schema_violations\",\n severity: schema.lint.schema_violations.severity,\n message: `missing required field \"${field}\" for type \"${typeName}\"`,\n });\n }\n }\n\n // Enum checks\n if (fm.status !== undefined && !schema.status_enum.includes(String(fm.status))) {\n findings.push({\n path: relativePath,\n rule: \"schema_violations\",\n severity: schema.lint.schema_violations.severity,\n message: `status \"${fm.status}\" not in enum [${schema.status_enum.join(\", \")}]`,\n });\n }\n if (fm.confidence !== undefined && !schema.confidence_enum.includes(String(fm.confidence))) {\n findings.push({\n path: relativePath,\n rule: \"schema_violations\",\n severity: schema.lint.schema_violations.severity,\n message: `confidence \"${fm.confidence}\" not in enum [${schema.confidence_enum.join(\", \")}]`,\n });\n }\n if (typeName === \"gotcha\" && fm.severity !== undefined && !schema.severity_enum.includes(String(fm.severity))) {\n findings.push({\n path: relativePath,\n rule: \"schema_violations\",\n severity: schema.lint.schema_violations.severity,\n message: `severity \"${fm.severity}\" not in enum [${schema.severity_enum.join(\", \")}]`,\n });\n }\n\n // Missing provenance\n if (schema.lint.missing_provenance.applies_to.includes(typeName)) {\n const hasSources = Array.isArray(fm.sources) ? fm.sources.length > 0 : typeof fm.sources === \"string\" && fm.sources.length > 0;\n const hasDerived = Array.isArray(fm.derived_from) ? fm.derived_from.length > 0 : typeof fm.derived_from === \"string\" && fm.derived_from.length > 0;\n if (!hasSources && !hasDerived) {\n findings.push({\n path: relativePath,\n rule: \"missing_provenance\",\n severity: schema.lint.missing_provenance.severity,\n message: `no sources or derived_from on ${typeName}`,\n });\n }\n }\n\n // Stale\n if (schema.lint.stale.max_age_days > 0 && fm.last_verified) {\n const today = opts.todayIso ?? new Date().toISOString().slice(0, 10);\n const verified = String(fm.last_verified);\n const ageDays = dayDiff(today, verified);\n if (ageDays !== null && ageDays > schema.lint.stale.max_age_days) {\n findings.push({\n path: relativePath,\n rule: \"stale\",\n severity: schema.lint.stale.severity,\n message: `last_verified ${verified} is ${ageDays} days old (limit ${schema.lint.stale.max_age_days})`,\n });\n }\n }\n\n return findings;\n}\n\nfunction dayDiff(todayIso: string, thenIso: string): number | null {\n const a = Date.parse(todayIso);\n const b = Date.parse(thenIso);\n if (Number.isNaN(a) || Number.isNaN(b)) return null;\n return Math.round((a - b) / 86400000);\n}\n","// Default vault schema shipped with semantic-sidekick.\n// Intentionally minimal — the plan is to grow from pain, not preempt it.\n// Users can override with a vault.schema.yml at the vault root.\n\nexport const DEFAULT_SCHEMA_YAML = `# semantic-sidekick vault schema (minimal starter).\n# Override by placing a vault.schema.yml at the vault root.\n\nversion: 1\n\n# Provenance fields — apply to all types, enforced by lint.find_missing_provenance.\n# sources: URLs or filesystem paths the note was derived from\n# derived_from: wikilinks to other notes this one synthesizes\n# last_verified: ISO date this note was last confirmed true (YYYY-MM-DD).\n# Set at creation and by verify_note; NOT bumped on ordinary edits.\n# Drives confidence decay (see the decay: block below).\n# evergreen: true → the note is pinned against decay while last_verified is\n# within 365 days; after that it decays normally (re-affirm to reset).\n# status: one of: draft | active | stale | deprecated | archived\n# confidence: one of: high | medium | low\nprovenance_fields:\n - sources\n - derived_from\n - last_verified\n - status\n - confidence\n\n# Allowed status values (enforced by schema_violations lint).\nstatus_enum: [draft, active, stale, deprecated, archived]\nconfidence_enum: [high, medium, low]\n\n# Note types. Each type declares its required frontmatter.\n# Any field not listed as required is allowed but not enforced.\ntypes:\n note:\n description: Generic note — default type when \\`type\\` frontmatter is absent\n required: [title, status]\n\n decision:\n description: Architectural or process decision with dated rationale\n required: [title, status, decided_on, decision_maker]\n\n gotcha:\n description: Compressed lesson from a past incident/bug/surprise\n required: [title, status, last_verified, severity]\n\n source:\n description: External reference (paper, blog post, doc) used as provenance\n required: [title, status, source]\n\n alias:\n description: Learned human→artifact vocabulary entry (v1.4 lexicon). Maps the human's phrasing to a concrete path/symbol.\n required: [canonical]\n\n dossier:\n description: Entity-centric living note (v1.5). One per critical component — purpose, failure modes, knobs, incident log, current state. Accretes in place.\n required: [title, status, entity]\n\n profile:\n description: Speaker profile (v1.5). Models how THIS human communicates — severity calibration, chronic omissions, verbosity, shorthand. One human, one profile.\n required: [title, status]\n\n# Severity values for gotcha type.\nseverity_enum: [low, medium, high, critical]\n\n# Lint policy. Each rule has a default severity that pre-commit/CI consumes.\nlint:\n missing_provenance:\n # Triggers when a note type in this list has no sources AND no derived_from.\n applies_to: [note, decision, gotcha]\n severity: warn\n stale:\n # Triggers when last_verified is older than this many days (0 = disabled).\n max_age_days: 180\n severity: warn\n schema_violations:\n # Triggers on missing required fields, unknown type, or enum mismatch.\n severity: error\n\n# Confidence decay (v1.3). Down-weights search results by time since last_verified.\n# Composes multiplicatively with load_priority — it never removes notes, only ranks\n# them lower (floored). Set enabled: false for byte-identical pre-v1.3 ranking.\ndecay:\n enabled: true\n default_half_life_days: 365 # multiplier = 0.5 ^ (age_days / half_life)\n per_type:\n decision: 365\n note: 365\n gotcha: 180\n source: null # null = never decays\n proposal: 14\n floor: 0.1 # notes never drop below this multiplier\n hotness_boost:\n enabled: false # extend half-life for heavily-backlinked notes (opt-in)\n cap: 2.0\n\n# Path-class ranking (v1.4). Down-weights whole regions of the vault by path glob\n# (composes multiplicatively with decay + load_priority). First matching rule wins.\n# The default down-weights archived notes so retired docs stop dominating results.\npath_class:\n enabled: true\n rules:\n - glob: \"archive/**\"\n multiplier: 0.3\n\n# Usage-feedback ranking (v1.5). Boosts notes that get CITED after retrieval (per\n# the local selection log) so load-bearing knowledge floats up. Bounded by cap to\n# stop a feedback runaway; composes multiplicatively with decay + path_class +\n# load_priority. A no-op until the selection log has citations. The complementary\n# signal (retrieved-but-never-cited \"decoys\") is surfaced by lint_vault, NEVER\n# auto-down-ranked. Set enabled: false for byte-identical pre-v1.5 ranking.\nusage_boost:\n enabled: true\n cap: 1.5 # max multiplier a note can earn from citations\n per_citation: 0.1 # multiplier += per_citation × citation_count, then capped\n\n# Token-frugal tool surface (v1.4). When enabled, the server registers a reduced\n# tool set in modes that don't need the full surface (outage-silence → search/read\n# only). DEFAULT OFF — mid-session mode switches don't re-register, so opt in and\n# dogfood before relying on it.\ntools:\n conditional: false\n\n# Selection-logging telemetry (v1.3.1). Records what search returned and which\n# note the answer used, to a LOCAL append-only JSONL at\n# .claude/.semantic-memory/selection.jsonl (gitignored). Never leaves the machine.\n# Feeds the decay_candidates lint and (later) usage-feedback ranking. Set\n# enabled: false to disable all logging.\ntelemetry:\n enabled: true\n`;\n"],"mappings":"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","names":[]}
|