@stackmemoryai/stackmemory 1.10.3 → 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.
@@ -82,6 +82,21 @@ const CANONICAL_HOOKS = [
82
82
  timeout: 2,
83
83
  commandPrefix: "node",
84
84
  required: false
85
+ },
86
+ {
87
+ scriptName: "wiki-update.js",
88
+ eventType: "Stop",
89
+ timeout: 10,
90
+ commandPrefix: "node",
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
85
100
  }
86
101
  ];
87
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",
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();
@@ -0,0 +1,373 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Wiki Update Hook
5
+ *
6
+ * Fires on session stop. Incrementally compiles new context (frames,
7
+ * entity states, anchors) into the wiki since the last compile.
8
+ *
9
+ * Works in any repo with StackMemory initialized + Obsidian configured:
10
+ * - .stackmemory/context.db must exist
11
+ * - .stackmemory/config.yaml must have obsidian.vaultPath
12
+ *
13
+ * The hook is lightweight: reads last compile timestamp from wiki/log.md,
14
+ * queries only new context, and writes updated .md articles.
15
+ */
16
+
17
+ const fs = require('fs');
18
+ const path = require('path');
19
+
20
+ const HOME = process.env.HOME || '/tmp';
21
+
22
+ function main() {
23
+ try {
24
+ const cwd = process.cwd();
25
+
26
+ // 1. Check if StackMemory is initialized
27
+ const dbPath = path.join(cwd, '.stackmemory', 'context.db');
28
+ if (!fs.existsSync(dbPath)) return;
29
+
30
+ // 2. Check if Obsidian vault is configured
31
+ const configPath = path.join(cwd, '.stackmemory', 'config.yaml');
32
+ if (!fs.existsSync(configPath)) return;
33
+
34
+ const configContent = fs.readFileSync(configPath, 'utf-8');
35
+ const vaultMatch = configContent.match(
36
+ /obsidian:\s*\n\s+vaultPath:\s*["']?([^\n"']+)/
37
+ );
38
+ if (!vaultMatch) return;
39
+
40
+ const vaultPath = vaultMatch[1].trim();
41
+ if (!fs.existsSync(vaultPath)) return;
42
+
43
+ const subdirMatch = configContent.match(
44
+ /obsidian:\s*\n(?:\s+\w+:.*\n)*\s+subdir:\s*["']?([^\n"']+)/
45
+ );
46
+ const subdir = subdirMatch ? subdirMatch[1].trim() : 'stackmemory';
47
+ const wikiDir = path.join(vaultPath, subdir, 'wiki');
48
+
49
+ // 3. Ensure wiki directory exists
50
+ const dirs = [
51
+ wikiDir,
52
+ path.join(wikiDir, 'entities'),
53
+ path.join(wikiDir, 'concepts'),
54
+ path.join(wikiDir, 'sources'),
55
+ path.join(wikiDir, 'synthesis'),
56
+ ];
57
+ for (const dir of dirs) {
58
+ if (!fs.existsSync(dir)) {
59
+ fs.mkdirSync(dir, { recursive: true });
60
+ }
61
+ }
62
+
63
+ // 4. Get last compile time from log.md
64
+ const logPath = path.join(wikiDir, 'log.md');
65
+ let sinceEpoch = 0;
66
+ if (fs.existsSync(logPath)) {
67
+ const logContent = fs.readFileSync(logPath, 'utf-8');
68
+ const entries = logContent.match(/^## \[(\d{4}-\d{2}-\d{2})\]/gm);
69
+ if (entries && entries.length > 0) {
70
+ const last = entries[entries.length - 1];
71
+ const dateMatch = last.match(/\[(\d{4}-\d{2}-\d{2})\]/);
72
+ if (dateMatch) {
73
+ sinceEpoch = Math.floor(
74
+ new Date(dateMatch[1] + 'T00:00:00Z').getTime() / 1000
75
+ );
76
+ }
77
+ }
78
+ }
79
+
80
+ // 5. Open database and query new context
81
+ let Database;
82
+ try {
83
+ Database = require('better-sqlite3');
84
+ } catch {
85
+ // better-sqlite3 not available in this context
86
+ return;
87
+ }
88
+
89
+ const db = new Database(dbPath, { readonly: true });
90
+
91
+ const digests = db
92
+ .prepare(
93
+ `SELECT frame_id, name as frame_name, type as frame_type,
94
+ digest_text, created_at, closed_at
95
+ FROM frames
96
+ WHERE state = 'closed' AND digest_text IS NOT NULL
97
+ AND created_at >= ?
98
+ ORDER BY created_at DESC
99
+ LIMIT 100`
100
+ )
101
+ .all(sinceEpoch);
102
+
103
+ const entities = db
104
+ .prepare(
105
+ `SELECT entity_name, relation, value, context,
106
+ source_frame_id, valid_from, superseded_at
107
+ FROM entity_states
108
+ WHERE valid_from >= ?
109
+ ORDER BY valid_from DESC
110
+ LIMIT 500`
111
+ )
112
+ .all(sinceEpoch);
113
+
114
+ const anchors = db
115
+ .prepare(
116
+ `SELECT a.anchor_id, a.frame_id, f.name as frame_name,
117
+ a.type, a.text, a.priority, a.created_at
118
+ FROM anchors a
119
+ JOIN frames f ON f.frame_id = a.frame_id
120
+ WHERE a.created_at >= ?
121
+ ORDER BY a.created_at DESC
122
+ LIMIT 500`
123
+ )
124
+ .all(sinceEpoch);
125
+
126
+ db.close();
127
+
128
+ // 6. Skip if nothing new
129
+ if (digests.length === 0 && entities.length === 0 && anchors.length === 0) {
130
+ return;
131
+ }
132
+
133
+ // 7. Write source articles for new digests
134
+ let created = 0;
135
+ let updated = 0;
136
+
137
+ for (const d of digests) {
138
+ const slug = slugify(d.frame_name);
139
+ const filePath = path.join(wikiDir, 'sources', slug + '.md');
140
+ if (fs.existsSync(filePath)) continue;
141
+
142
+ const content = [
143
+ '---',
144
+ `title: "${escapeYaml(d.frame_name)}"`,
145
+ `category: source`,
146
+ `frame_id: "${d.frame_id}"`,
147
+ `frame_type: "${d.frame_type}"`,
148
+ `created: ${new Date(d.created_at * 1000).toISOString()}`,
149
+ `updated: ${new Date().toISOString()}`,
150
+ `tags: [source, ${d.frame_type}]`,
151
+ '---',
152
+ '',
153
+ `# ${d.frame_name}`,
154
+ '',
155
+ `> Frame \`${d.frame_id.slice(0, 8)}\` | Type: ${d.frame_type}`,
156
+ '',
157
+ '## Summary',
158
+ '',
159
+ d.digest_text || 'No digest.',
160
+ '',
161
+ ].join('\n');
162
+
163
+ fs.writeFileSync(filePath, content);
164
+ created++;
165
+ }
166
+
167
+ // 8. Write/update entity pages
168
+ const entitiesByName = {};
169
+ for (const e of entities) {
170
+ if (!entitiesByName[e.entity_name]) entitiesByName[e.entity_name] = [];
171
+ entitiesByName[e.entity_name].push(e);
172
+ }
173
+
174
+ for (const [name, states] of Object.entries(entitiesByName)) {
175
+ const slug = slugify(name);
176
+ const filePath = path.join(wikiDir, 'entities', slug + '.md');
177
+
178
+ if (!fs.existsSync(filePath)) {
179
+ const current = states.filter((s) => s.superseded_at === null);
180
+ const lines = [
181
+ '---',
182
+ `title: "${escapeYaml(name)}"`,
183
+ `category: entity`,
184
+ `created: ${new Date().toISOString()}`,
185
+ `updated: ${new Date().toISOString()}`,
186
+ `tags: [entity]`,
187
+ '---',
188
+ '',
189
+ `# ${name}`,
190
+ '',
191
+ '## Current State',
192
+ '',
193
+ ];
194
+ for (const s of current) {
195
+ lines.push(`- **${s.relation}**: ${s.value}`);
196
+ if (s.context) lines.push(` - _Context: ${s.context}_`);
197
+ }
198
+ lines.push('');
199
+ fs.writeFileSync(filePath, lines.join('\n'));
200
+ created++;
201
+ } else {
202
+ // Append new states
203
+ let content = fs.readFileSync(filePath, 'utf-8');
204
+ let changed = false;
205
+ for (const s of states) {
206
+ if (content.includes(s.value)) continue;
207
+ const entry = `- **${s.relation}**: ${s.value}`;
208
+ content = content.trimEnd() + '\n' + entry + '\n';
209
+ changed = true;
210
+ }
211
+ if (changed) {
212
+ content = content.replace(
213
+ /updated:\s*.+/,
214
+ `updated: ${new Date().toISOString()}`
215
+ );
216
+ fs.writeFileSync(filePath, content);
217
+ updated++;
218
+ }
219
+ }
220
+ }
221
+
222
+ // 9. Write/update concept pages from anchors
223
+ const conceptMap = {
224
+ DECISION: 'Decisions',
225
+ FACT: 'Key Facts',
226
+ CONSTRAINT: 'Constraints',
227
+ RISK: 'Risks',
228
+ TODO: 'Action Items',
229
+ INTERFACE_CONTRACT: 'Interface Contracts',
230
+ };
231
+
232
+ const conceptGroups = {};
233
+ for (const a of anchors) {
234
+ const concept = conceptMap[a.type] || 'Notes';
235
+ if (!conceptGroups[concept]) conceptGroups[concept] = [];
236
+ conceptGroups[concept].push(a);
237
+ }
238
+
239
+ for (const [concept, items] of Object.entries(conceptGroups)) {
240
+ const slug = slugify(concept);
241
+ const filePath = path.join(wikiDir, 'concepts', slug + '.md');
242
+
243
+ if (!fs.existsSync(filePath)) {
244
+ const lines = [
245
+ '---',
246
+ `title: "${escapeYaml(concept)}"`,
247
+ `category: concept`,
248
+ `created: ${new Date().toISOString()}`,
249
+ `updated: ${new Date().toISOString()}`,
250
+ `tags: [concept]`,
251
+ '---',
252
+ '',
253
+ `# ${concept}`,
254
+ '',
255
+ ];
256
+ for (const a of items) {
257
+ const date = new Date(a.created_at * 1000).toISOString().slice(0, 10);
258
+ lines.push(
259
+ `- ${a.text}`,
260
+ ` - _${date} — from [[sources/${slugify(a.frame_name)}]]_`
261
+ );
262
+ }
263
+ lines.push('');
264
+ fs.writeFileSync(filePath, lines.join('\n'));
265
+ created++;
266
+ } else {
267
+ let content = fs.readFileSync(filePath, 'utf-8');
268
+ let changed = false;
269
+ for (const a of items) {
270
+ if (content.includes(a.text)) continue;
271
+ const date = new Date(a.created_at * 1000).toISOString().slice(0, 10);
272
+ const entry = `- ${a.text}\n - _${date} — from [[sources/${slugify(a.frame_name)}]]_`;
273
+ content = content.trimEnd() + '\n' + entry + '\n';
274
+ changed = true;
275
+ }
276
+ if (changed) {
277
+ content = content.replace(
278
+ /updated:\s*.+/,
279
+ `updated: ${new Date().toISOString()}`
280
+ );
281
+ fs.writeFileSync(filePath, content);
282
+ updated++;
283
+ }
284
+ }
285
+ }
286
+
287
+ // 10. Update index.md
288
+ updateIndex(wikiDir);
289
+
290
+ // 11. Append to log.md
291
+ if (created > 0 || updated > 0) {
292
+ const now = new Date().toISOString().slice(0, 10);
293
+ const logEntry = `\n## [${now}] auto-update | session stop\n${created} created, ${updated} updated from ${digests.length} digests, ${entities.length} entities, ${anchors.length} anchors\n`;
294
+
295
+ if (fs.existsSync(logPath)) {
296
+ fs.appendFileSync(logPath, logEntry);
297
+ } else {
298
+ fs.writeFileSync(
299
+ logPath,
300
+ '# Wiki Log\n\n> Chronological record of wiki operations.\n' +
301
+ logEntry
302
+ );
303
+ }
304
+ }
305
+ } catch {
306
+ // Silent fail — never block the agent
307
+ }
308
+ }
309
+
310
+ function updateIndex(wikiDir) {
311
+ const categories = ['entities', 'concepts', 'sources', 'synthesis'];
312
+ const articles = [];
313
+
314
+ for (const cat of categories) {
315
+ const dir = path.join(wikiDir, cat);
316
+ if (!fs.existsSync(dir)) continue;
317
+ for (const f of fs.readdirSync(dir)) {
318
+ if (!f.endsWith('.md')) continue;
319
+ const content = fs.readFileSync(path.join(dir, f), 'utf-8');
320
+ const titleMatch = content.match(/^title:\s*"?([^"\n]+)"?\s*$/m);
321
+ articles.push({
322
+ path: `${cat}/${f}`,
323
+ title: titleMatch ? titleMatch[1] : f.replace('.md', ''),
324
+ category: cat,
325
+ });
326
+ }
327
+ }
328
+
329
+ const grouped = {};
330
+ for (const a of articles) {
331
+ if (!grouped[a.category]) grouped[a.category] = [];
332
+ grouped[a.category].push(a);
333
+ }
334
+
335
+ const lines = [
336
+ '---',
337
+ `updated: ${new Date().toISOString()}`,
338
+ `total_articles: ${articles.length}`,
339
+ '---',
340
+ '',
341
+ '# Wiki Index',
342
+ '',
343
+ `> ${articles.length} articles. Auto-maintained by StackMemory.`,
344
+ '',
345
+ ];
346
+
347
+ for (const [cat, items] of Object.entries(grouped)) {
348
+ lines.push(`## ${cat.charAt(0).toUpperCase() + cat.slice(1)}`, '');
349
+ for (const item of items.slice(0, 200)) {
350
+ const link = item.path.replace('.md', '');
351
+ lines.push(`- [[${link}|${item.title}]]`);
352
+ }
353
+ lines.push('');
354
+ }
355
+
356
+ fs.writeFileSync(path.join(wikiDir, 'index.md'), lines.join('\n'));
357
+ }
358
+
359
+ function slugify(text) {
360
+ return text
361
+ .toLowerCase()
362
+ .replace(/[^a-z0-9-_\s]/g, '')
363
+ .replace(/\s+/g, '-')
364
+ .replace(/-+/g, '-')
365
+ .slice(0, 60)
366
+ .replace(/-$/, '');
367
+ }
368
+
369
+ function escapeYaml(s) {
370
+ return s.replace(/"/g, '\\"').replace(/\n/g, ' ');
371
+ }
372
+
373
+ main();