@djolex999/vir-cli 0.1.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.md +149 -0
- package/LICENSE +21 -0
- package/README.md +155 -0
- package/dist/claude/updater.js +230 -0
- package/dist/claude/updater.js.map +1 -0
- package/dist/cli.js +779 -0
- package/dist/cli.js.map +1 -0
- package/dist/config.js +82 -0
- package/dist/config.js.map +1 -0
- package/dist/daemon/launchd.js +93 -0
- package/dist/daemon/launchd.js.map +1 -0
- package/dist/dedupe/detector.js +159 -0
- package/dist/dedupe/detector.js.map +1 -0
- package/dist/dedupe/merger.js +116 -0
- package/dist/dedupe/merger.js.map +1 -0
- package/dist/lint/linter.js +224 -0
- package/dist/lint/linter.js.map +1 -0
- package/dist/pipeline/distiller.js +208 -0
- package/dist/pipeline/distiller.js.map +1 -0
- package/dist/pipeline/filter.js +28 -0
- package/dist/pipeline/filter.js.map +1 -0
- package/dist/pipeline/parser.js +109 -0
- package/dist/pipeline/parser.js.map +1 -0
- package/dist/pipeline/run.js +312 -0
- package/dist/pipeline/run.js.map +1 -0
- package/dist/pipeline/scanner.js +47 -0
- package/dist/pipeline/scanner.js.map +1 -0
- package/dist/pipeline/scrubber.js +51 -0
- package/dist/pipeline/scrubber.js.map +1 -0
- package/dist/pipeline/summarizer.js +162 -0
- package/dist/pipeline/summarizer.js.map +1 -0
- package/dist/pipeline/types.js +2 -0
- package/dist/pipeline/types.js.map +1 -0
- package/dist/pipeline/writer.js +195 -0
- package/dist/pipeline/writer.js.map +1 -0
- package/dist/search/embedder.js +93 -0
- package/dist/search/embedder.js.map +1 -0
- package/dist/search/retriever.js +212 -0
- package/dist/search/retriever.js.map +1 -0
- package/dist/search/synthesizer.js +26 -0
- package/dist/search/synthesizer.js.map +1 -0
- package/dist/state/db.js +309 -0
- package/dist/state/db.js.map +1 -0
- package/dist/ui/display.js +148 -0
- package/dist/ui/display.js.map +1 -0
- package/package.json +50 -0
- package/src/claude/updater.ts +273 -0
- package/src/cli.ts +953 -0
- package/src/config.ts +89 -0
- package/src/daemon/launchd.ts +115 -0
- package/src/dedupe/detector.ts +197 -0
- package/src/dedupe/merger.ts +172 -0
- package/src/lint/linter.ts +286 -0
- package/src/pipeline/distiller.ts +280 -0
- package/src/pipeline/filter.ts +43 -0
- package/src/pipeline/parser.ts +118 -0
- package/src/pipeline/run.ts +378 -0
- package/src/pipeline/scanner.ts +51 -0
- package/src/pipeline/scrubber.ts +55 -0
- package/src/pipeline/summarizer.ts +204 -0
- package/src/pipeline/types.ts +41 -0
- package/src/pipeline/writer.ts +242 -0
- package/src/search/embedder.ts +88 -0
- package/src/search/retriever.ts +255 -0
- package/src/search/synthesizer.ts +45 -0
- package/src/state/db.ts +451 -0
- package/src/ui/display.ts +184 -0
- package/tsconfig.json +23 -0
- package/vir-flow.html +708 -0
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import type { Config } from "../config.js";
|
|
4
|
+
import type { DistilledRow, StateDb } from "../state/db.js";
|
|
5
|
+
import {
|
|
6
|
+
buildAnthropicClient,
|
|
7
|
+
callLLM,
|
|
8
|
+
normalizeModelName,
|
|
9
|
+
withRateLimitRetry,
|
|
10
|
+
} from "./distiller.js";
|
|
11
|
+
import type { Category } from "./types.js";
|
|
12
|
+
import { kebab } from "./writer.js";
|
|
13
|
+
|
|
14
|
+
const EXCERPT_LEN = 200;
|
|
15
|
+
const CHANGELOG_HEADER = "## Changelog";
|
|
16
|
+
|
|
17
|
+
export interface ProjectGroup {
|
|
18
|
+
slug: string;
|
|
19
|
+
displayName: string;
|
|
20
|
+
rows: DistilledRow[];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface ProjectCounts {
|
|
24
|
+
patterns: number;
|
|
25
|
+
gotchas: number;
|
|
26
|
+
decisions: number;
|
|
27
|
+
tools: number;
|
|
28
|
+
total: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function groupByProject(rows: DistilledRow[]): Map<string, ProjectGroup> {
|
|
32
|
+
const out = new Map<string, ProjectGroup>();
|
|
33
|
+
for (const r of rows) {
|
|
34
|
+
const slug = kebab(r.project);
|
|
35
|
+
if (slug.length === 0) continue;
|
|
36
|
+
let g = out.get(slug);
|
|
37
|
+
if (!g) {
|
|
38
|
+
g = { slug, displayName: r.project, rows: [] };
|
|
39
|
+
out.set(slug, g);
|
|
40
|
+
}
|
|
41
|
+
g.rows.push(r);
|
|
42
|
+
}
|
|
43
|
+
return out;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function countByCategory(rows: DistilledRow[]): ProjectCounts {
|
|
47
|
+
let patterns = 0,
|
|
48
|
+
gotchas = 0,
|
|
49
|
+
decisions = 0,
|
|
50
|
+
tools = 0;
|
|
51
|
+
for (const r of rows) {
|
|
52
|
+
if (r.category === "pattern") patterns += 1;
|
|
53
|
+
else if (r.category === "gotcha") gotchas += 1;
|
|
54
|
+
else if (r.category === "decision") decisions += 1;
|
|
55
|
+
else if (r.category === "tool") tools += 1;
|
|
56
|
+
}
|
|
57
|
+
return { patterns, gotchas, decisions, tools, total: rows.length };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export async function summarizeProject(
|
|
61
|
+
cfg: Config,
|
|
62
|
+
projectSlug: string,
|
|
63
|
+
db: StateDb,
|
|
64
|
+
): Promise<{ slug: string; path: string; counts: ProjectCounts } | null> {
|
|
65
|
+
const allRows = db.listDistilled();
|
|
66
|
+
const grouped = groupByProject(allRows);
|
|
67
|
+
const group = grouped.get(projectSlug);
|
|
68
|
+
if (!group || group.rows.length === 0) return null;
|
|
69
|
+
|
|
70
|
+
const counts = countByCategory(group.rows);
|
|
71
|
+
const prompt = buildPrompt(group, counts);
|
|
72
|
+
|
|
73
|
+
const client = buildAnthropicClient(cfg);
|
|
74
|
+
const model = normalizeModelName(cfg.models.distill, cfg.provider);
|
|
75
|
+
const body = await withRateLimitRetry(() =>
|
|
76
|
+
callLLM(cfg, client, { prompt, model, maxTokens: 1500 }),
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
const outPath = writeSummaryFile(cfg, projectSlug, body.trim(), counts);
|
|
80
|
+
return { slug: projectSlug, path: outPath, counts };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export async function summarizeAll(
|
|
84
|
+
cfg: Config,
|
|
85
|
+
db: StateDb,
|
|
86
|
+
): Promise<Array<{ slug: string; path: string; counts: ProjectCounts }>> {
|
|
87
|
+
const grouped = groupByProject(db.listDistilled());
|
|
88
|
+
const results: Array<{ slug: string; path: string; counts: ProjectCounts }> =
|
|
89
|
+
[];
|
|
90
|
+
for (const slug of grouped.keys()) {
|
|
91
|
+
const res = await summarizeProject(cfg, slug, db);
|
|
92
|
+
if (res) results.push(res);
|
|
93
|
+
}
|
|
94
|
+
return results;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function buildPrompt(group: ProjectGroup, counts: ProjectCounts): string {
|
|
98
|
+
const byCat: Record<Category, DistilledRow[]> = {
|
|
99
|
+
pattern: [],
|
|
100
|
+
gotcha: [],
|
|
101
|
+
decision: [],
|
|
102
|
+
tool: [],
|
|
103
|
+
};
|
|
104
|
+
for (const r of group.rows) byCat[r.category].push(r);
|
|
105
|
+
|
|
106
|
+
const renderList = (rows: DistilledRow[]): string => {
|
|
107
|
+
if (rows.length === 0) return "(none)";
|
|
108
|
+
return rows
|
|
109
|
+
.map((r) => {
|
|
110
|
+
const excerpt = r.content
|
|
111
|
+
.replace(/\s+/g, " ")
|
|
112
|
+
.trim()
|
|
113
|
+
.slice(0, EXCERPT_LEN);
|
|
114
|
+
return `- ${r.topic}: ${excerpt}`;
|
|
115
|
+
})
|
|
116
|
+
.join("\n");
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
return `You are synthesizing a project knowledge summary from distilled Claude Code session notes.
|
|
120
|
+
|
|
121
|
+
Project: ${group.slug}
|
|
122
|
+
Total sessions: ${counts.total}
|
|
123
|
+
|
|
124
|
+
Patterns (${counts.patterns}):
|
|
125
|
+
${renderList(byCat.pattern)}
|
|
126
|
+
|
|
127
|
+
Gotchas (${counts.gotchas}):
|
|
128
|
+
${renderList(byCat.gotcha)}
|
|
129
|
+
|
|
130
|
+
Decisions (${counts.decisions}):
|
|
131
|
+
${renderList(byCat.decision)}
|
|
132
|
+
|
|
133
|
+
Tools (${counts.tools}):
|
|
134
|
+
${renderList(byCat.tool)}
|
|
135
|
+
|
|
136
|
+
Write a project summary with these exact sections:
|
|
137
|
+
## Overview
|
|
138
|
+
2-3 sentences: what this project is, what stack/approach dominates
|
|
139
|
+
|
|
140
|
+
## Key Patterns
|
|
141
|
+
Bullet list of the most reusable patterns, 1 sentence each
|
|
142
|
+
|
|
143
|
+
## Watch Out For
|
|
144
|
+
Bullet list of the most important gotchas, 1 sentence each
|
|
145
|
+
|
|
146
|
+
## Architecture Decisions
|
|
147
|
+
Bullet list of significant decisions made, 1 sentence each
|
|
148
|
+
|
|
149
|
+
## Knowledge Gaps
|
|
150
|
+
1-2 sentences: what topics appear underrepresented or missing
|
|
151
|
+
|
|
152
|
+
Be specific and direct. Use the actual topic names.`;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function writeSummaryFile(
|
|
156
|
+
cfg: Config,
|
|
157
|
+
projectSlug: string,
|
|
158
|
+
body: string,
|
|
159
|
+
counts: ProjectCounts,
|
|
160
|
+
): string {
|
|
161
|
+
const dir = join(cfg.vaultPath, cfg.outputDir, "projects");
|
|
162
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
163
|
+
const filePath = join(dir, `${projectSlug}.md`);
|
|
164
|
+
|
|
165
|
+
const generated = new Date().toISOString();
|
|
166
|
+
const date = generated.slice(0, 10);
|
|
167
|
+
const newEntry = `- ${date}: ${counts.total} sessions, ${counts.patterns} patterns, ${counts.gotchas} gotchas, ${counts.decisions} decisions, ${counts.tools} tools`;
|
|
168
|
+
|
|
169
|
+
const existingChangelog = readExistingChangelog(filePath);
|
|
170
|
+
const changelog = [CHANGELOG_HEADER, newEntry, ...existingChangelog].join("\n");
|
|
171
|
+
|
|
172
|
+
const frontmatter = [
|
|
173
|
+
"---",
|
|
174
|
+
`project: ${projectSlug}`,
|
|
175
|
+
`generated: ${generated}`,
|
|
176
|
+
`sessions: ${counts.total}`,
|
|
177
|
+
"---",
|
|
178
|
+
"",
|
|
179
|
+
`Project: [[${projectSlug}]]`,
|
|
180
|
+
"",
|
|
181
|
+
].join("\n");
|
|
182
|
+
|
|
183
|
+
const content = `${frontmatter}${body}\n\n---\n${changelog}\n`;
|
|
184
|
+
writeFileSync(filePath, content);
|
|
185
|
+
return filePath;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Returns the existing changelog entries (the lines after `## Changelog`),
|
|
189
|
+
// excluding the header itself. Empty array if file is missing or has no
|
|
190
|
+
// changelog section yet.
|
|
191
|
+
function readExistingChangelog(filePath: string): string[] {
|
|
192
|
+
if (!existsSync(filePath)) return [];
|
|
193
|
+
let raw: string;
|
|
194
|
+
try {
|
|
195
|
+
raw = readFileSync(filePath, "utf8");
|
|
196
|
+
} catch {
|
|
197
|
+
return [];
|
|
198
|
+
}
|
|
199
|
+
const idx = raw.indexOf(CHANGELOG_HEADER);
|
|
200
|
+
if (idx === -1) return [];
|
|
201
|
+
const rest = raw.slice(idx + CHANGELOG_HEADER.length).trim();
|
|
202
|
+
if (rest.length === 0) return [];
|
|
203
|
+
return rest.split("\n").filter((l) => l.trim().length > 0);
|
|
204
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
export interface TranscriptLine {
|
|
2
|
+
type?: string;
|
|
3
|
+
role?: string;
|
|
4
|
+
content?: unknown;
|
|
5
|
+
timestamp?: string;
|
|
6
|
+
message?: {
|
|
7
|
+
role?: string;
|
|
8
|
+
content?: unknown;
|
|
9
|
+
};
|
|
10
|
+
toolUseResult?: unknown;
|
|
11
|
+
[key: string]: unknown;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface ParsedSession {
|
|
15
|
+
path: string;
|
|
16
|
+
hash: string;
|
|
17
|
+
sessionId: string;
|
|
18
|
+
projectSlug: string;
|
|
19
|
+
startedAt: string | null;
|
|
20
|
+
endedAt: string | null;
|
|
21
|
+
lineCount: number;
|
|
22
|
+
toolCallCount: number;
|
|
23
|
+
filesTouched: string[];
|
|
24
|
+
assistantText: string;
|
|
25
|
+
userText: string;
|
|
26
|
+
rawSummary: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export type Category = "pattern" | "gotcha" | "decision" | "tool";
|
|
30
|
+
|
|
31
|
+
export interface Classification {
|
|
32
|
+
category: Category;
|
|
33
|
+
topic: string;
|
|
34
|
+
project: string;
|
|
35
|
+
confidence: number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface DistilledNote {
|
|
39
|
+
classification: Classification;
|
|
40
|
+
markdown: string;
|
|
41
|
+
}
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
import chalk from "chalk";
|
|
2
|
+
import {
|
|
3
|
+
appendFileSync,
|
|
4
|
+
existsSync,
|
|
5
|
+
mkdirSync,
|
|
6
|
+
readFileSync,
|
|
7
|
+
readdirSync,
|
|
8
|
+
writeFileSync,
|
|
9
|
+
} from "node:fs";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
import type { Config } from "../config.js";
|
|
12
|
+
import {
|
|
13
|
+
embeddingForNote,
|
|
14
|
+
isOllamaAvailableCached,
|
|
15
|
+
} from "../search/embedder.js";
|
|
16
|
+
import type { StateDb } from "../state/db.js";
|
|
17
|
+
import type { Category, DistilledNote, ParsedSession } from "./types.js";
|
|
18
|
+
|
|
19
|
+
const CATEGORY_DIR: Record<Category, string> = {
|
|
20
|
+
pattern: "patterns",
|
|
21
|
+
gotcha: "gotchas",
|
|
22
|
+
decision: "decisions",
|
|
23
|
+
tool: "tools",
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export class VaultWriter {
|
|
27
|
+
private root: string;
|
|
28
|
+
private db: StateDb | null;
|
|
29
|
+
|
|
30
|
+
constructor(cfg: Config, db: StateDb | null = null) {
|
|
31
|
+
this.root = join(cfg.vaultPath, cfg.outputDir);
|
|
32
|
+
this.db = db;
|
|
33
|
+
for (const sub of Object.values(CATEGORY_DIR)) {
|
|
34
|
+
const p = join(this.root, sub);
|
|
35
|
+
if (!existsSync(p)) mkdirSync(p, { recursive: true });
|
|
36
|
+
}
|
|
37
|
+
this.ensureIndex();
|
|
38
|
+
this.ensureLog();
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async write(session: ParsedSession, note: DistilledNote): Promise<string[]> {
|
|
42
|
+
const { classification, markdown } = note;
|
|
43
|
+
const slug = makeSlug(classification.topic, session.sessionId);
|
|
44
|
+
const subDir = CATEGORY_DIR[classification.category];
|
|
45
|
+
const relPath = join(subDir, `${slug}.md`);
|
|
46
|
+
const fullPath = join(this.root, relPath);
|
|
47
|
+
|
|
48
|
+
const frontmatter = [
|
|
49
|
+
"---",
|
|
50
|
+
`topic: "${classification.topic.replace(/"/g, '\\"')}"`,
|
|
51
|
+
`category: ${classification.category}`,
|
|
52
|
+
`project: "${classification.project.replace(/"/g, '\\"')}"`,
|
|
53
|
+
`session_id: ${session.sessionId}`,
|
|
54
|
+
`date: ${session.startedAt ?? new Date().toISOString()}`,
|
|
55
|
+
`confidence: ${classification.confidence}`,
|
|
56
|
+
"---",
|
|
57
|
+
"",
|
|
58
|
+
].join("\n");
|
|
59
|
+
|
|
60
|
+
const projectSlug = kebab(classification.project);
|
|
61
|
+
const categorySlug = classification.category;
|
|
62
|
+
const wikilinkHeader =
|
|
63
|
+
`Project: [[${projectSlug}]]\n` +
|
|
64
|
+
`Category: [[${categorySlug}]]\n\n`;
|
|
65
|
+
|
|
66
|
+
const body = wikilinkRelated(markdown);
|
|
67
|
+
|
|
68
|
+
const finalContent = frontmatter + wikilinkHeader + body + "\n";
|
|
69
|
+
writeFileSync(fullPath, finalContent);
|
|
70
|
+
await this.maybeEmbed(session, note, finalContent);
|
|
71
|
+
this.appendIndex({
|
|
72
|
+
date: (session.startedAt ?? new Date().toISOString()).slice(0, 10),
|
|
73
|
+
topic: classification.topic,
|
|
74
|
+
category: classification.category,
|
|
75
|
+
project: classification.project,
|
|
76
|
+
relPath,
|
|
77
|
+
});
|
|
78
|
+
this.appendLog({
|
|
79
|
+
ts: new Date().toISOString().slice(0, 16).replace("T", " "),
|
|
80
|
+
category: classification.category,
|
|
81
|
+
topic: classification.topic,
|
|
82
|
+
project: classification.project,
|
|
83
|
+
});
|
|
84
|
+
return [fullPath];
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Best-effort: embed the freshly-written note via Ollama and store the
|
|
88
|
+
// vector. Any failure (Ollama down, timeout, model missing) is swallowed —
|
|
89
|
+
// an embedding miss must never fail a write.
|
|
90
|
+
private async maybeEmbed(
|
|
91
|
+
session: ParsedSession,
|
|
92
|
+
note: DistilledNote,
|
|
93
|
+
fileContent: string,
|
|
94
|
+
): Promise<void> {
|
|
95
|
+
if (!this.db) return;
|
|
96
|
+
try {
|
|
97
|
+
const available = await isOllamaAvailableCached();
|
|
98
|
+
if (!available) return;
|
|
99
|
+
const vec = await embeddingForNote(fileContent);
|
|
100
|
+
if (!vec) return;
|
|
101
|
+
this.db.storeEmbedding(session.sessionId, vec);
|
|
102
|
+
console.log(
|
|
103
|
+
chalk.dim(
|
|
104
|
+
` embedded ${note.classification.topic} (${vec.length}d)`,
|
|
105
|
+
),
|
|
106
|
+
);
|
|
107
|
+
} catch {
|
|
108
|
+
// never crash the writer on embedding failure
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
private ensureIndex(): void {
|
|
113
|
+
const p = join(this.root, "index.md");
|
|
114
|
+
if (!existsSync(p)) {
|
|
115
|
+
writeFileSync(
|
|
116
|
+
p,
|
|
117
|
+
"# vir — Distilled Knowledge\n\n| Date | Topic | Category | Project | Link |\n|------|-------|----------|---------|------|\n",
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
private ensureLog(): void {
|
|
123
|
+
const p = join(this.root, "log.md");
|
|
124
|
+
if (!existsSync(p)) {
|
|
125
|
+
writeFileSync(p, "# vir — Run Log\n\n");
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
private appendIndex(row: {
|
|
130
|
+
date: string;
|
|
131
|
+
topic: string;
|
|
132
|
+
category: Category;
|
|
133
|
+
project: string;
|
|
134
|
+
relPath: string;
|
|
135
|
+
}): void {
|
|
136
|
+
const p = join(this.root, "index.md");
|
|
137
|
+
const link = `[[${row.relPath.replace(/\.md$/, "")}|${row.topic}]]`;
|
|
138
|
+
const line = `| ${row.date} | ${row.topic} | ${row.category} | ${row.project} | ${link} |\n`;
|
|
139
|
+
const current = readFileSync(p, "utf8");
|
|
140
|
+
// Insert after the table header (first occurrence of '|------')
|
|
141
|
+
const headerIdx = current.indexOf("|------");
|
|
142
|
+
if (headerIdx === -1) {
|
|
143
|
+
appendFileSync(p, line);
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
const newlineAfter = current.indexOf("\n", headerIdx);
|
|
147
|
+
const updated =
|
|
148
|
+
current.slice(0, newlineAfter + 1) + line + current.slice(newlineAfter + 1);
|
|
149
|
+
writeFileSync(p, updated);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
private appendLog(entry: {
|
|
153
|
+
ts: string;
|
|
154
|
+
category: Category;
|
|
155
|
+
topic: string;
|
|
156
|
+
project: string;
|
|
157
|
+
}): void {
|
|
158
|
+
const p = join(this.root, "log.md");
|
|
159
|
+
appendFileSync(
|
|
160
|
+
p,
|
|
161
|
+
`## [${entry.ts}] ${entry.category} | ${entry.topic} | ${entry.project}\n\n`,
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
noteCount(): number {
|
|
166
|
+
let n = 0;
|
|
167
|
+
for (const sub of Object.values(CATEGORY_DIR)) {
|
|
168
|
+
const dir = join(this.root, sub);
|
|
169
|
+
if (!existsSync(dir)) continue;
|
|
170
|
+
try {
|
|
171
|
+
n += readdirSync(dir).filter((f) => f.endsWith(".md")).length;
|
|
172
|
+
} catch {
|
|
173
|
+
// ignore
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
return n;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function makeSlug(topic: string, sessionId: string): string {
|
|
181
|
+
const base = kebab(topic).slice(0, 50);
|
|
182
|
+
const suffix = sessionId.slice(0, 8);
|
|
183
|
+
return base.length > 0 ? `${base}-${suffix}` : `note-${suffix}`;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export function kebab(s: string): string {
|
|
187
|
+
return s
|
|
188
|
+
.toLowerCase()
|
|
189
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
190
|
+
.replace(/^-+|-+$/g, "");
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Rewrites the bullet list under a `## Related` heading so each item
|
|
194
|
+
// becomes an Obsidian wikilink to a kebab-cased slug of the item's text.
|
|
195
|
+
// Stops at the next heading or end of document.
|
|
196
|
+
export function wikilinkRelated(markdown: string): string {
|
|
197
|
+
const lines = markdown.split("\n");
|
|
198
|
+
const out: string[] = [];
|
|
199
|
+
let inRelated = false;
|
|
200
|
+
|
|
201
|
+
for (const line of lines) {
|
|
202
|
+
if (/^##\s+related\b/i.test(line)) {
|
|
203
|
+
inRelated = true;
|
|
204
|
+
out.push(line);
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
if (inRelated && /^#{1,6}\s+/.test(line)) {
|
|
208
|
+
inRelated = false;
|
|
209
|
+
out.push(line);
|
|
210
|
+
continue;
|
|
211
|
+
}
|
|
212
|
+
if (inRelated) {
|
|
213
|
+
const bullet = line.match(/^(\s*[-*]\s+)(.*)$/);
|
|
214
|
+
if (bullet) {
|
|
215
|
+
const prefix = bullet[1] ?? "- ";
|
|
216
|
+
const text = (bullet[2] ?? "").trim();
|
|
217
|
+
if (text.length === 0) {
|
|
218
|
+
out.push(line);
|
|
219
|
+
} else if (/^\[\[.+\]\]$/.test(text)) {
|
|
220
|
+
// already a wikilink — leave it
|
|
221
|
+
out.push(line);
|
|
222
|
+
} else {
|
|
223
|
+
const slug = kebab(stripWikilink(text));
|
|
224
|
+
out.push(`${prefix}[[${slug}]]`);
|
|
225
|
+
}
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
out.push(line);
|
|
230
|
+
}
|
|
231
|
+
return out.join("\n");
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function stripWikilink(s: string): string {
|
|
235
|
+
// If the model already partially wrapped it like "[[Something]]" or
|
|
236
|
+
// "[Something](url)", reduce to the inner text before kebab-casing.
|
|
237
|
+
const wiki = s.match(/^\[\[(.+?)\]\]$/);
|
|
238
|
+
if (wiki) return wiki[1] ?? s;
|
|
239
|
+
const md = s.match(/^\[(.+?)\]\(.+\)$/);
|
|
240
|
+
if (md) return md[1] ?? s;
|
|
241
|
+
return s;
|
|
242
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
const OLLAMA_BASE = "http://localhost:11434";
|
|
2
|
+
export const EMBED_MODEL = "nomic-embed-text";
|
|
3
|
+
const EMBED_TIMEOUT_MS = 10_000;
|
|
4
|
+
const PING_TIMEOUT_MS = 3_000;
|
|
5
|
+
|
|
6
|
+
export class EmbedderError extends Error {
|
|
7
|
+
constructor(message: string) {
|
|
8
|
+
super(message);
|
|
9
|
+
this.name = "EmbedderError";
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export async function embed(text: string): Promise<number[]> {
|
|
14
|
+
const ac = new AbortController();
|
|
15
|
+
const t = setTimeout(() => ac.abort(), EMBED_TIMEOUT_MS);
|
|
16
|
+
try {
|
|
17
|
+
const resp = await fetch(`${OLLAMA_BASE}/api/embeddings`, {
|
|
18
|
+
method: "POST",
|
|
19
|
+
headers: { "Content-Type": "application/json" },
|
|
20
|
+
body: JSON.stringify({ model: EMBED_MODEL, prompt: text }),
|
|
21
|
+
signal: ac.signal,
|
|
22
|
+
});
|
|
23
|
+
if (!resp.ok) {
|
|
24
|
+
throw new EmbedderError(`Ollama ${resp.status}: ${await resp.text().catch(() => "")}`);
|
|
25
|
+
}
|
|
26
|
+
const data = (await resp.json()) as { embedding?: unknown; error?: string };
|
|
27
|
+
if (data.error) throw new EmbedderError(`Ollama error: ${data.error}`);
|
|
28
|
+
if (!Array.isArray(data.embedding) || data.embedding.length === 0) {
|
|
29
|
+
throw new EmbedderError("Ollama returned no embedding");
|
|
30
|
+
}
|
|
31
|
+
return (data.embedding as unknown[]).map((n) => Number(n));
|
|
32
|
+
} finally {
|
|
33
|
+
clearTimeout(t);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function cosineSimilarity(a: number[], b: number[]): number {
|
|
38
|
+
if (a.length === 0 || b.length === 0 || a.length !== b.length) return 0;
|
|
39
|
+
let dot = 0;
|
|
40
|
+
let magA = 0;
|
|
41
|
+
let magB = 0;
|
|
42
|
+
for (let i = 0; i < a.length; i += 1) {
|
|
43
|
+
const x = a[i] ?? 0;
|
|
44
|
+
const y = b[i] ?? 0;
|
|
45
|
+
dot += x * y;
|
|
46
|
+
magA += x * x;
|
|
47
|
+
magB += y * y;
|
|
48
|
+
}
|
|
49
|
+
if (magA === 0 || magB === 0) return 0;
|
|
50
|
+
const sim = dot / (Math.sqrt(magA) * Math.sqrt(magB));
|
|
51
|
+
// Clamp to [0, 1] — embeddings are typically positively correlated; negative
|
|
52
|
+
// values would otherwise distort the topK ordering downstream.
|
|
53
|
+
if (!Number.isFinite(sim)) return 0;
|
|
54
|
+
if (sim < 0) return 0;
|
|
55
|
+
if (sim > 1) return 1;
|
|
56
|
+
return sim;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export async function isOllamaAvailable(): Promise<boolean> {
|
|
60
|
+
const ac = new AbortController();
|
|
61
|
+
const t = setTimeout(() => ac.abort(), PING_TIMEOUT_MS);
|
|
62
|
+
try {
|
|
63
|
+
const resp = await fetch(`${OLLAMA_BASE}/api/tags`, { signal: ac.signal });
|
|
64
|
+
return resp.ok;
|
|
65
|
+
} catch {
|
|
66
|
+
return false;
|
|
67
|
+
} finally {
|
|
68
|
+
clearTimeout(t);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Module-level memo so a long-running daemon doesn't hammer /api/tags on every
|
|
73
|
+
// session. Invalidated only by process restart, which is fine for our cadence.
|
|
74
|
+
let _availabilityCache: Promise<boolean> | null = null;
|
|
75
|
+
export function isOllamaAvailableCached(): Promise<boolean> {
|
|
76
|
+
if (_availabilityCache === null) {
|
|
77
|
+
_availabilityCache = isOllamaAvailable();
|
|
78
|
+
}
|
|
79
|
+
return _availabilityCache;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export async function embeddingForNote(text: string): Promise<number[] | null> {
|
|
83
|
+
try {
|
|
84
|
+
return await embed(text);
|
|
85
|
+
} catch {
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
}
|