@stackmemoryai/stackmemory 1.10.3 → 1.10.4

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,13 @@ 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
85
92
  }
86
93
  ];
87
94
  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.4",
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,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();