@stackmemoryai/stackmemory 1.10.4 → 1.10.5
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.
|
@@ -173,6 +173,39 @@ function createWikiCommand() {
|
|
|
173
173
|
console.log(chalk.gray(` Articles updated: ${result.updated.length}`));
|
|
174
174
|
console.log(chalk.gray(` Total articles: ${result.totalArticles}`));
|
|
175
175
|
});
|
|
176
|
+
cmd.command("ingest <source>").description("Ingest a URL or local path into the wiki").option("--wiki-dir <path>", "Override wiki directory").option("-n, --max-pages <n>", "Max pages to crawl for URLs", "20").option("--json", "Output as JSON").action(async (source, options) => {
|
|
177
|
+
const compiler = getCompiler(options.wikiDir);
|
|
178
|
+
await compiler.initialize();
|
|
179
|
+
const isUrl = source.startsWith("http://") || source.startsWith("https://");
|
|
180
|
+
let result;
|
|
181
|
+
if (isUrl) {
|
|
182
|
+
console.log(
|
|
183
|
+
chalk.cyan(`Crawling ${source} (max ${options.maxPages} pages)...`)
|
|
184
|
+
);
|
|
185
|
+
result = await compiler.ingestUrl(source, {
|
|
186
|
+
maxPages: parseInt(options.maxPages)
|
|
187
|
+
});
|
|
188
|
+
} else {
|
|
189
|
+
console.log(chalk.cyan(`Ingesting ${source}...`));
|
|
190
|
+
result = await compiler.ingestPath(source);
|
|
191
|
+
}
|
|
192
|
+
if (options.json) {
|
|
193
|
+
console.log(JSON.stringify(result, null, 2));
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
console.log(chalk.green("\nIngested."));
|
|
197
|
+
console.log(chalk.gray(` Articles created: ${result.created.length}`));
|
|
198
|
+
console.log(chalk.gray(` Total articles: ${result.totalArticles}`));
|
|
199
|
+
if (result.created.length > 0) {
|
|
200
|
+
console.log(chalk.gray("\n Created:"));
|
|
201
|
+
result.created.slice(0, 10).forEach((p) => console.log(chalk.gray(` - ${p}`)));
|
|
202
|
+
if (result.created.length > 10) {
|
|
203
|
+
console.log(
|
|
204
|
+
chalk.gray(` ...and ${result.created.length - 10} more`)
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
});
|
|
176
209
|
cmd.command("lint").description("Health check the wiki for issues").option("--wiki-dir <path>", "Override wiki directory").option("--json", "Output as JSON").action(async (options) => {
|
|
177
210
|
const compiler = getCompiler(options.wikiDir);
|
|
178
211
|
await compiler.initialize();
|
|
@@ -234,6 +234,225 @@ class WikiCompiler {
|
|
|
234
234
|
compiledAt: Date.now()
|
|
235
235
|
};
|
|
236
236
|
}
|
|
237
|
+
/**
|
|
238
|
+
* Ingest a URL — fetch page content, convert to markdown, compile into wiki.
|
|
239
|
+
* Supports single pages and basic site crawling (follows internal links up to maxPages).
|
|
240
|
+
*/
|
|
241
|
+
async ingestUrl(url, opts) {
|
|
242
|
+
const maxPages = opts?.maxPages ?? 20;
|
|
243
|
+
const created = [];
|
|
244
|
+
const visited = /* @__PURE__ */ new Set();
|
|
245
|
+
const queue = [url];
|
|
246
|
+
let baseHost;
|
|
247
|
+
try {
|
|
248
|
+
baseHost = new URL(url).hostname;
|
|
249
|
+
} catch {
|
|
250
|
+
return {
|
|
251
|
+
created: [],
|
|
252
|
+
updated: [],
|
|
253
|
+
totalArticles: this.countArticles(),
|
|
254
|
+
compiledAt: Date.now()
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
while (queue.length > 0 && visited.size < maxPages) {
|
|
258
|
+
const pageUrl = queue.shift();
|
|
259
|
+
if (!pageUrl || visited.has(pageUrl)) continue;
|
|
260
|
+
visited.add(pageUrl);
|
|
261
|
+
try {
|
|
262
|
+
const { title, content, links } = await this.fetchPage(pageUrl);
|
|
263
|
+
if (!content || content.length < 50) continue;
|
|
264
|
+
const slug = this.slugify(title || this.urlToSlug(pageUrl));
|
|
265
|
+
const sourcePath = `sources/${slug}.md`;
|
|
266
|
+
const article = [
|
|
267
|
+
"---",
|
|
268
|
+
`title: "${this.escapeYaml(title || pageUrl)}"`,
|
|
269
|
+
`category: source`,
|
|
270
|
+
`url: "${pageUrl}"`,
|
|
271
|
+
`created: ${(/* @__PURE__ */ new Date()).toISOString()}`,
|
|
272
|
+
`updated: ${(/* @__PURE__ */ new Date()).toISOString()}`,
|
|
273
|
+
`tags: [source, web-ingest, ${baseHost}]`,
|
|
274
|
+
"---",
|
|
275
|
+
"",
|
|
276
|
+
`# ${title || pageUrl}`,
|
|
277
|
+
"",
|
|
278
|
+
`> Source: ${pageUrl}`,
|
|
279
|
+
"",
|
|
280
|
+
content.slice(0, 8e3),
|
|
281
|
+
content.length > 8e3 ? "\n\n_...truncated..._" : "",
|
|
282
|
+
""
|
|
283
|
+
].join("\n");
|
|
284
|
+
this.writeArticle(sourcePath, article);
|
|
285
|
+
created.push(sourcePath);
|
|
286
|
+
for (const link of links) {
|
|
287
|
+
try {
|
|
288
|
+
const parsed = new URL(link, pageUrl);
|
|
289
|
+
if (parsed.hostname === baseHost && !visited.has(parsed.href)) {
|
|
290
|
+
queue.push(parsed.href);
|
|
291
|
+
}
|
|
292
|
+
} catch {
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
} catch {
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
if (created.length > 0) {
|
|
299
|
+
this.updateIndex();
|
|
300
|
+
this.appendLog(
|
|
301
|
+
`## [${(/* @__PURE__ */ new Date()).toISOString().slice(0, 10)}] ingest-url | ${baseHost}`,
|
|
302
|
+
`Crawled ${visited.size} pages from ${url} \u2014 ${created.length} articles created`
|
|
303
|
+
);
|
|
304
|
+
}
|
|
305
|
+
return {
|
|
306
|
+
created,
|
|
307
|
+
updated: [],
|
|
308
|
+
totalArticles: this.countArticles(),
|
|
309
|
+
compiledAt: Date.now()
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
/**
|
|
313
|
+
* Ingest a local file or directory into the wiki.
|
|
314
|
+
*/
|
|
315
|
+
async ingestPath(filePath) {
|
|
316
|
+
const created = [];
|
|
317
|
+
const { statSync } = await import("fs");
|
|
318
|
+
const stat = statSync(filePath);
|
|
319
|
+
const processFile = (fp) => {
|
|
320
|
+
if (!existsSync(fp)) return;
|
|
321
|
+
const content = readFileSync(fp, "utf-8");
|
|
322
|
+
const basename = fp.split("/").pop() ?? fp;
|
|
323
|
+
const ext = basename.split(".").pop() ?? "";
|
|
324
|
+
if (![
|
|
325
|
+
"md",
|
|
326
|
+
"txt",
|
|
327
|
+
"json",
|
|
328
|
+
"yaml",
|
|
329
|
+
"yml",
|
|
330
|
+
"toml",
|
|
331
|
+
"ts",
|
|
332
|
+
"js",
|
|
333
|
+
"py"
|
|
334
|
+
].includes(ext))
|
|
335
|
+
return;
|
|
336
|
+
const title = basename.replace(/\.[^.]+$/, "");
|
|
337
|
+
const slug = this.slugify(title);
|
|
338
|
+
const sourcePath = `sources/${slug}.md`;
|
|
339
|
+
const article = [
|
|
340
|
+
"---",
|
|
341
|
+
`title: "${this.escapeYaml(title)}"`,
|
|
342
|
+
`category: source`,
|
|
343
|
+
`source_file: "${fp}"`,
|
|
344
|
+
`created: ${(/* @__PURE__ */ new Date()).toISOString()}`,
|
|
345
|
+
`updated: ${(/* @__PURE__ */ new Date()).toISOString()}`,
|
|
346
|
+
`tags: [source, local-ingest]`,
|
|
347
|
+
"---",
|
|
348
|
+
"",
|
|
349
|
+
`# ${title}`,
|
|
350
|
+
"",
|
|
351
|
+
`> Source: \`${fp}\``,
|
|
352
|
+
"",
|
|
353
|
+
content.slice(0, 8e3),
|
|
354
|
+
content.length > 8e3 ? "\n\n_...truncated..._" : "",
|
|
355
|
+
""
|
|
356
|
+
].join("\n");
|
|
357
|
+
this.writeArticle(sourcePath, article);
|
|
358
|
+
created.push(sourcePath);
|
|
359
|
+
};
|
|
360
|
+
if (stat.isDirectory()) {
|
|
361
|
+
const entries = readdirSync(filePath, { recursive: true });
|
|
362
|
+
for (const entry of entries) {
|
|
363
|
+
processFile(join(filePath, entry));
|
|
364
|
+
}
|
|
365
|
+
} else {
|
|
366
|
+
processFile(filePath);
|
|
367
|
+
}
|
|
368
|
+
if (created.length > 0) {
|
|
369
|
+
this.updateIndex();
|
|
370
|
+
this.appendLog(
|
|
371
|
+
`## [${(/* @__PURE__ */ new Date()).toISOString().slice(0, 10)}] ingest-path | ${filePath}`,
|
|
372
|
+
`${created.length} articles from local path`
|
|
373
|
+
);
|
|
374
|
+
}
|
|
375
|
+
return {
|
|
376
|
+
created,
|
|
377
|
+
updated: [],
|
|
378
|
+
totalArticles: this.countArticles(),
|
|
379
|
+
compiledAt: Date.now()
|
|
380
|
+
};
|
|
381
|
+
}
|
|
382
|
+
/** Fetch a web page and extract title, markdown content, and links */
|
|
383
|
+
async fetchPage(url) {
|
|
384
|
+
const res = await fetch(url, {
|
|
385
|
+
headers: { "User-Agent": "StackMemory-Wiki/1.0" },
|
|
386
|
+
signal: AbortSignal.timeout(1e4)
|
|
387
|
+
});
|
|
388
|
+
if (!res.ok) return { title: "", content: "", links: [] };
|
|
389
|
+
const html = await res.text();
|
|
390
|
+
const titleMatch = html.match(/<title[^>]*>([^<]+)<\/title>/i);
|
|
391
|
+
const title = titleMatch?.[1]?.trim() ?? "";
|
|
392
|
+
const links = [];
|
|
393
|
+
const linkRe = /href="([^"]+)"/g;
|
|
394
|
+
let linkMatch;
|
|
395
|
+
while ((linkMatch = linkRe.exec(html)) !== null) {
|
|
396
|
+
const href = linkMatch[1] ?? "";
|
|
397
|
+
if (href && !href.startsWith("#") && !href.startsWith("javascript:") && !href.startsWith("mailto:")) {
|
|
398
|
+
links.push(href);
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
const content = this.htmlToMarkdown(html);
|
|
402
|
+
return { title, content, links };
|
|
403
|
+
}
|
|
404
|
+
/** Simple HTML to markdown conversion */
|
|
405
|
+
htmlToMarkdown(html) {
|
|
406
|
+
let text = html;
|
|
407
|
+
text = text.replace(
|
|
408
|
+
/<(script|style|nav|footer|header|aside)[^>]*>[\s\S]*?<\/\1>/gi,
|
|
409
|
+
""
|
|
410
|
+
);
|
|
411
|
+
const mainMatch = text.match(
|
|
412
|
+
/<(?:main|article)[^>]*>([\s\S]*?)<\/(?:main|article)>/i
|
|
413
|
+
);
|
|
414
|
+
if (mainMatch) text = mainMatch[1] ?? text;
|
|
415
|
+
text = text.replace(/<h1[^>]*>([\s\S]*?)<\/h1>/gi, "\n# $1\n");
|
|
416
|
+
text = text.replace(/<h2[^>]*>([\s\S]*?)<\/h2>/gi, "\n## $1\n");
|
|
417
|
+
text = text.replace(/<h3[^>]*>([\s\S]*?)<\/h3>/gi, "\n### $1\n");
|
|
418
|
+
text = text.replace(/<h4[^>]*>([\s\S]*?)<\/h4>/gi, "\n#### $1\n");
|
|
419
|
+
text = text.replace(/<p[^>]*>/gi, "\n");
|
|
420
|
+
text = text.replace(/<\/p>/gi, "\n");
|
|
421
|
+
text = text.replace(/<br\s*\/?>/gi, "\n");
|
|
422
|
+
text = text.replace(/<li[^>]*>/gi, "- ");
|
|
423
|
+
text = text.replace(/<\/li>/gi, "\n");
|
|
424
|
+
text = text.replace(
|
|
425
|
+
/<(?:strong|b)[^>]*>([\s\S]*?)<\/(?:strong|b)>/gi,
|
|
426
|
+
"**$1**"
|
|
427
|
+
);
|
|
428
|
+
text = text.replace(/<(?:em|i)[^>]*>([\s\S]*?)<\/(?:em|i)>/gi, "*$1*");
|
|
429
|
+
text = text.replace(/<code[^>]*>([\s\S]*?)<\/code>/gi, "`$1`");
|
|
430
|
+
text = text.replace(/<pre[^>]*>([\s\S]*?)<\/pre>/gi, "\n```\n$1\n```\n");
|
|
431
|
+
text = text.replace(
|
|
432
|
+
/<a[^>]*href="([^"]*)"[^>]*>([\s\S]*?)<\/a>/gi,
|
|
433
|
+
"[$2]($1)"
|
|
434
|
+
);
|
|
435
|
+
text = text.replace(/<[^>]+>/g, "");
|
|
436
|
+
text = text.replace(/&/g, "&");
|
|
437
|
+
text = text.replace(/</g, "<");
|
|
438
|
+
text = text.replace(/>/g, ">");
|
|
439
|
+
text = text.replace(/"/g, '"');
|
|
440
|
+
text = text.replace(/'/g, "'");
|
|
441
|
+
text = text.replace(/ /g, " ");
|
|
442
|
+
text = text.replace(/\n{3,}/g, "\n\n");
|
|
443
|
+
text = text.trim();
|
|
444
|
+
return text;
|
|
445
|
+
}
|
|
446
|
+
/** Convert URL path to a readable slug */
|
|
447
|
+
urlToSlug(url) {
|
|
448
|
+
try {
|
|
449
|
+
const parsed = new URL(url);
|
|
450
|
+
const path = parsed.pathname.replace(/^\/|\/$/g, "");
|
|
451
|
+
return path ? this.slugify(path.replace(/\//g, "-")) : this.slugify(parsed.hostname);
|
|
452
|
+
} catch {
|
|
453
|
+
return this.slugify(url);
|
|
454
|
+
}
|
|
455
|
+
}
|
|
237
456
|
/** Lint the wiki for health issues */
|
|
238
457
|
async lint() {
|
|
239
458
|
const allArticles = this.listAllArticles();
|
|
@@ -89,6 +89,14 @@ const CANONICAL_HOOKS = [
|
|
|
89
89
|
timeout: 10,
|
|
90
90
|
commandPrefix: "node",
|
|
91
91
|
required: false
|
|
92
|
+
},
|
|
93
|
+
{
|
|
94
|
+
scriptName: "doc-ingest.js",
|
|
95
|
+
eventType: "PostToolUse",
|
|
96
|
+
matcher: "WebFetch",
|
|
97
|
+
timeout: 15,
|
|
98
|
+
commandPrefix: "node",
|
|
99
|
+
required: false
|
|
92
100
|
}
|
|
93
101
|
];
|
|
94
102
|
const DEAD_HOOKS = ["sms-response-handler.js"];
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stackmemoryai/stackmemory",
|
|
3
|
-
"version": "1.10.
|
|
3
|
+
"version": "1.10.5",
|
|
4
4
|
"description": "Lossless, project-scoped memory for AI coding tools. Durable context across sessions with 56 MCP tools, FTS5 search, conductor orchestrator, loop/watch monitoring, snapshot capture, pre-flight overlap checks, Claude/Codex/OpenCode wrappers, Linear sync, and automatic hooks.",
|
|
5
5
|
"engines": {
|
|
6
6
|
"node": ">=20.0.0",
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Doc Ingest Hook
|
|
5
|
+
*
|
|
6
|
+
* Fires on PostToolUse when WebFetch is used. If the fetched URL looks like
|
|
7
|
+
* documentation (docs.*, /api/, /reference/, /guide/), auto-ingests it into
|
|
8
|
+
* the project wiki via stackmemory wiki ingest.
|
|
9
|
+
*
|
|
10
|
+
* Lightweight: only runs if .stackmemory/config.yaml has obsidian.vaultPath.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const fs = require('fs');
|
|
14
|
+
const path = require('path');
|
|
15
|
+
const { execSync } = require('child_process');
|
|
16
|
+
|
|
17
|
+
// Doc URL patterns
|
|
18
|
+
const DOC_PATTERNS = [
|
|
19
|
+
/^https?:\/\/docs\./i,
|
|
20
|
+
/^https?:\/\/[^/]+\/docs\//i,
|
|
21
|
+
/^https?:\/\/[^/]+\/api\//i,
|
|
22
|
+
/^https?:\/\/[^/]+\/reference\//i,
|
|
23
|
+
/^https?:\/\/[^/]+\/guide/i,
|
|
24
|
+
/^https?:\/\/[^/]+\/tutorial/i,
|
|
25
|
+
/^https?:\/\/developer\./i,
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
// Rate limit: max 1 ingest per URL per session
|
|
29
|
+
const seen = new Set();
|
|
30
|
+
|
|
31
|
+
function main() {
|
|
32
|
+
try {
|
|
33
|
+
// Only fire on WebFetch tool
|
|
34
|
+
const toolName = process.env.TOOL_NAME || '';
|
|
35
|
+
if (toolName !== 'WebFetch') return;
|
|
36
|
+
|
|
37
|
+
// Check wiki is configured
|
|
38
|
+
const configPath = path.join(process.cwd(), '.stackmemory', 'config.yaml');
|
|
39
|
+
if (!fs.existsSync(configPath)) return;
|
|
40
|
+
const config = fs.readFileSync(configPath, 'utf-8');
|
|
41
|
+
if (!config.includes('vaultPath:')) return;
|
|
42
|
+
|
|
43
|
+
// Extract URL from tool input
|
|
44
|
+
const input = process.env.TOOL_INPUT || '';
|
|
45
|
+
let url;
|
|
46
|
+
try {
|
|
47
|
+
const parsed = JSON.parse(input);
|
|
48
|
+
url = parsed.url;
|
|
49
|
+
} catch {
|
|
50
|
+
// Try regex fallback
|
|
51
|
+
const match = input.match(/https?:\/\/[^\s"']+/);
|
|
52
|
+
url = match ? match[0] : null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (!url) return;
|
|
56
|
+
|
|
57
|
+
// Check if URL matches doc patterns
|
|
58
|
+
const isDoc = DOC_PATTERNS.some((p) => p.test(url));
|
|
59
|
+
if (!isDoc) return;
|
|
60
|
+
|
|
61
|
+
// Dedupe per session
|
|
62
|
+
const host = new URL(url).hostname;
|
|
63
|
+
if (seen.has(host)) return;
|
|
64
|
+
seen.add(host);
|
|
65
|
+
|
|
66
|
+
// Run ingest (fire-and-forget, max 1 page for auto-ingest)
|
|
67
|
+
execSync(`stackmemory wiki ingest "${url}" -n 5`, {
|
|
68
|
+
timeout: 15000,
|
|
69
|
+
stdio: 'ignore',
|
|
70
|
+
});
|
|
71
|
+
} catch {
|
|
72
|
+
// Silent fail
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
main();
|