@djolex999/vir-cli 0.1.0 → 0.1.1

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.
@@ -1,286 +0,0 @@
1
- import { readFileSync, readdirSync, statSync } from "node:fs";
2
- import { join, relative } 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 "../pipeline/distiller.js";
11
- import { kebab } from "../pipeline/writer.js";
12
-
13
- const SKIP_BASENAMES = new Set(["index.md", "log.md"]);
14
- const SKIP_DIRS = new Set(["projects"]);
15
- const CATEGORY_DIRS = ["patterns", "gotchas", "decisions", "tools"];
16
- const STALE_AGE_DAYS = 90;
17
- const RECENT_AGE_DAYS = 30;
18
- const MAX_CONTRADICTION_PAIRS = 20;
19
-
20
- export interface OrphanResult {
21
- orphans: string[];
22
- }
23
-
24
- export interface StaleEntry {
25
- relPath: string;
26
- project: string;
27
- ageDays: number;
28
- newerSameProjectCount: number;
29
- startedAt: string;
30
- }
31
-
32
- export interface ContradictionEntry {
33
- a: string;
34
- b: string;
35
- reason: string;
36
- }
37
-
38
- export interface ContradictionResult {
39
- checked: number;
40
- contradictions: ContradictionEntry[];
41
- }
42
-
43
- interface NoteFile {
44
- relPath: string;
45
- noteRef: string;
46
- id: string;
47
- raw: string;
48
- }
49
-
50
- export function orphanCheck(cfg: Config): OrphanResult {
51
- const notes = loadVaultNotes(cfg);
52
- const idSet = new Set(notes.map((n) => n.id));
53
-
54
- const outgoing = new Map<string, Set<string>>();
55
- const incoming = new Map<string, Set<string>>();
56
-
57
- for (const n of notes) {
58
- const targets = new Set<string>();
59
- const linkRe = /\[\[([^\]\n]+?)\]\]/g;
60
- let m: RegExpExecArray | null;
61
- while ((m = linkRe.exec(n.raw)) !== null) {
62
- const inner = (m[1] ?? "").trim();
63
- if (inner.length === 0) continue;
64
- const target = inner.split("|")[0]?.trim() ?? inner;
65
- const tail = target.includes("/")
66
- ? (target.split("/").pop() ?? target)
67
- : target;
68
- // Only count links that resolve to actual note files; Project/Category
69
- // virtual nodes (e.g. [[growthq]], [[pattern]]) don't count.
70
- if (idSet.has(tail) && tail !== n.id) {
71
- targets.add(tail);
72
- if (!incoming.has(tail)) incoming.set(tail, new Set());
73
- incoming.get(tail)!.add(n.id);
74
- }
75
- }
76
- outgoing.set(n.id, targets);
77
- }
78
-
79
- const orphans: string[] = [];
80
- for (const n of notes) {
81
- const out = outgoing.get(n.id) ?? new Set();
82
- const inc = incoming.get(n.id) ?? new Set();
83
- if (out.size === 0 && inc.size === 0) orphans.push(n.noteRef);
84
- }
85
- orphans.sort();
86
- return { orphans };
87
- }
88
-
89
- export function stalenessCheck(_cfg: Config, db: StateDb): StaleEntry[] {
90
- const rows = db.listDistilled();
91
- const now = Date.now();
92
- const day = 24 * 60 * 60 * 1000;
93
-
94
- const parsed = rows
95
- .map((r) => ({ r, t: r.startedAt ? Date.parse(r.startedAt) : NaN }))
96
- .filter((x) => Number.isFinite(x.t));
97
-
98
- const stale: StaleEntry[] = [];
99
- for (const { r, t } of parsed) {
100
- const ageDays = Math.floor((now - t) / day);
101
- if (ageDays < STALE_AGE_DAYS) continue;
102
- const projectSlug = kebab(r.project);
103
-
104
- const hasRecentSameCat = parsed.some(
105
- (x) =>
106
- kebab(x.r.project) === projectSlug &&
107
- x.r.category === r.category &&
108
- x.t > t &&
109
- (now - x.t) / day <= RECENT_AGE_DAYS,
110
- );
111
- if (!hasRecentSameCat) continue;
112
-
113
- const newerSameProj = parsed.filter(
114
- (x) => kebab(x.r.project) === projectSlug && x.t > t,
115
- ).length;
116
-
117
- stale.push({
118
- relPath: noteRef(r),
119
- project: projectSlug,
120
- ageDays,
121
- newerSameProjectCount: newerSameProj,
122
- startedAt: r.startedAt ?? "",
123
- });
124
- }
125
-
126
- stale.sort((a, b) => b.ageDays - a.ageDays);
127
- return stale;
128
- }
129
-
130
- interface CandidatePair {
131
- a: DistilledRow;
132
- b: DistilledRow;
133
- score: number;
134
- }
135
-
136
- export async function contradictionCheck(
137
- cfg: Config,
138
- db: StateDb,
139
- ): Promise<ContradictionResult> {
140
- const rows = db.listDistilled();
141
- const pairs = rankCandidatePairs(rows).slice(0, MAX_CONTRADICTION_PAIRS);
142
-
143
- if (pairs.length === 0) {
144
- return { checked: 0, contradictions: [] };
145
- }
146
-
147
- const client = buildAnthropicClient(cfg);
148
- const model = normalizeModelName(cfg.models.classify, cfg.provider);
149
- const contradictions: ContradictionEntry[] = [];
150
-
151
- for (const pair of pairs) {
152
- const prompt = `Do these two knowledge notes contradict each other?
153
- Answer JSON only: { "contradicts": boolean, "reason": "string (max 20 words)" }
154
-
155
- Note A (topic: ${pair.a.topic}):
156
- ${excerpt(pair.a.content)}
157
-
158
- Note B (topic: ${pair.b.topic}):
159
- ${excerpt(pair.b.content)}`;
160
-
161
- try {
162
- const text = await withRateLimitRetry(() =>
163
- callLLM(cfg, client, { prompt, model, maxTokens: 200 }),
164
- );
165
- const parsed = parseContradictionResponse(text);
166
- if (parsed.contradicts) {
167
- contradictions.push({
168
- a: noteRef(pair.a),
169
- b: noteRef(pair.b),
170
- reason: parsed.reason,
171
- });
172
- }
173
- } catch {
174
- // ignore individual pair failures — don't abort the whole check
175
- }
176
- }
177
-
178
- return { checked: pairs.length, contradictions };
179
- }
180
-
181
- function rankCandidatePairs(rows: DistilledRow[]): CandidatePair[] {
182
- const pairs: CandidatePair[] = [];
183
- for (let i = 0; i < rows.length; i += 1) {
184
- const a = rows[i];
185
- if (!a) continue;
186
- for (let j = i + 1; j < rows.length; j += 1) {
187
- const b = rows[j];
188
- if (!b) continue;
189
- const sameProjCat =
190
- kebab(a.project) === kebab(b.project) && a.category === b.category;
191
- const sharedTokens = countSharedTokens(a.topic, b.topic);
192
- if (!sameProjCat && sharedTokens < 2) continue;
193
- const score = (sameProjCat ? 2 : 0) + sharedTokens;
194
- pairs.push({ a, b, score });
195
- }
196
- }
197
- pairs.sort((x, y) => y.score - x.score);
198
- return pairs;
199
- }
200
-
201
- function countSharedTokens(a: string, b: string): number {
202
- const at = new Set(kebab(a).split("-").filter((t) => t.length >= 3));
203
- let n = 0;
204
- for (const t of kebab(b).split("-")) {
205
- if (t.length >= 3 && at.has(t)) n += 1;
206
- }
207
- return n;
208
- }
209
-
210
- function parseContradictionResponse(text: string): {
211
- contradicts: boolean;
212
- reason: string;
213
- } {
214
- const match = text.match(/\{[\s\S]*\}/);
215
- if (!match) return { contradicts: false, reason: "" };
216
- try {
217
- const obj = JSON.parse(match[0]) as Record<string, unknown>;
218
- return {
219
- contradicts: obj.contradicts === true,
220
- reason: typeof obj.reason === "string" ? obj.reason : "",
221
- };
222
- } catch {
223
- return { contradicts: false, reason: "" };
224
- }
225
- }
226
-
227
- function excerpt(s: string): string {
228
- return s.replace(/\s+/g, " ").trim().slice(0, 300);
229
- }
230
-
231
- function loadVaultNotes(cfg: Config): NoteFile[] {
232
- const root = join(cfg.vaultPath, cfg.outputDir);
233
- const files: string[] = [];
234
- walkVault(root, files);
235
-
236
- const notes: NoteFile[] = [];
237
- for (const full of files) {
238
- const rel = relative(root, full);
239
- const parts = rel.split("/");
240
- const base = parts[parts.length - 1] ?? "";
241
- if (SKIP_BASENAMES.has(base)) continue;
242
- const firstDir = parts[0] ?? "";
243
- if (SKIP_DIRS.has(firstDir)) continue;
244
- if (!CATEGORY_DIRS.includes(firstDir)) continue;
245
- let raw: string;
246
- try {
247
- raw = readFileSync(full, "utf8");
248
- } catch {
249
- continue;
250
- }
251
- notes.push({
252
- relPath: rel,
253
- noteRef: rel.replace(/\.md$/, ""),
254
- id: base.replace(/\.md$/, ""),
255
- raw,
256
- });
257
- }
258
- return notes;
259
- }
260
-
261
- function walkVault(dir: string, acc: string[]): void {
262
- let entries: string[];
263
- try {
264
- entries = readdirSync(dir);
265
- } catch {
266
- return;
267
- }
268
- for (const name of entries) {
269
- const full = join(dir, name);
270
- let st: ReturnType<typeof statSync>;
271
- try {
272
- st = statSync(full);
273
- } catch {
274
- continue;
275
- }
276
- if (st.isDirectory()) walkVault(full, acc);
277
- else if (st.isFile() && name.endsWith(".md")) acc.push(full);
278
- }
279
- }
280
-
281
- function noteRef(r: DistilledRow): string {
282
- const dir = `${r.category}s`;
283
- const slug = kebab(r.topic);
284
- const suffix = r.sessionId.slice(0, 8);
285
- return `${dir}/${slug}-${suffix}`;
286
- }
@@ -1,280 +0,0 @@
1
- import Anthropic from "@anthropic-ai/sdk";
2
- import type { Config } from "../config.js";
3
- import type {
4
- Category,
5
- Classification,
6
- DistilledNote,
7
- ParsedSession,
8
- } from "./types.js";
9
-
10
- const CATEGORIES: Category[] = ["pattern", "gotcha", "decision", "tool"];
11
-
12
- class HttpError extends Error {
13
- status: number;
14
- constructor(status: number, message: string) {
15
- super(message);
16
- this.status = status;
17
- this.name = "HttpError";
18
- }
19
- }
20
-
21
- export function buildAnthropicClient(config: Config): Anthropic {
22
- return new Anthropic({ apiKey: config.anthropicApiKey ?? "" });
23
- }
24
-
25
- // Canonical model IDs accepted by Kie's /claude/v1/messages endpoint.
26
- // Anything that *starts with* one of these keys collapses to the bare ID,
27
- // so a stray suffix in config (date stamp, accidental path fragment like
28
- // "v1messages", etc.) can't corrupt the outgoing model string.
29
- const KIE_CANONICAL_MODELS = ["claude-haiku-4-5", "claude-sonnet-4-6"] as const;
30
-
31
- export function normalizeModelName(model: string, provider: string): string {
32
- if (provider !== "kie") return model;
33
- for (const canonical of KIE_CANONICAL_MODELS) {
34
- if (model.startsWith(canonical)) return canonical;
35
- }
36
- // Fallback: still strip a trailing -YYYYMMDD date suffix.
37
- return model.replace(/-\d{8}$/, "");
38
- }
39
-
40
- interface KieResponseBlock {
41
- type?: string;
42
- text?: string;
43
- }
44
- interface KieResponse {
45
- content?: KieResponseBlock[];
46
- error?: { message?: string };
47
- }
48
-
49
- async function callKie(opts: {
50
- apiKey: string;
51
- model: string;
52
- maxTokens: number;
53
- prompt: string;
54
- }): Promise<string> {
55
- const response = await fetch("https://api.kie.ai/claude/v1/messages", {
56
- method: "POST",
57
- headers: {
58
- Authorization: `Bearer ${opts.apiKey}`,
59
- "Content-Type": "application/json",
60
- },
61
- body: JSON.stringify({
62
- model: opts.model,
63
- max_tokens: opts.maxTokens,
64
- messages: [{ role: "user", content: opts.prompt }],
65
- }),
66
- });
67
-
68
- if (!response.ok) {
69
- const body = await response.text().catch(() => "");
70
- throw new HttpError(
71
- response.status,
72
- `Kie ${response.status}: ${body.slice(0, 500)}`,
73
- );
74
- }
75
-
76
- const data = (await response.json()) as KieResponse;
77
- const text = data.content?.[0]?.text ?? "";
78
- return text;
79
- }
80
-
81
- async function callAnthropic(opts: {
82
- client: Anthropic;
83
- model: string;
84
- maxTokens: number;
85
- prompt: string;
86
- }): Promise<string> {
87
- const resp = await opts.client.messages.create({
88
- model: opts.model,
89
- max_tokens: opts.maxTokens,
90
- messages: [{ role: "user", content: opts.prompt }],
91
- });
92
- const parts: string[] = [];
93
- for (const block of resp.content) {
94
- if (block.type === "text") parts.push(block.text);
95
- }
96
- return parts.join("\n");
97
- }
98
-
99
- export interface LlmCallOpts {
100
- prompt: string;
101
- model: string;
102
- maxTokens: number;
103
- }
104
-
105
- export async function callLLM(
106
- config: Config,
107
- client: Anthropic,
108
- opts: LlmCallOpts,
109
- ): Promise<string> {
110
- if (config.provider === "kie") {
111
- return callKie({
112
- apiKey: config.kieApiKey ?? "",
113
- model: opts.model,
114
- maxTokens: opts.maxTokens,
115
- prompt: opts.prompt,
116
- });
117
- }
118
- return callAnthropic({
119
- client,
120
- model: opts.model,
121
- maxTokens: opts.maxTokens,
122
- prompt: opts.prompt,
123
- });
124
- }
125
-
126
- export class Distiller {
127
- private client: Anthropic;
128
- private cfg: Config;
129
- private classifyModel: string;
130
- private distillModel: string;
131
-
132
- constructor(cfg: Config) {
133
- this.cfg = cfg;
134
- this.client = buildAnthropicClient(cfg);
135
- this.classifyModel = normalizeModelName(cfg.models.classify, cfg.provider);
136
- this.distillModel = normalizeModelName(cfg.models.distill, cfg.provider);
137
- }
138
-
139
- async classify(
140
- session: ParsedSession,
141
- scrubbedSummary: string,
142
- ): Promise<Classification> {
143
- const prompt = `Given this Claude Code session summary, output JSON only:
144
- { "category": "pattern" | "gotcha" | "decision" | "tool",
145
- "topic": string (2-4 words, kebab-friendly),
146
- "project": string,
147
- "confidence": number (0..1) }
148
-
149
- Project slug from path: ${session.projectSlug}
150
-
151
- Session:
152
- ${scrubbedSummary}`;
153
-
154
- const text = await withRateLimitRetry(() =>
155
- callLLM(this.cfg, this.client, {
156
- prompt,
157
- model: this.classifyModel,
158
- maxTokens: 400,
159
- }),
160
- );
161
- return parseClassification(text, session.projectSlug);
162
- }
163
-
164
- async distill(
165
- session: ParsedSession,
166
- scrubbedContent: string,
167
- cls: Classification,
168
- ): Promise<string> {
169
- const prompt = `Extract durable knowledge from this Claude Code session.
170
-
171
- Output a markdown page with these sections (no preamble, start with '## Summary'):
172
- - ## Summary (2-3 sentences)
173
- - ## What Was Learned
174
- - ## Context (project: ${cls.project}, category: ${cls.category}, date: ${session.startedAt ?? "unknown"})
175
- - ## Related
176
-
177
- Be concise. Only include information a future developer would reuse.
178
- Omit implementation details that won't generalize.
179
-
180
- Session:
181
- ${scrubbedContent}`;
182
-
183
- const text = await withRateLimitRetry(() =>
184
- callLLM(this.cfg, this.client, {
185
- prompt,
186
- model: this.distillModel,
187
- maxTokens: 1500,
188
- }),
189
- );
190
- return text.trim();
191
- }
192
-
193
- async run(
194
- session: ParsedSession,
195
- scrubbedSummary: string,
196
- scrubbedContent: string,
197
- ): Promise<DistilledNote | null> {
198
- const cls = await this.classify(session, scrubbedSummary);
199
- if (cls.confidence <= 0.6) return null;
200
- const md = await this.distill(session, scrubbedContent, cls);
201
- return { classification: cls, markdown: md };
202
- }
203
- }
204
-
205
- const RETRY_DELAYS_MS = [60_000, 120_000, 240_000];
206
-
207
- function isRateLimit(err: unknown): boolean {
208
- if (!err || typeof err !== "object") return false;
209
- const e = err as { status?: number; statusCode?: number };
210
- if (e.status === 429 || e.statusCode === 429) return true;
211
- if (err instanceof Anthropic.APIError && err.status === 429) return true;
212
- if (err instanceof HttpError && err.status === 429) return true;
213
- return false;
214
- }
215
-
216
- async function sleep(ms: number): Promise<void> {
217
- return new Promise((r) => setTimeout(r, ms));
218
- }
219
-
220
- export async function withRateLimitRetry<T>(fn: () => Promise<T>): Promise<T> {
221
- for (let attempt = 0; attempt < RETRY_DELAYS_MS.length; attempt += 1) {
222
- try {
223
- return await fn();
224
- } catch (err) {
225
- if (!isRateLimit(err)) throw err;
226
- const delay = RETRY_DELAYS_MS[attempt] ?? 240_000;
227
- console.warn(
228
- `[vir] 429 rate limit — retry ${attempt + 1}/${RETRY_DELAYS_MS.length} in ${delay / 1000}s`,
229
- );
230
- await sleep(delay);
231
- }
232
- }
233
- return await fn();
234
- }
235
-
236
- function parseClassification(
237
- text: string,
238
- fallbackProject: string,
239
- ): Classification {
240
- const jsonMatch = text.match(/\{[\s\S]*\}/);
241
- if (!jsonMatch) {
242
- return {
243
- category: "pattern",
244
- topic: "unknown",
245
- project: fallbackProject,
246
- confidence: 0,
247
- };
248
- }
249
- let obj: Record<string, unknown>;
250
- try {
251
- obj = JSON.parse(jsonMatch[0]) as Record<string, unknown>;
252
- } catch {
253
- return {
254
- category: "pattern",
255
- topic: "unknown",
256
- project: fallbackProject,
257
- confidence: 0,
258
- };
259
- }
260
- const rawCat = typeof obj.category === "string" ? obj.category : "pattern";
261
- const category: Category = (CATEGORIES as string[]).includes(rawCat)
262
- ? (rawCat as Category)
263
- : "pattern";
264
- const topic =
265
- typeof obj.topic === "string" && obj.topic.trim().length > 0
266
- ? obj.topic.trim()
267
- : "unknown";
268
- const project =
269
- typeof obj.project === "string" && obj.project.trim().length > 0
270
- ? obj.project.trim()
271
- : fallbackProject;
272
- const confidenceRaw =
273
- typeof obj.confidence === "number"
274
- ? obj.confidence
275
- : Number(obj.confidence ?? 0);
276
- const confidence = Number.isFinite(confidenceRaw)
277
- ? Math.max(0, Math.min(1, confidenceRaw))
278
- : 0;
279
- return { category, topic, project, confidence };
280
- }
@@ -1,43 +0,0 @@
1
- import type { ParsedSession } from "./types.js";
2
-
3
- const SIGNAL_REGEX = /\b(error|fixed|bug|learned|gotcha|workaround|fixed it|root cause)\b/i;
4
-
5
- export interface FilterResult {
6
- score: number;
7
- passes: boolean;
8
- reasons: string[];
9
- }
10
-
11
- export function scoreSession(
12
- session: ParsedSession,
13
- threshold: number,
14
- ): FilterResult {
15
- let score = 0;
16
- const reasons: string[] = [];
17
-
18
- if (session.lineCount > 50) {
19
- score += 0.3;
20
- reasons.push(`lineCount>50 (+0.3)`);
21
- }
22
- if (session.toolCallCount > 5) {
23
- score += 0.3;
24
- reasons.push(`toolCalls>5 (+0.3)`);
25
- }
26
- if (session.filesTouched.length > 2) {
27
- score += 0.2;
28
- reasons.push(`files>2 (+0.2)`);
29
- }
30
- if (
31
- SIGNAL_REGEX.test(session.assistantText) ||
32
- SIGNAL_REGEX.test(session.userText)
33
- ) {
34
- score += 0.2;
35
- reasons.push(`signal-word (+0.2)`);
36
- }
37
-
38
- return {
39
- score: Math.round(score * 100) / 100,
40
- passes: score >= threshold,
41
- reasons,
42
- };
43
- }