Skip to content

Commit 40c3937

Browse files
author
StackMemory Bot (CLI)
committed
feat(hooks): auto-ingest docs on WebFetch + /ingest-docs command
doc-ingest.js hook fires on PostToolUse(WebFetch) — if URL matches docs patterns (docs.*, /api/, /reference/), auto-ingests into wiki. Rate-limited to 1 ingest per hostname per session, max 5 pages. /ingest-docs slash command for manual use.
1 parent d216350 commit 40c3937

3 files changed

Lines changed: 89 additions & 3 deletions

File tree

src/utils/__tests__/hook-installer.test.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,8 @@ const HOOKS_DIR = path.join(os.homedir(), '.claude', 'hooks');
1818

1919
describe('hook-installer', () => {
2020
describe('CANONICAL_HOOKS', () => {
21-
it('defines all 12 hooks', () => {
22-
expect(CANONICAL_HOOKS).toHaveLength(12);
21+
it('defines all 13 hooks', () => {
22+
expect(CANONICAL_HOOKS).toHaveLength(13);
2323
const names = CANONICAL_HOOKS.map((h) => h.scriptName);
2424
expect(names).toContain('session-rescue.sh');
2525
expect(names).toContain('stop-checkpoint.js');
@@ -33,13 +33,14 @@ describe('hook-installer', () => {
3333
expect(names).toContain('desire-path-trace.js');
3434
expect(names).toContain('daemon-auto-start.js');
3535
expect(names).toContain('wiki-update.js');
36+
expect(names).toContain('doc-ingest.js');
3637
});
3738

3839
it('core hooks are required, optional hooks are not', () => {
3940
const required = CANONICAL_HOOKS.filter((h) => h.required);
4041
const optional = CANONICAL_HOOKS.filter((h) => !h.required);
4142
expect(required).toHaveLength(5);
42-
expect(optional).toHaveLength(7);
43+
expect(optional).toHaveLength(8);
4344
const optionalNames = optional.map((h) => h.scriptName);
4445
expect(optionalNames).toContain('theory-capture.js');
4546
expect(optionalNames).toContain('team-subagent-stop.js');
@@ -48,6 +49,7 @@ describe('hook-installer', () => {
4849
expect(optionalNames).toContain('desire-path-trace.js');
4950
expect(optionalNames).toContain('daemon-auto-start.js');
5051
expect(optionalNames).toContain('wiki-update.js');
52+
expect(optionalNames).toContain('doc-ingest.js');
5153
});
5254

5355
it('js hooks have node commandPrefix', () => {

src/utils/hook-installer.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,14 @@ export const CANONICAL_HOOKS: HookEntry[] = [
112112
commandPrefix: 'node',
113113
required: false,
114114
},
115+
{
116+
scriptName: 'doc-ingest.js',
117+
eventType: 'PostToolUse',
118+
matcher: 'WebFetch',
119+
timeout: 15,
120+
commandPrefix: 'node',
121+
required: false,
122+
},
115123
];
116124

117125
/** Script names that should be removed from settings (dead/deprecated hooks) */
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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();

0 commit comments

Comments
 (0)