import * as fs from "node:fs"; import * as path from "node:path"; import { getWolfDir, ensureWolfDir, writeJSON, appendMarkdown, readJSON, readBugLogFile, timestamp, timeShort, estimateTokens, readStdin, detectAgent, recordInjectionToSessionFile, getProjectDir, hookMain, getSessionFilePath, gcSessionFiles } from "./shared.js"; import { topRules, scopedRulesForFiles } from "./rule-reinjection.js"; function digestBudget(wolfDir) { const cfg = readJSON(path.join(wolfDir, "config.json"), {}); const ctx = cfg.openwolf?.context ?? {}; const agent = detectAgent(); return ctx.budgets?.[agent] ?? ctx.session_digest_budget_tokens ?? 1500; } /** Extract one `## heading` section (heading line through the next ## or ---). */ function extractSection(markdown, headingPattern) { const lines = markdown.split(/\r?\n/); const start = lines.findIndex((l) => headingPattern.test(l)); if (start === -1) return ""; const out = [lines[start]]; for (let i = start + 1; i < lines.length; i++) { if (/^## /.test(lines[i]) || /^---\s*$/.test(lines[i])) break; out.push(lines[i]); } return out.join("\n").trim(); } /** * True when this project's cerebrum was synced into Claude Code auto-memory * (openwolf update leaves a marker in the memory index). Claude then recalls * Do-Not-Repeat natively, so the digest must not inject it a second time. */ function claudeMemoryHasSync() { if (detectAgent() !== "claude") return false; try { const home = process.env.HOME ?? process.env.USERPROFILE ?? ""; if (!home) return false; const slug = path.resolve(getProjectDir()).replace(/[^a-zA-Z0-9]/g, "-"); const index = fs.readFileSync(path.join(home, ".claude", "projects", slug, "memory", "MEMORY.md"), "utf-8"); return index.includes(""); } catch { return false; } } /** Parse a leading YAML-ish frontmatter block for description/always keys (2.4). */ function readFrontmatter(content) { if (!content.startsWith("---\n")) return { body: content }; const end = content.indexOf("\n---", 4); if (end === -1) return { body: content }; const fm = content.slice(4, end); const body = content.slice(content.indexOf("\n", end + 1) + 1); const description = fm.match(/^description:\s*(.+)$/m)?.[1]?.trim(); const always = /^always:\s*true\s*$/m.test(fm); return { description, always, body }; } /** * Progressive-disclosure digest (2.4). The old digest injected content * (mean 794 tok, 75% of it a staleness nag or placeholder text). The new * shape is an INDEX: one line per live .wolf file with what it is, how big, * and how fresh, so the model can decide what to pull with grep or find — * the retrieval pattern claude-mem, Letta, and the platform's own skills * design converged on. Only two things still inject content: the top * Do-Not-Repeat rules (small, highest value, unless native auto-memory * already carries them) and any .wolf file whose frontmatter says * `always: true` (budget-capped). Target: ~400 tokens. */ function buildSessionDigest(wolfDir, budget) { budget = Math.min(budget, 400 + Math.floor(budget / 4)); const parts = []; let used = 0; const tryAdd = (text) => { if (!text) return false; const cost = estimateTokens(text, "prose"); if (used + cost > budget) return false; parts.push(text); used += cost; return true; }; const isPlaceholder = (l) => /_<[^>]*>_|\{\{[^}]*\}\}|^_?<[^>]*>_?$/.test(l.trim()); // 1. STATUS.md "next phase": the one piece of content that always earns its // tokens on resume — but never template placeholders (8/28 measured digests // were pure placeholder before this filter). try { const status = fs.readFileSync(path.join(wolfDir, "STATUS.md"), "utf-8"); const section = extractSection(readFrontmatter(status).body, /^## 🚀/); const meaningful = section.split("\n").filter((l) => l.trim() && !isPlaceholder(l)); const contentLines = meaningful.filter((l) => !/^#|^\||^-+$/.test(l.trim())); if (contentLines.length > 0) tryAdd(meaningful.slice(0, 14).join("\n")); } catch { } // 2. Top Do-Not-Repeat rules (skipped when native auto-memory carries them). if (!claudeMemoryHasSync()) { try { const cerebrum = readFrontmatter(fs.readFileSync(path.join(wolfDir, "cerebrum.md"), "utf-8")).body; const dnr = extractSection(cerebrum, /^## Do-Not-Repeat/); const entries = dnr.split("\n").filter((l) => l.startsWith("- ") && !isPlaceholder(l)); if (entries.length > 0) { tryAdd("Do-Not-Repeat (top rules from .wolf/cerebrum.md):\n" + entries.slice(-3).join("\n")); } } catch { } } // 3. always:true files — the Letta-style explicit always-loaded set, capped. try { for (const f of fs.readdirSync(wolfDir)) { if (!f.endsWith(".md") || f === "STATUS.md" || f === "OPENWOLF.md" || f === "anatomy.md") continue; const raw = fs.readFileSync(path.join(wolfDir, f), "utf-8"); const fm = readFrontmatter(raw); if (fm.always) { tryAdd(`## .wolf/${f} (always)\n` + fm.body.trim().split("\n").slice(0, 40).join("\n")); } } } catch { } // 4. The index: one line per live state file. Pointers, not content. try { const indexLines = []; const describe = { "cerebrum.md": "learned preferences, conventions, Do-Not-Repeat rules", "memory.md": "chronological action log per session", "STATUS.md": "session handoff (regenerate with /handoff)", "buglog.json": "known bugs with root causes and fixes (openwolf bug search)", "anatomy.md": "project file index (query with openwolf find, never read whole)", }; for (const [file, fallback] of Object.entries(describe)) { const p = path.join(wolfDir, file); let st; try { st = fs.statSync(p); } catch { continue; } let desc = fallback; let extra = ""; if (file === "buglog.json") { const bugs = readBugLogFile(wolfDir).bugs.length; if (bugs === 0) continue; extra = `, ${bugs} bugs`; } else if (file.endsWith(".md")) { const fm = readFrontmatter(fs.readFileSync(p, "utf-8")); if (fm.description) desc = fm.description; // A template-only file earns no index line. if (fm.body.trim().length < 80) continue; } const tok = Math.ceil(st.size / 4); indexLines.push(`- .wolf/${file}: ${desc} (~${tok} tok${extra}, updated ${st.mtime.toISOString().slice(0, 10)})`); } if (indexLines.length > 0) { tryAdd("OpenWolf state on disk (read on demand, not preloaded):\n" + indexLines.join("\n")); } } catch { } return parts.join("\n\n"); } async function main() { ensureWolfDir(); const wolfDir = getWolfDir(); // Clean up stale .tmp files left from failed atomic writes try { const files = fs.readdirSync(wolfDir); for (const f of files) { if (f.endsWith(".tmp")) { try { fs.unlinkSync(path.join(wolfDir, f)); } catch { } } } } catch { } const hooksDir = path.join(wolfDir, "hooks"); const now = new Date(); // SessionStart fires for startup/resume/clear/compact. Only startup and // clear begin a genuinely new session — on resume/compact the session is // still live, and resetting the session state would wipe read/write // tracking mid-flight (Workstream F3: compaction survival). let source = "startup"; let hookInput = {}; try { hookInput = JSON.parse(await readStdin()); if (typeof hookInput.source === "string") source = hookInput.source; } catch { } // Session state is keyed by the harness session id (2.2): concurrent // sessions in one project no longer cross-contaminate each other. const sessionFile = getSessionFilePath(hookInput); const sessionId = hookInput.session_id ?? `session-${now.toISOString().slice(0, 10)}-${String(now.getHours()).padStart(2, "0")}${String(now.getMinutes()).padStart(2, "0")}`; gcSessionFiles(); // Hook install self-test (2.2): a missing dependency file once silently // killed a hook for 3 weeks across 440 invocations. Verify every installed // hook's relative imports resolve; surface breakage in the digest. const brokenHooks = selfTestHooks(hooksDir); const continuing = (source === "compact" || source === "resume") && fs.existsSync(sessionFile); // Compaction evicts verbatim file contents from the model's context, so a // post-compact re-read is legitimate: clear deny-eligibility on every // recorded read (the warn path still fires; only denial is disarmed). if (continuing && source === "compact") { try { const session = readJSON(sessionFile, {}); if (session.files_read) { for (const entry of Object.values(session.files_read)) { entry.compacted = true; } writeJSON(sessionFile, session); } } catch { } } if (!continuing) { writeJSON(sessionFile, { session_id: sessionId, started: timestamp(), files_read: {}, files_written: [], edit_counts: {}, anatomy_hits: 0, anatomy_misses: 0, repeated_reads_warned: 0, stop_count: 0, }); // Append session header to memory.md const memoryPath = path.join(wolfDir, "memory.md"); const header = `\n## Session: ${now.toISOString().slice(0, 10)} ${timeShort()}\n\n| Time | Action | File(s) | Outcome | ~Tokens |\n|------|--------|---------|---------|--------|\n`; appendMarkdown(memoryPath, header); } // Startup nudges — folded into the injected digest below (stderr from an // exit-0 hook never reaches the model). const startupNotes = []; try { const cerebrumPath = path.join(wolfDir, "cerebrum.md"); const cerebrumContent = fs.readFileSync(cerebrumPath, "utf-8"); const stat = fs.statSync(cerebrumPath); const daysSinceUpdate = (Date.now() - stat.mtimeMs) / (1000 * 60 * 60 * 24); // Count actual entries (non-comment, non-empty lines in content sections) const entryLines = cerebrumContent.split("\n").filter(l => { const t = l.trim(); return t.startsWith("- ") || t.startsWith("* ") || (t.startsWith("[") && t.includes("]")); }); if (entryLines.length < 3) { startupNotes.push(`cerebrum.md has only ${entryLines.length} entries. Record user preferences, project conventions, and mistakes to .wolf/cerebrum.md as you learn them this session.`); } else if (daysSinceUpdate > 3) { startupNotes.push(`cerebrum.md hasn't been updated in ${Math.floor(daysSinceUpdate)} days. Look for opportunities to add learnings this session.`); } } catch { } try { if (readBugLogFile(wolfDir).bugs.length === 0) { startupNotes.push(`buglog.json is empty. If you encounter or fix any bugs, errors, or failed tests this session, log them to .wolf/buglog.json.`); } } catch { } // Increment total_sessions in token-ledger — only for a genuinely new // session. Resume/compact re-fires SessionStart for a session already // counted, which used to inflate the lifetime count. if (!continuing) { const ledgerPath = path.join(wolfDir, "token-ledger.json"); const ledger = readJSON(ledgerPath, { version: 1, lifetime: { total_sessions: 0 } }); ledger.lifetime.total_sessions++; writeJSON(ledgerPath, ledger); } // Inject the budget-capped digest into the model's context. try { let digest = buildSessionDigest(wolfDir, digestBudget(wolfDir)); // (The anatomy-staleness banner is gone in 2.2: it led 21 of 28 measured // digests because a 6h rescan interval loses to normal commit cadence — // a warning firing 75% of the time is a tax, not a signal. Freshness is // the daemon's and scan's job, not the model's.) // Hook breakage is the one warning worth its tokens: without it, a broken // install silently loses every feature (happened for 3 weeks once). if (brokenHooks.length > 0) { digest = `OpenWolf hooks are degraded: ${brokenHooks.join("; ")}. Run \`openwolf update\` in this project to repair the install.\n\n` + digest; } // Post-compaction restore (F3 + 2.4): resurface what compaction erased. // The platform documents that paths-scoped rules and nested CLAUDE.md are // LOST at compaction until a matching file is read again — restore the // scoped rules for files this session already touched, plus the top // Do-Not-Repeat rules (decay countermeasure) and the in-flight state. if (continuing && source === "compact") { const session = readJSON(sessionFile, {}); const restoreParts = []; const files = [...new Set((session.files_written ?? []).map((w) => w.file))]; if (files.length > 0) { restoreParts.push(`## Session in progress (context was just compacted)\nFiles already modified this session: ${files.slice(-15).join(", ")}. Do not re-read them wholesale — check .wolf/memory.md for what was done.`); } try { const rules = topRules(fs.readFileSync(path.join(wolfDir, "cerebrum.md"), "utf-8"), 3); if (rules.length > 0) { restoreParts.push(`Project rules still in effect (from .wolf/cerebrum.md Do-Not-Repeat):\n${rules.join("\n")}`); } } catch { } try { const projectDir = getProjectDir(); const relFiles = [ ...Object.keys(session.files_read ?? {}), ...files, ].map((f) => path.isAbsolute(f) ? path.relative(projectDir, f).split(path.sep).join("/") : f); restoreParts.push(...scopedRulesForFiles(projectDir, relFiles)); } catch { } if (restoreParts.length > 0) { digest = restoreParts.join("\n\n") + (digest ? "\n\n" + digest : ""); } } if (startupNotes.length > 0) { digest = digest ? `${digest}\n\n${startupNotes.map((n) => `- ${n}`).join("\n")}` : startupNotes.map((n) => `- ${n}`).join("\n"); } if (digest) { recordInjectionToSessionFile(sessionFile, "digest", digest); process.stdout.write(JSON.stringify({ hookSpecificOutput: { hookEventName: "SessionStart", additionalContext: digest }, })); } } catch { } } /** * Verify every installed hook script's relative imports resolve (2.2). The * 440-crash class was a missing DEPENDENCY file, not a missing hook, so a * bare existence check of registered hooks would have missed it. Cheap static * check: scan each installed hook script for relative import specifiers and * verify the target files exist. Returns problem strings (empty = healthy). */ function selfTestHooks(hooksDir) { const problems = []; try { const files = fs.readdirSync(hooksDir).filter((f) => f.endsWith(".js")); for (const f of files) { let src = ""; try { src = fs.readFileSync(path.join(hooksDir, f), "utf-8"); } catch { problems.push(`${f} unreadable`); continue; } for (const m of src.matchAll(/from\s+["']\.\/([\w.-]+\.js)["']/g)) { if (!fs.existsSync(path.join(hooksDir, m[1]))) { problems.push(`${f} cannot load ${m[1]}`); } } } } catch { } return [...new Set(problems)].slice(0, 3); } hookMain("session-start", main); //# sourceMappingURL=session-start.js.map