aidimag 1.0.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/LICENSE +102 -0
- package/README.md +113 -0
- package/dist/capture/bootstrap.d.ts +25 -0
- package/dist/capture/bootstrap.js +188 -0
- package/dist/capture/commit-miner.d.ts +59 -0
- package/dist/capture/commit-miner.js +381 -0
- package/dist/capture/harvest.d.ts +50 -0
- package/dist/capture/harvest.js +207 -0
- package/dist/capture/pr-miner.d.ts +57 -0
- package/dist/capture/pr-miner.js +185 -0
- package/dist/capture/session-briefing.d.ts +36 -0
- package/dist/capture/session-briefing.js +150 -0
- package/dist/capture/session-extraction.d.ts +23 -0
- package/dist/capture/session-extraction.js +54 -0
- package/dist/capture/triage.d.ts +30 -0
- package/dist/capture/triage.js +108 -0
- package/dist/cli/commands/capture.d.ts +5 -0
- package/dist/cli/commands/capture.js +357 -0
- package/dist/cli/commands/hosts.d.ts +5 -0
- package/dist/cli/commands/hosts.js +98 -0
- package/dist/cli/commands/knowledge.d.ts +5 -0
- package/dist/cli/commands/knowledge.js +121 -0
- package/dist/cli/commands/memory.d.ts +6 -0
- package/dist/cli/commands/memory.js +392 -0
- package/dist/cli/commands/sync.d.ts +5 -0
- package/dist/cli/commands/sync.js +307 -0
- package/dist/cli/commands/tickets.d.ts +6 -0
- package/dist/cli/commands/tickets.js +328 -0
- package/dist/cli/commands/verify.d.ts +5 -0
- package/dist/cli/commands/verify.js +136 -0
- package/dist/cli/index.d.ts +19 -0
- package/dist/cli/index.js +46 -0
- package/dist/cli/shared.d.ts +37 -0
- package/dist/cli/shared.js +175 -0
- package/dist/config.d.ts +55 -0
- package/dist/config.js +51 -0
- package/dist/context/generate.d.ts +24 -0
- package/dist/context/generate.js +115 -0
- package/dist/critique/critique.d.ts +40 -0
- package/dist/critique/critique.js +110 -0
- package/dist/db/schema.d.ts +28 -0
- package/dist/db/schema.js +269 -0
- package/dist/db/store.d.ts +182 -0
- package/dist/db/store.js +906 -0
- package/dist/debug.d.ts +14 -0
- package/dist/debug.js +25 -0
- package/dist/embeddings/provider.d.ts +16 -0
- package/dist/embeddings/provider.js +100 -0
- package/dist/embeddings/search.d.ts +22 -0
- package/dist/embeddings/search.js +95 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.js +12 -0
- package/dist/knowledge/chunk.d.ts +9 -0
- package/dist/knowledge/chunk.js +78 -0
- package/dist/knowledge/extract.d.ts +34 -0
- package/dist/knowledge/extract.js +113 -0
- package/dist/knowledge/ingest.d.ts +79 -0
- package/dist/knowledge/ingest.js +305 -0
- package/dist/knowledge/llm.d.ts +21 -0
- package/dist/knowledge/llm.js +110 -0
- package/dist/mcp/server.d.ts +11 -0
- package/dist/mcp/server.js +532 -0
- package/dist/security/evidence.d.ts +11 -0
- package/dist/security/evidence.js +15 -0
- package/dist/security/seal.d.ts +3 -0
- package/dist/security/seal.js +28 -0
- package/dist/security/sync-push.d.ts +2 -0
- package/dist/security/sync-push.js +37 -0
- package/dist/security/url.d.ts +6 -0
- package/dist/security/url.js +67 -0
- package/dist/sync/client.d.ts +84 -0
- package/dist/sync/client.js +391 -0
- package/dist/sync/server.d.ts +75 -0
- package/dist/sync/server.js +659 -0
- package/dist/tickets/provider.d.ts +80 -0
- package/dist/tickets/provider.js +375 -0
- package/dist/types.d.ts +133 -0
- package/dist/types.js +5 -0
- package/dist/ui/page.d.ts +5 -0
- package/dist/ui/page.js +841 -0
- package/dist/ui/server.d.ts +10 -0
- package/dist/ui/server.js +437 -0
- package/dist/verify/check.d.ts +38 -0
- package/dist/verify/check.js +121 -0
- package/dist/verify/engine.d.ts +39 -0
- package/dist/verify/engine.js +178 -0
- package/dist/verify/hooks.d.ts +24 -0
- package/dist/verify/hooks.js +125 -0
- package/dist/verify/runners.d.ts +26 -0
- package/dist/verify/runners.js +193 -0
- package/package.json +78 -0
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Knowledge ingestion pipeline (KNOWLEDGEBASE_DESIGN.md).
|
|
3
|
+
*
|
|
4
|
+
* Drop docs into the `knowledge/` inbox → they're classified, (chunked and)
|
|
5
|
+
* summarized into typed claims → queued as proposals (source `knowledge:<doc>`,
|
|
6
|
+
* pin-on-approve) → a plain-text summary is written and the original is backed up
|
|
7
|
+
* to .aidimag/knowledge/processed/ before the inbox copy is removed.
|
|
8
|
+
* PDF and DOCX files have their text extracted (pdf-parse / mammoth) first.
|
|
9
|
+
*
|
|
10
|
+
* Trust gate: claims become PINNED memories only after `dim review` (unless the
|
|
11
|
+
* repo opts out with knowledge.requireReview = false). Nothing is ever deleted —
|
|
12
|
+
* unsupported files move to .aidimag/knowledge/skipped/ with a reason.
|
|
13
|
+
*/
|
|
14
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync, statSync, renameSync, copyFileSync, } from "node:fs";
|
|
15
|
+
import path from "node:path";
|
|
16
|
+
import { createHash } from "node:crypto";
|
|
17
|
+
import { KNOWLEDGE_EXTRACT_INSTRUCTIONS, buildExtractionUser, parseClaims, dedupeClaims } from "./extract.js";
|
|
18
|
+
import { chunkText } from "./chunk.js";
|
|
19
|
+
import { getTextProvider } from "./llm.js";
|
|
20
|
+
const KB_DIR = path.join(".aidimag", "knowledge");
|
|
21
|
+
// ── paths ───────────────────────────────────────────────────────────────────
|
|
22
|
+
function inboxDir(root, cfg) {
|
|
23
|
+
return path.join(root, cfg.folder);
|
|
24
|
+
}
|
|
25
|
+
function kbDir(root) { return path.join(root, KB_DIR); }
|
|
26
|
+
function processedDir(root) { return path.join(kbDir(root), "processed"); }
|
|
27
|
+
function skippedDir(root) { return path.join(kbDir(root), "skipped"); }
|
|
28
|
+
function manifestPath(root) { return path.join(kbDir(root), "manifest.json"); }
|
|
29
|
+
function summaryPath(root, file) {
|
|
30
|
+
return path.join(kbDir(root), `${file}.summary.md`);
|
|
31
|
+
}
|
|
32
|
+
export function readManifest(root) {
|
|
33
|
+
try {
|
|
34
|
+
return JSON.parse(readFileSync(manifestPath(root), "utf8"));
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
return { version: 1, docs: [] };
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
function appendManifest(root, entry) {
|
|
41
|
+
const m = readManifest(root);
|
|
42
|
+
m.docs.push(entry);
|
|
43
|
+
mkdirSync(kbDir(root), { recursive: true });
|
|
44
|
+
writeFileSync(manifestPath(root), JSON.stringify(m, null, 2) + "\n");
|
|
45
|
+
}
|
|
46
|
+
// ── classification (read-only; no side effects) ───────────────────────────────
|
|
47
|
+
function isBinary(buf) {
|
|
48
|
+
const n = Math.min(buf.length, 8192);
|
|
49
|
+
for (let i = 0; i < n; i++)
|
|
50
|
+
if (buf[i] === 0)
|
|
51
|
+
return true; // NUL byte → binary
|
|
52
|
+
try {
|
|
53
|
+
new TextDecoder("utf-8", { fatal: true }).decode(buf.subarray(0, n));
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
return true; // not valid UTF-8
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
// ── binary document extraction (.pdf / .docx → plain text) ────────────────────
|
|
61
|
+
const BINARY_DOC_EXTS = new Set([".pdf", ".docx"]);
|
|
62
|
+
/**
|
|
63
|
+
* Extract plain text from a binary document. Parsers are imported lazily so
|
|
64
|
+
* repos that never ingest PDFs/DOCX pay no startup cost.
|
|
65
|
+
*/
|
|
66
|
+
async function extractBinaryDocText(buf, ext) {
|
|
67
|
+
if (ext === ".pdf") {
|
|
68
|
+
// NOTE: import the lib entry directly — pdf-parse's index.js runs debug
|
|
69
|
+
// code (reads a test fixture) when loaded outside a CJS parent module.
|
|
70
|
+
const mod = await import("pdf-parse/lib/pdf-parse.js");
|
|
71
|
+
const pdfParse = mod.default;
|
|
72
|
+
const parsed = await pdfParse(buf);
|
|
73
|
+
return String(parsed.text ?? "");
|
|
74
|
+
}
|
|
75
|
+
if (ext === ".docx") {
|
|
76
|
+
const mammoth = await import("mammoth");
|
|
77
|
+
const res = await mammoth.extractRawText({ buffer: buf });
|
|
78
|
+
return res.value ?? "";
|
|
79
|
+
}
|
|
80
|
+
throw new Error(`no text extractor for ${ext}`);
|
|
81
|
+
}
|
|
82
|
+
/** Collapse extractor artifacts: >2 blank lines, trailing spaces. */
|
|
83
|
+
function tidyExtractedText(text) {
|
|
84
|
+
return text.replace(/\r\n/g, "\n").replace(/[ \t]+\n/g, "\n").replace(/\n{3,}/g, "\n\n").trim();
|
|
85
|
+
}
|
|
86
|
+
export async function classifyInbox(root, cfg) {
|
|
87
|
+
const dir = inboxDir(root, cfg);
|
|
88
|
+
const pending = [];
|
|
89
|
+
const toSkip = [];
|
|
90
|
+
if (!existsSync(dir))
|
|
91
|
+
return { pending, toSkip };
|
|
92
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
93
|
+
if (!entry.isFile())
|
|
94
|
+
continue;
|
|
95
|
+
if (entry.name.startsWith("."))
|
|
96
|
+
continue; // .gitkeep and other dotfiles
|
|
97
|
+
const abs = path.join(dir, entry.name);
|
|
98
|
+
const ext = path.extname(entry.name).toLowerCase();
|
|
99
|
+
if (!cfg.extensions.includes(ext)) {
|
|
100
|
+
toSkip.push({ file: entry.name, reason: `unsupported type (${ext || "no extension"})` });
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
const size = statSync(abs).size;
|
|
104
|
+
if (size > cfg.maxBytes) {
|
|
105
|
+
toSkip.push({ file: entry.name, reason: `exceeds maxBytes (${size} > ${cfg.maxBytes})` });
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
const buf = readFileSync(abs);
|
|
109
|
+
let content;
|
|
110
|
+
if (BINARY_DOC_EXTS.has(ext)) {
|
|
111
|
+
// PDF/DOCX: extract text before summarization; extraction failures are
|
|
112
|
+
// skipped (never deleted) with the parser error as the reason.
|
|
113
|
+
try {
|
|
114
|
+
content = tidyExtractedText(await extractBinaryDocText(buf, ext));
|
|
115
|
+
}
|
|
116
|
+
catch (e) {
|
|
117
|
+
toSkip.push({ file: entry.name, reason: `text extraction failed (${e instanceof Error ? e.message : String(e)})` });
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
if (!content) {
|
|
121
|
+
toSkip.push({ file: entry.name, reason: "no extractable text (scanned/image-only document?)" });
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
else {
|
|
126
|
+
if (isBinary(buf)) {
|
|
127
|
+
toSkip.push({ file: entry.name, reason: "not text-decodable (looks binary)" });
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
content = buf.toString("utf8");
|
|
131
|
+
if (!content.trim()) {
|
|
132
|
+
toSkip.push({ file: entry.name, reason: "no content" });
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
pending.push({
|
|
137
|
+
file: entry.name,
|
|
138
|
+
abs,
|
|
139
|
+
content,
|
|
140
|
+
bytes: size,
|
|
141
|
+
hash: createHash("sha256").update(content).digest("hex"),
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
return { pending, toSkip };
|
|
145
|
+
}
|
|
146
|
+
function moveToSkipped(root, cfg, file, reason) {
|
|
147
|
+
const dest = skippedDir(root);
|
|
148
|
+
mkdirSync(dest, { recursive: true });
|
|
149
|
+
const src = path.join(inboxDir(root, cfg), file);
|
|
150
|
+
try {
|
|
151
|
+
renameSync(src, path.join(dest, file));
|
|
152
|
+
}
|
|
153
|
+
catch {
|
|
154
|
+
// cross-device or already gone — best-effort copy
|
|
155
|
+
try {
|
|
156
|
+
copyFileSync(src, path.join(dest, file));
|
|
157
|
+
}
|
|
158
|
+
catch { /* ignore */ }
|
|
159
|
+
}
|
|
160
|
+
writeFileSync(path.join(dest, `${file}.reason.txt`), `${reason}\nskipped: ${new Date().toISOString()}\n`);
|
|
161
|
+
}
|
|
162
|
+
// ── summarization (LLM path) ──────────────────────────────────────────────────
|
|
163
|
+
async function summarizeWithLlm(provider, doc, cfg) {
|
|
164
|
+
const chunks = chunkText(doc.content, cfg.chunkBytes);
|
|
165
|
+
const all = [];
|
|
166
|
+
for (const chunk of chunks) {
|
|
167
|
+
const raw = await provider.generate(KNOWLEDGE_EXTRACT_INSTRUCTIONS, buildExtractionUser(doc.file, chunk));
|
|
168
|
+
all.push(...parseClaims(raw));
|
|
169
|
+
}
|
|
170
|
+
return dedupeClaims(all);
|
|
171
|
+
}
|
|
172
|
+
// ── finalize one doc: proposals → summary → backup → remove inbox copy ─────────
|
|
173
|
+
/**
|
|
174
|
+
* Turn extracted claims into proposals and retire the inbox file safely.
|
|
175
|
+
* Used by both the LLM path and the MCP agent path (which supplies its own claims).
|
|
176
|
+
*/
|
|
177
|
+
export function finalizeDoc(store, root, cfg, doc, claims, via) {
|
|
178
|
+
const source = `knowledge:${doc.file}`;
|
|
179
|
+
const sourceRef = doc.hash.slice(0, 12);
|
|
180
|
+
const proposalIds = [];
|
|
181
|
+
for (const c of claims) {
|
|
182
|
+
const p = store.propose({
|
|
183
|
+
kind: c.kind,
|
|
184
|
+
claim: c.claim,
|
|
185
|
+
paths: c.paths,
|
|
186
|
+
symbols: c.symbols,
|
|
187
|
+
guardrailLevel: c.guardrailLevel,
|
|
188
|
+
source,
|
|
189
|
+
sourceRef,
|
|
190
|
+
rationale: c.rationale ?? `Extracted from ${doc.file}`,
|
|
191
|
+
evidence: [
|
|
192
|
+
{ type: "HUMAN_ATTESTED", payload: `Extracted from knowledge doc ${doc.file} (${via})` },
|
|
193
|
+
],
|
|
194
|
+
});
|
|
195
|
+
if (p)
|
|
196
|
+
proposalIds.push(p.id);
|
|
197
|
+
}
|
|
198
|
+
// Optional auto-approve (opt-out of the review gate). SAFETY: unlike
|
|
199
|
+
// human-reviewed approvals, auto-approved claims are NOT pinned — a machine
|
|
200
|
+
// wrote them and no human looked, so they must stay subject to decay and
|
|
201
|
+
// evidence checks rather than leading the generated context forever.
|
|
202
|
+
const memoryIds = [];
|
|
203
|
+
const pinned = cfg.requireReview === false;
|
|
204
|
+
if (pinned) {
|
|
205
|
+
for (const id of proposalIds) {
|
|
206
|
+
try {
|
|
207
|
+
memoryIds.push(store.approveProposal(id, { pinned: false }).id);
|
|
208
|
+
}
|
|
209
|
+
catch { /* ignore */ }
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
// Durable record + backup BEFORE removing the inbox copy (no data loss).
|
|
213
|
+
mkdirSync(kbDir(root), { recursive: true });
|
|
214
|
+
writeFileSync(summaryPath(root, doc.file), renderSummary(doc.file, doc.hash, via, claims, pinned));
|
|
215
|
+
if (cfg.backup) {
|
|
216
|
+
mkdirSync(processedDir(root), { recursive: true });
|
|
217
|
+
const src = doc.abs ?? path.join(inboxDir(root, cfg), doc.file);
|
|
218
|
+
if (existsSync(src))
|
|
219
|
+
copyFileSync(src, path.join(processedDir(root), doc.file));
|
|
220
|
+
}
|
|
221
|
+
// retire the inbox original
|
|
222
|
+
const inboxFile = doc.abs ?? path.join(inboxDir(root, cfg), doc.file);
|
|
223
|
+
try {
|
|
224
|
+
if (existsSync(inboxFile))
|
|
225
|
+
renameSync(inboxFile, path.join(processedDir(root), doc.file));
|
|
226
|
+
}
|
|
227
|
+
catch { /* already backed up */ }
|
|
228
|
+
appendManifest(root, {
|
|
229
|
+
file: doc.file, hash: doc.hash, date: new Date().toISOString(),
|
|
230
|
+
via, claimCount: claims.length, proposalIds, memoryIds,
|
|
231
|
+
});
|
|
232
|
+
return { file: doc.file, hash: doc.hash, claimCount: claims.length, proposalIds, memoryIds, pinned };
|
|
233
|
+
}
|
|
234
|
+
function renderSummary(file, hash, via, claims, pinned) {
|
|
235
|
+
const lines = [];
|
|
236
|
+
lines.push(`# Knowledge summary: ${file}`);
|
|
237
|
+
lines.push(`> source: ${file} · sha256: ${hash} · summarized by: ${via} · ${new Date().toISOString()}`);
|
|
238
|
+
lines.push(pinned
|
|
239
|
+
? `> ⚠️ ${claims.length} claim(s) auto-approved (knowledge.requireReview:false) as ACTIVE but UNPINNED memories — no human reviewed them, so they stay subject to decay. Pin the keepers with \`dim pin\`.`
|
|
240
|
+
: `> ${claims.length} claim(s) queued as proposals — review with \`dim review\`.`);
|
|
241
|
+
lines.push("");
|
|
242
|
+
if (!claims.length) {
|
|
243
|
+
lines.push("_No durable claims were extracted from this document._");
|
|
244
|
+
}
|
|
245
|
+
for (const c of claims) {
|
|
246
|
+
const lvl = c.kind === "GUARDRAIL" && c.guardrailLevel ? ` (${c.guardrailLevel})` : "";
|
|
247
|
+
lines.push(`- **[${c.kind}${lvl}]** ${c.claim}`);
|
|
248
|
+
const scope = [...(c.paths ?? []), ...(c.symbols ?? [])];
|
|
249
|
+
if (scope.length)
|
|
250
|
+
lines.push(` - scope: ${scope.join(", ")}`);
|
|
251
|
+
if (c.rationale)
|
|
252
|
+
lines.push(` - why: ${c.rationale}`);
|
|
253
|
+
}
|
|
254
|
+
return lines.join("\n") + "\n";
|
|
255
|
+
}
|
|
256
|
+
// ── top-level: process the whole inbox (CLI / hook / watcher) ──────────────────
|
|
257
|
+
export async function ingestAll(store, root, cfg) {
|
|
258
|
+
const { pending, toSkip } = await classifyInbox(root, cfg);
|
|
259
|
+
// move unsupported files aside (never deleted)
|
|
260
|
+
for (const s of toSkip)
|
|
261
|
+
moveToSkipped(root, cfg, s.file, s.reason);
|
|
262
|
+
const report = {
|
|
263
|
+
processed: [], pendingNoSummarizer: [], duplicates: [], skipped: toSkip, summarizer: null,
|
|
264
|
+
};
|
|
265
|
+
// resolve summarizer (LLM path); "agent"/"off" never summarize from the CLI
|
|
266
|
+
let provider = null;
|
|
267
|
+
if (cfg.summarizer === "auto" || cfg.summarizer === "llm") {
|
|
268
|
+
provider = await getTextProvider().catch(() => null);
|
|
269
|
+
}
|
|
270
|
+
report.summarizer = provider?.name ?? null;
|
|
271
|
+
const known = new Set(readManifest(root).docs.map((d) => d.hash));
|
|
272
|
+
for (const doc of pending) {
|
|
273
|
+
if (known.has(doc.hash)) {
|
|
274
|
+
// identical content already ingested — just retire the inbox copy
|
|
275
|
+
mkdirSync(processedDir(root), { recursive: true });
|
|
276
|
+
try {
|
|
277
|
+
renameSync(doc.abs, path.join(processedDir(root), doc.file));
|
|
278
|
+
}
|
|
279
|
+
catch { /* ignore */ }
|
|
280
|
+
report.duplicates.push(doc.file);
|
|
281
|
+
continue;
|
|
282
|
+
}
|
|
283
|
+
if (!provider) {
|
|
284
|
+
report.pendingNoSummarizer.push(doc.file); // leave in inbox for next time
|
|
285
|
+
continue;
|
|
286
|
+
}
|
|
287
|
+
const claims = await summarizeWithLlm(provider, doc, cfg);
|
|
288
|
+
report.processed.push(finalizeDoc(store, root, cfg, doc, claims, `llm:${provider.name}`));
|
|
289
|
+
}
|
|
290
|
+
return report;
|
|
291
|
+
}
|
|
292
|
+
export async function knowledgeStatus(root, cfg) {
|
|
293
|
+
const { pending, toSkip } = await classifyInbox(root, cfg);
|
|
294
|
+
const skippedOnDisk = existsSync(skippedDir(root))
|
|
295
|
+
? readdirSync(skippedDir(root)).filter((f) => !f.endsWith(".reason.txt"))
|
|
296
|
+
: [];
|
|
297
|
+
return {
|
|
298
|
+
folder: cfg.folder,
|
|
299
|
+
pending,
|
|
300
|
+
unsupported: toSkip,
|
|
301
|
+
skippedOnDisk,
|
|
302
|
+
processed: readManifest(root).docs,
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
//# sourceMappingURL=ingest.js.map
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Text-generation provider for knowledge summarization — the CLI/`dim knowledge sync`
|
|
3
|
+
* "LLM fallback" when no MCP agent is in the loop. Auto-detects the same way the
|
|
4
|
+
* embedding provider does: OpenAI if OPENAI_API_KEY is set, else a local Ollama, else
|
|
5
|
+
* none (in which case docs simply wait in the inbox until a provider/agent appears).
|
|
6
|
+
*
|
|
7
|
+
* AIDIMAG_LLM = auto (default) | openai | ollama | off
|
|
8
|
+
* AIDIMAG_OPENAI_CHAT_MODEL (default gpt-4o-mini)
|
|
9
|
+
* AIDIMAG_OLLAMA_CHAT_MODEL (default llama3.1)
|
|
10
|
+
* AIDIMAG_OLLAMA_URL (default http://localhost:11434)
|
|
11
|
+
*/
|
|
12
|
+
export interface TextProvider {
|
|
13
|
+
readonly name: string;
|
|
14
|
+
readonly model: string;
|
|
15
|
+
/** Returns the model's raw text response (expected to be JSON for our prompts). */
|
|
16
|
+
generate(system: string, user: string): Promise<string>;
|
|
17
|
+
}
|
|
18
|
+
/** Check if running inside an MCP client (e.g., Cursor AI with MCP enabled). */
|
|
19
|
+
export declare function isMcpAvailable(): boolean;
|
|
20
|
+
/** Resolve the text/LLM provider (cached per process). null = none available. */
|
|
21
|
+
export declare function getTextProvider(): Promise<TextProvider | null>;
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Text-generation provider for knowledge summarization — the CLI/`dim knowledge sync`
|
|
3
|
+
* "LLM fallback" when no MCP agent is in the loop. Auto-detects the same way the
|
|
4
|
+
* embedding provider does: OpenAI if OPENAI_API_KEY is set, else a local Ollama, else
|
|
5
|
+
* none (in which case docs simply wait in the inbox until a provider/agent appears).
|
|
6
|
+
*
|
|
7
|
+
* AIDIMAG_LLM = auto (default) | openai | ollama | off
|
|
8
|
+
* AIDIMAG_OPENAI_CHAT_MODEL (default gpt-4o-mini)
|
|
9
|
+
* AIDIMAG_OLLAMA_CHAT_MODEL (default llama3.1)
|
|
10
|
+
* AIDIMAG_OLLAMA_URL (default http://localhost:11434)
|
|
11
|
+
*/
|
|
12
|
+
const OLLAMA_URL = process.env.AIDIMAG_OLLAMA_URL ?? "http://localhost:11434";
|
|
13
|
+
const OLLAMA_CHAT_MODEL = process.env.AIDIMAG_OLLAMA_CHAT_MODEL ?? "llama3.1";
|
|
14
|
+
const OPENAI_CHAT_MODEL = process.env.AIDIMAG_OPENAI_CHAT_MODEL ?? "gpt-4o-mini";
|
|
15
|
+
class OpenAiTextProvider {
|
|
16
|
+
name = "openai";
|
|
17
|
+
model = OPENAI_CHAT_MODEL;
|
|
18
|
+
async generate(system, user) {
|
|
19
|
+
const res = await fetch("https://api.openai.com/v1/chat/completions", {
|
|
20
|
+
method: "POST",
|
|
21
|
+
headers: {
|
|
22
|
+
"Content-Type": "application/json",
|
|
23
|
+
Authorization: `Bearer ${process.env.OPENAI_API_KEY}`,
|
|
24
|
+
},
|
|
25
|
+
body: JSON.stringify({
|
|
26
|
+
model: this.model,
|
|
27
|
+
temperature: 0.1,
|
|
28
|
+
response_format: { type: "json_object" },
|
|
29
|
+
messages: [
|
|
30
|
+
{ role: "system", content: system },
|
|
31
|
+
{ role: "user", content: user },
|
|
32
|
+
],
|
|
33
|
+
}),
|
|
34
|
+
});
|
|
35
|
+
if (!res.ok)
|
|
36
|
+
throw new Error(`OpenAI chat: HTTP ${res.status} ${await res.text()}`);
|
|
37
|
+
const body = (await res.json());
|
|
38
|
+
return body.choices[0]?.message?.content ?? "";
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
class OllamaTextProvider {
|
|
42
|
+
name = "ollama";
|
|
43
|
+
model = OLLAMA_CHAT_MODEL;
|
|
44
|
+
async generate(system, user) {
|
|
45
|
+
const res = await fetch(`${OLLAMA_URL}/api/generate`, {
|
|
46
|
+
method: "POST",
|
|
47
|
+
headers: { "Content-Type": "application/json" },
|
|
48
|
+
body: JSON.stringify({
|
|
49
|
+
model: this.model,
|
|
50
|
+
system,
|
|
51
|
+
prompt: user,
|
|
52
|
+
format: "json",
|
|
53
|
+
stream: false,
|
|
54
|
+
options: { temperature: 0.1 },
|
|
55
|
+
}),
|
|
56
|
+
});
|
|
57
|
+
if (!res.ok)
|
|
58
|
+
throw new Error(`Ollama generate: HTTP ${res.status}`);
|
|
59
|
+
const body = (await res.json());
|
|
60
|
+
return body.response ?? "";
|
|
61
|
+
}
|
|
62
|
+
static async detect() {
|
|
63
|
+
try {
|
|
64
|
+
const ctl = new AbortController();
|
|
65
|
+
const t = setTimeout(() => ctl.abort(), 1500);
|
|
66
|
+
const res = await fetch(`${OLLAMA_URL}/api/tags`, { signal: ctl.signal });
|
|
67
|
+
clearTimeout(t);
|
|
68
|
+
if (!res.ok)
|
|
69
|
+
return null;
|
|
70
|
+
return new OllamaTextProvider();
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
let cached;
|
|
78
|
+
/** Check if running inside an MCP client (e.g., Cursor AI with MCP enabled). */
|
|
79
|
+
export function isMcpAvailable() {
|
|
80
|
+
// MCP clients communicate via stdio with the MCP server process
|
|
81
|
+
// When dim is invoked via MCP, stdin/stdout are connected to the client
|
|
82
|
+
return (typeof process.send === "function" || // IPC channel (some MCP clients)
|
|
83
|
+
!process.stdin.isTTY || // stdin is piped (MCP stdio transport)
|
|
84
|
+
process.env.MCP_CLIENT === "true" // explicit env var
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
/** Resolve the text/LLM provider (cached per process). null = none available. */
|
|
88
|
+
export async function getTextProvider() {
|
|
89
|
+
if (cached !== undefined)
|
|
90
|
+
return cached;
|
|
91
|
+
const mode = (process.env.AIDIMAG_LLM ?? "auto").toLowerCase();
|
|
92
|
+
if (mode === "off")
|
|
93
|
+
return (cached = null);
|
|
94
|
+
if (mode === "openai") {
|
|
95
|
+
if (!process.env.OPENAI_API_KEY)
|
|
96
|
+
throw new Error("AIDIMAG_LLM=openai but OPENAI_API_KEY is not set");
|
|
97
|
+
return (cached = new OpenAiTextProvider());
|
|
98
|
+
}
|
|
99
|
+
if (mode === "ollama") {
|
|
100
|
+
const p = await OllamaTextProvider.detect();
|
|
101
|
+
if (!p)
|
|
102
|
+
throw new Error(`AIDIMAG_LLM=ollama but Ollama is not reachable at ${OLLAMA_URL}`);
|
|
103
|
+
return (cached = p);
|
|
104
|
+
}
|
|
105
|
+
// auto: try OpenAI first, then Ollama
|
|
106
|
+
if (process.env.OPENAI_API_KEY)
|
|
107
|
+
return (cached = new OpenAiTextProvider());
|
|
108
|
+
return (cached = await OllamaTextProvider.detect());
|
|
109
|
+
}
|
|
110
|
+
//# sourceMappingURL=llm.js.map
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* aidimag MCP server — exposes repo memory to any MCP-compatible agent
|
|
4
|
+
* (Claude Code, Cursor, Copilot, ...) over stdio.
|
|
5
|
+
*
|
|
6
|
+
* Tools: memory_search, memory_get_for_files, memory_write, memory_refute, memory_status,
|
|
7
|
+
* commits_mine, context_note (passive in-chat fact capture), … — searches are logged so zero-hit
|
|
8
|
+
* queries surface as coverage gaps (`dim gaps`).
|
|
9
|
+
* Resource: aidimag://digest — repo memory digest for session bootstrapping.
|
|
10
|
+
*/
|
|
11
|
+
export {};
|