@stackmemoryai/stackmemory
Version:
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, a
374 lines (332 loc) • 11.1 kB
JavaScript
/**
* Wiki Update Hook
*
* Fires on session stop. Incrementally compiles new context (frames,
* entity states, anchors) into the wiki since the last compile.
*
* Works in any repo with StackMemory initialized + Obsidian configured:
* - .stackmemory/context.db must exist
* - .stackmemory/config.yaml must have obsidian.vaultPath
*
* The hook is lightweight: reads last compile timestamp from wiki/log.md,
* queries only new context, and writes updated .md articles.
*/
const fs = require('fs');
const path = require('path');
const HOME = process.env.HOME || '/tmp';
function main() {
try {
const cwd = process.cwd();
// 1. Check if StackMemory is initialized
const dbPath = path.join(cwd, '.stackmemory', 'context.db');
if (!fs.existsSync(dbPath)) return;
// 2. Check if Obsidian vault is configured
const configPath = path.join(cwd, '.stackmemory', 'config.yaml');
if (!fs.existsSync(configPath)) return;
const configContent = fs.readFileSync(configPath, 'utf-8');
const vaultMatch = configContent.match(
/obsidian:\s*\n\s+vaultPath:\s*["']?([^\n"']+)/
);
if (!vaultMatch) return;
const vaultPath = vaultMatch[1].trim();
if (!fs.existsSync(vaultPath)) return;
const subdirMatch = configContent.match(
/obsidian:\s*\n(?:\s+\w+:.*\n)*\s+subdir:\s*["']?([^\n"']+)/
);
const subdir = subdirMatch ? subdirMatch[1].trim() : 'stackmemory';
const wikiDir = path.join(vaultPath, subdir, 'wiki');
// 3. Ensure wiki directory exists
const dirs = [
wikiDir,
path.join(wikiDir, 'entities'),
path.join(wikiDir, 'concepts'),
path.join(wikiDir, 'sources'),
path.join(wikiDir, 'synthesis'),
];
for (const dir of dirs) {
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
}
// 4. Get last compile time from log.md
const logPath = path.join(wikiDir, 'log.md');
let sinceEpoch = 0;
if (fs.existsSync(logPath)) {
const logContent = fs.readFileSync(logPath, 'utf-8');
const entries = logContent.match(/^## \[(\d{4}-\d{2}-\d{2})\]/gm);
if (entries && entries.length > 0) {
const last = entries[entries.length - 1];
const dateMatch = last.match(/\[(\d{4}-\d{2}-\d{2})\]/);
if (dateMatch) {
sinceEpoch = Math.floor(
new Date(dateMatch[1] + 'T00:00:00Z').getTime() / 1000
);
}
}
}
// 5. Open database and query new context
let Database;
try {
Database = require('better-sqlite3');
} catch {
// better-sqlite3 not available in this context
return;
}
const db = new Database(dbPath, { readonly: true });
const digests = db
.prepare(
`SELECT frame_id, name as frame_name, type as frame_type,
digest_text, created_at, closed_at
FROM frames
WHERE state = 'closed' AND digest_text IS NOT NULL
AND created_at >= ?
ORDER BY created_at DESC
LIMIT 100`
)
.all(sinceEpoch);
const entities = db
.prepare(
`SELECT entity_name, relation, value, context,
source_frame_id, valid_from, superseded_at
FROM entity_states
WHERE valid_from >= ?
ORDER BY valid_from DESC
LIMIT 500`
)
.all(sinceEpoch);
const anchors = db
.prepare(
`SELECT a.anchor_id, a.frame_id, f.name as frame_name,
a.type, a.text, a.priority, a.created_at
FROM anchors a
JOIN frames f ON f.frame_id = a.frame_id
WHERE a.created_at >= ?
ORDER BY a.created_at DESC
LIMIT 500`
)
.all(sinceEpoch);
db.close();
// 6. Skip if nothing new
if (digests.length === 0 && entities.length === 0 && anchors.length === 0) {
return;
}
// 7. Write source articles for new digests
let created = 0;
let updated = 0;
for (const d of digests) {
const slug = slugify(d.frame_name);
const filePath = path.join(wikiDir, 'sources', slug + '.md');
if (fs.existsSync(filePath)) continue;
const content = [
'---',
`title: "${escapeYaml(d.frame_name)}"`,
`category: source`,
`frame_id: "${d.frame_id}"`,
`frame_type: "${d.frame_type}"`,
`created: ${new Date(d.created_at * 1000).toISOString()}`,
`updated: ${new Date().toISOString()}`,
`tags: [source, ${d.frame_type}]`,
'---',
'',
`# ${d.frame_name}`,
'',
`> Frame \`${d.frame_id.slice(0, 8)}\` | Type: ${d.frame_type}`,
'',
'## Summary',
'',
d.digest_text || 'No digest.',
'',
].join('\n');
fs.writeFileSync(filePath, content);
created++;
}
// 8. Write/update entity pages
const entitiesByName = {};
for (const e of entities) {
if (!entitiesByName[e.entity_name]) entitiesByName[e.entity_name] = [];
entitiesByName[e.entity_name].push(e);
}
for (const [name, states] of Object.entries(entitiesByName)) {
const slug = slugify(name);
const filePath = path.join(wikiDir, 'entities', slug + '.md');
if (!fs.existsSync(filePath)) {
const current = states.filter((s) => s.superseded_at === null);
const lines = [
'---',
`title: "${escapeYaml(name)}"`,
`category: entity`,
`created: ${new Date().toISOString()}`,
`updated: ${new Date().toISOString()}`,
`tags: [entity]`,
'---',
'',
`# ${name}`,
'',
'## Current State',
'',
];
for (const s of current) {
lines.push(`- **${s.relation}**: ${s.value}`);
if (s.context) lines.push(` - _Context: ${s.context}_`);
}
lines.push('');
fs.writeFileSync(filePath, lines.join('\n'));
created++;
} else {
// Append new states
let content = fs.readFileSync(filePath, 'utf-8');
let changed = false;
for (const s of states) {
if (content.includes(s.value)) continue;
const entry = `- **${s.relation}**: ${s.value}`;
content = content.trimEnd() + '\n' + entry + '\n';
changed = true;
}
if (changed) {
content = content.replace(
/updated:\s*.+/,
`updated: ${new Date().toISOString()}`
);
fs.writeFileSync(filePath, content);
updated++;
}
}
}
// 9. Write/update concept pages from anchors
const conceptMap = {
DECISION: 'Decisions',
FACT: 'Key Facts',
CONSTRAINT: 'Constraints',
RISK: 'Risks',
TODO: 'Action Items',
INTERFACE_CONTRACT: 'Interface Contracts',
};
const conceptGroups = {};
for (const a of anchors) {
const concept = conceptMap[a.type] || 'Notes';
if (!conceptGroups[concept]) conceptGroups[concept] = [];
conceptGroups[concept].push(a);
}
for (const [concept, items] of Object.entries(conceptGroups)) {
const slug = slugify(concept);
const filePath = path.join(wikiDir, 'concepts', slug + '.md');
if (!fs.existsSync(filePath)) {
const lines = [
'---',
`title: "${escapeYaml(concept)}"`,
`category: concept`,
`created: ${new Date().toISOString()}`,
`updated: ${new Date().toISOString()}`,
`tags: [concept]`,
'---',
'',
`# ${concept}`,
'',
];
for (const a of items) {
const date = new Date(a.created_at * 1000).toISOString().slice(0, 10);
lines.push(
`- ${a.text}`,
` - _${date} — from [[sources/${slugify(a.frame_name)}]]_`
);
}
lines.push('');
fs.writeFileSync(filePath, lines.join('\n'));
created++;
} else {
let content = fs.readFileSync(filePath, 'utf-8');
let changed = false;
for (const a of items) {
if (content.includes(a.text)) continue;
const date = new Date(a.created_at * 1000).toISOString().slice(0, 10);
const entry = `- ${a.text}\n - _${date} — from [[sources/${slugify(a.frame_name)}]]_`;
content = content.trimEnd() + '\n' + entry + '\n';
changed = true;
}
if (changed) {
content = content.replace(
/updated:\s*.+/,
`updated: ${new Date().toISOString()}`
);
fs.writeFileSync(filePath, content);
updated++;
}
}
}
// 10. Update index.md
updateIndex(wikiDir);
// 11. Append to log.md
if (created > 0 || updated > 0) {
const now = new Date().toISOString().slice(0, 10);
const logEntry = `\n## [${now}] auto-update | session stop\n${created} created, ${updated} updated from ${digests.length} digests, ${entities.length} entities, ${anchors.length} anchors\n`;
if (fs.existsSync(logPath)) {
fs.appendFileSync(logPath, logEntry);
} else {
fs.writeFileSync(
logPath,
'# Wiki Log\n\n> Chronological record of wiki operations.\n' +
logEntry
);
}
}
} catch {
// Silent fail — never block the agent
}
}
function updateIndex(wikiDir) {
const categories = ['entities', 'concepts', 'sources', 'synthesis'];
const articles = [];
for (const cat of categories) {
const dir = path.join(wikiDir, cat);
if (!fs.existsSync(dir)) continue;
for (const f of fs.readdirSync(dir)) {
if (!f.endsWith('.md')) continue;
const content = fs.readFileSync(path.join(dir, f), 'utf-8');
const titleMatch = content.match(/^title:\s*"?([^"\n]+)"?\s*$/m);
articles.push({
path: `${cat}/${f}`,
title: titleMatch ? titleMatch[1] : f.replace('.md', ''),
category: cat,
});
}
}
const grouped = {};
for (const a of articles) {
if (!grouped[a.category]) grouped[a.category] = [];
grouped[a.category].push(a);
}
const lines = [
'---',
`updated: ${new Date().toISOString()}`,
`total_articles: ${articles.length}`,
'---',
'',
'# Wiki Index',
'',
`> ${articles.length} articles. Auto-maintained by StackMemory.`,
'',
];
for (const [cat, items] of Object.entries(grouped)) {
lines.push(`## ${cat.charAt(0).toUpperCase() + cat.slice(1)}`, '');
for (const item of items.slice(0, 200)) {
const link = item.path.replace('.md', '');
lines.push(`- [[${link}|${item.title}]]`);
}
lines.push('');
}
fs.writeFileSync(path.join(wikiDir, 'index.md'), lines.join('\n'));
}
function slugify(text) {
return text
.toLowerCase()
.replace(/[^a-z0-9-_\s]/g, '')
.replace(/\s+/g, '-')
.replace(/-+/g, '-')
.slice(0, 60)
.replace(/-$/, '');
}
function escapeYaml(s) {
return s.replace(/"/g, '\\"').replace(/\n/g, ' ');
}
main();