@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(/&amp;/g, "&");
437
+ text = text.replace(/&lt;/g, "<");
438
+ text = text.replace(/&gt;/g, ">");
439
+ text = text.replace(/&quot;/g, '"');
440
+ text = text.replace(/&#39;/g, "'");
441
+ text = text.replace(/&nbsp;/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.4",
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();