openwolf-enhanced 1.28.0 → 1.29.0
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.
- package/CHANGELOG.md +162 -0
- package/dist/dashboard/assets/{AISuggestions-C7WFvzLO.js → AISuggestions-CKD-5h-c.js} +1 -1
- package/dist/dashboard/assets/{ActivityLog-SsRbpBFt.js → ActivityLog-BhURSYyt.js} +1 -1
- package/dist/dashboard/assets/{ActivityTimeline-BoV30nDY.js → ActivityTimeline-CJBgc2Xw.js} +1 -1
- package/dist/dashboard/assets/{AnatomyBrowser-DwzsHn4z.js → AnatomyBrowser-CH3-GM_8.js} +1 -1
- package/dist/dashboard/assets/{BugLog-CPl4Q-JG.js → BugLog-DliFykQ1.js} +1 -1
- package/dist/dashboard/assets/{CerebrumViewer-_f6fBsNF.js → CerebrumViewer-DOn8HiUY.js} +1 -1
- package/dist/dashboard/assets/{CronStatus-1iBEPJK5.js → CronStatus-D4kJmhyD.js} +1 -1
- package/dist/dashboard/assets/{DesignQC-D4UU7lca.js → DesignQC-5Cw-aAH0.js} +1 -1
- package/dist/dashboard/assets/{LinkGraph-BWtI8Gh3.js → LinkGraph-CsIS8mCj.js} +1 -1
- package/dist/dashboard/assets/{MemoryViewer-BYRVG34g.js → MemoryViewer-CEFFPOoL.js} +1 -1
- package/dist/dashboard/assets/{NativeMemory-Dphtrqjt.js → NativeMemory-DuelxupG.js} +1 -1
- package/dist/dashboard/assets/{ProjectOverview-CTYohvjD.js → ProjectOverview-Dcc-Z6UO.js} +1 -1
- package/dist/dashboard/assets/{ProjectsAggregate-CMxNs35H.js → ProjectsAggregate-LvFcMjZk.js} +1 -1
- package/dist/dashboard/assets/{StatusBadge-CjrFCDOI.js → StatusBadge-CphWVA52.js} +1 -1
- package/dist/dashboard/assets/{TokenUsage-C5wJpMl8.js → TokenUsage-BMaSQoYU.js} +1 -1
- package/dist/dashboard/assets/{index-C74qneS9.js → index-D5e2y39w.js} +3 -3
- package/dist/dashboard/index.html +1 -1
- package/dist/hooks/post-write.js +7 -3
- package/dist/hooks/pre-write.js +13 -4
- package/dist/hooks/session-start.js +8 -1
- package/dist/hooks/shared.js +242 -10
- package/dist/hooks/stop.js +60 -13
- package/dist/src/cli/doctor-cmd.js +4 -2
- package/dist/src/cli/doctor-cmd.js.map +1 -1
- package/dist/src/cli/init.js +1 -1
- package/dist/src/cli/init.js.map +1 -1
- package/dist/src/cli/link-cmd.js +3 -1
- package/dist/src/cli/link-cmd.js.map +1 -1
- package/dist/src/daemon/file-watcher.js +8 -1
- package/dist/src/daemon/file-watcher.js.map +1 -1
- package/dist/src/daemon/llm-provider.js +70 -1
- package/dist/src/daemon/llm-provider.js.map +1 -1
- package/dist/src/daemon/wolf-daemon.js +47 -4
- package/dist/src/daemon/wolf-daemon.js.map +1 -1
- package/dist/src/hooks/post-write.js +7 -3
- package/dist/src/hooks/post-write.js.map +1 -1
- package/dist/src/hooks/pre-write.js +13 -4
- package/dist/src/hooks/pre-write.js.map +1 -1
- package/dist/src/hooks/session-start.js +8 -1
- package/dist/src/hooks/session-start.js.map +1 -1
- package/dist/src/hooks/shared.js +242 -10
- package/dist/src/hooks/shared.js.map +1 -1
- package/dist/src/hooks/stop.js +60 -13
- package/dist/src/hooks/stop.js.map +1 -1
- package/dist/src/mcp/server.js +2 -1
- package/dist/src/mcp/server.js.map +1 -1
- package/dist/src/scanner/anatomy-scanner.js +7 -3
- package/dist/src/scanner/anatomy-scanner.js.map +1 -1
- package/dist/src/utils/embeddings.js +6 -1
- package/dist/src/utils/embeddings.js.map +1 -1
- package/dist/src/utils/fs-safe.js +86 -4
- package/dist/src/utils/fs-safe.js.map +1 -1
- package/dist/src/utils/maintenance.js +39 -2
- package/dist/src/utils/maintenance.js.map +1 -1
- package/dist/src/utils/remote.js +56 -5
- package/dist/src/utils/remote.js.map +1 -1
- package/package.json +7 -2
- package/src/templates/OPENWOLF.md +6 -3
- package/src/templates/claude-rules-openwolf.md +6 -17
- package/src/templates/config.json +0 -1
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
|
8
8
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
|
9
9
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet" />
|
|
10
|
-
<script type="module" crossorigin src="./assets/index-
|
|
10
|
+
<script type="module" crossorigin src="./assets/index-D5e2y39w.js"></script>
|
|
11
11
|
<link rel="stylesheet" crossorigin href="./assets/index-BNi8lsJE.css">
|
|
12
12
|
</head>
|
|
13
13
|
<body>
|
package/dist/hooks/post-write.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import * as fs from "node:fs";
|
|
2
2
|
import * as path from "node:path";
|
|
3
|
-
import { getWolfDir, ensureWolfDir, writeJSON, writeAtomic, parseAnatomy, serializeAnatomy, extractDescription, estimateFileTokens, getTokenRatios, appendMarkdown, timeShort, readStdin, normalizePath, getRetention, loadIgnore, readBugLog, isSecretFile, updateSession, isOutsideProject, tryWithLock, sessionFileFor } from "./shared.js";
|
|
3
|
+
import { getWolfDir, ensureWolfDir, writeJSON, writeAtomic, parseAnatomy, serializeAnatomy, extractDescription, estimateFileTokens, getTokenRatios, appendMarkdown, timeShort, readStdin, normalizePath, getRetention, loadIgnore, readBugLog, isSecretFile, updateSession, isOutsideProject, tryWithLock, sessionFileFor, capBuglogWithArchive } from "./shared.js";
|
|
4
4
|
async function main() {
|
|
5
5
|
ensureWolfDir();
|
|
6
6
|
const wolfDir = getWolfDir();
|
|
@@ -320,9 +320,13 @@ function autoDetectBugFix(wolfDir, absolutePath, projectRoot, oldStr, newStr) {
|
|
|
320
320
|
last_seen: new Date().toISOString(),
|
|
321
321
|
});
|
|
322
322
|
// Keep buglog.json bounded — auto-detection can otherwise append on nearly every edit.
|
|
323
|
+
// Overflow is MOVED to buglog-archive.json, never dropped: the old slice(-max) deleted the
|
|
324
|
+
// oldest curated entries, which are the ones other files cite by id.
|
|
323
325
|
const maxBugs = getRetention(wolfDir).buglog_max_entries;
|
|
324
|
-
|
|
325
|
-
|
|
326
|
+
const capped = capBuglogWithArchive(wolfDir, bugLog.bugs, maxBugs);
|
|
327
|
+
if (capped.archived.length) {
|
|
328
|
+
bugLog.bugs = capped.kept;
|
|
329
|
+
process.stderr.write(`[openwolf] buglog over ${maxBugs} entries — moved ${capped.archived.length} to buglog-archive.json\n`);
|
|
326
330
|
}
|
|
327
331
|
writeJSON(bugLogPath, bugLog);
|
|
328
332
|
});
|
package/dist/hooks/pre-write.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import * as fs from "node:fs";
|
|
2
2
|
import * as path from "node:path";
|
|
3
|
-
import { getWolfDir, ensureWolfDir, readJSON, writeJSON, readMarkdown, readStdin, readBugLog, tryWithLock, sessionFileFor } from "./shared.js";
|
|
3
|
+
import { getWolfDir, ensureWolfDir, readJSON, writeJSON, readMarkdown, readStdin, readBugLog, tryWithLock, sessionFileFor, extractMarkdownSection } from "./shared.js";
|
|
4
4
|
async function main() {
|
|
5
5
|
ensureWolfDir();
|
|
6
6
|
const wolfDir = getWolfDir();
|
|
@@ -46,18 +46,23 @@ async function main() {
|
|
|
46
46
|
}
|
|
47
47
|
process.exit(0);
|
|
48
48
|
}
|
|
49
|
+
// One write should never produce a wall of warnings; the first few are the useful ones.
|
|
50
|
+
const MAX_CEREBRUM_WARNINGS = 3;
|
|
49
51
|
function checkCerebrum(wolfDir, content) {
|
|
50
52
|
const cerebrumContent = readMarkdown(path.join(wolfDir, "cerebrum.md"));
|
|
51
|
-
|
|
52
|
-
|
|
53
|
+
// Anchored heading lookup, not a substring: an entry that quotes "## Do-Not-Repeat" inside its
|
|
54
|
+
// own prose used to win the split and reduce this check to a handful of unrelated bullets.
|
|
55
|
+
const entries = extractMarkdownSection(cerebrumContent, /^#{2,3}\s*Do[-\s]?Not[-\s]?Repeat/i);
|
|
56
|
+
if (!entries)
|
|
53
57
|
return 0;
|
|
54
|
-
const entries = doNotRepeatSection.split("## ")[0];
|
|
55
58
|
const lines = entries.split("\n").filter((l) => l.trim().startsWith("[") || l.trim().startsWith("-"));
|
|
56
59
|
let fired = 0;
|
|
57
60
|
for (const line of lines) {
|
|
58
61
|
const trimmed = line.trim().replace(/^[-*]\s*/, "").replace(/^\[[\d-]+\]\s*/, "");
|
|
59
62
|
if (!trimmed)
|
|
60
63
|
continue;
|
|
64
|
+
if (fired >= MAX_CEREBRUM_WARNINGS)
|
|
65
|
+
break;
|
|
61
66
|
const patterns = [];
|
|
62
67
|
const quotedMatches = trimmed.match(/"([^"]+)"/g) || trimmed.match(/'([^']+)'/g) || trimmed.match(/`([^`]+)`/g);
|
|
63
68
|
if (quotedMatches) {
|
|
@@ -69,6 +74,10 @@ function checkCerebrum(wolfDir, content) {
|
|
|
69
74
|
if (neverMatch)
|
|
70
75
|
patterns.push(neverMatch[1]);
|
|
71
76
|
for (const pattern of patterns) {
|
|
77
|
+
// Anything shorter than four characters ("id", "os", "npm") matches almost any file and
|
|
78
|
+
// would turn a correctly-scoped section into a warning machine.
|
|
79
|
+
if (pattern.trim().length < 4)
|
|
80
|
+
continue;
|
|
72
81
|
try {
|
|
73
82
|
const regex = new RegExp(`\\b${pattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`, "i");
|
|
74
83
|
if (regex.test(content)) {
|
|
@@ -97,7 +97,14 @@ async function main() {
|
|
|
97
97
|
// starts must not clobber the read-modify-write.
|
|
98
98
|
const ledgerPath = path.join(wolfDir, "token-ledger.json");
|
|
99
99
|
tryWithLock(ledgerPath, () => {
|
|
100
|
-
|
|
100
|
+
// The full shape, including `sessions: []`. Writing the stub without it used to poison the
|
|
101
|
+
// file: the stop hook reads it (it exists now, so no default applies) and died in
|
|
102
|
+
// `ledger.sessions.findIndex` on every turn of every session until someone fixed it by hand.
|
|
103
|
+
const ledger = readJSON(ledgerPath, { version: 1, sessions: [], lifetime: { total_sessions: 0 } });
|
|
104
|
+
if (!Array.isArray(ledger.sessions))
|
|
105
|
+
ledger.sessions = [];
|
|
106
|
+
if (!ledger.lifetime || typeof ledger.lifetime !== "object")
|
|
107
|
+
ledger.lifetime = { total_sessions: 0 };
|
|
101
108
|
ledger.lifetime.total_sessions++;
|
|
102
109
|
writeJSON(ledgerPath, ledger);
|
|
103
110
|
});
|
package/dist/hooks/shared.js
CHANGED
|
@@ -99,19 +99,51 @@ export class LockTimeoutError extends Error {
|
|
|
99
99
|
this.name = "LockTimeoutError";
|
|
100
100
|
}
|
|
101
101
|
}
|
|
102
|
+
/**
|
|
103
|
+
* The lock could not be taken for a reason that waiting will not fix — a missing directory we
|
|
104
|
+
* could not create, a read-only filesystem, no permission. Extends LockTimeoutError on purpose:
|
|
105
|
+
* every caller already treats that as "skip the update and say so", which is exactly right here
|
|
106
|
+
* too, and a hook must never die on it (see tryWithLock).
|
|
107
|
+
*/
|
|
108
|
+
export class LockUnavailableError extends LockTimeoutError {
|
|
109
|
+
code;
|
|
110
|
+
constructor(targetPath, code) {
|
|
111
|
+
super(targetPath, 0);
|
|
112
|
+
this.code = code;
|
|
113
|
+
this.message = `could not acquire the lock on ${path.basename(targetPath)} (${code}) — update skipped`;
|
|
114
|
+
this.name = "LockUnavailableError";
|
|
115
|
+
}
|
|
116
|
+
}
|
|
102
117
|
function sleepSync(ms) {
|
|
103
118
|
try {
|
|
104
119
|
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
105
120
|
}
|
|
106
121
|
catch { /* no-op */ }
|
|
107
122
|
}
|
|
123
|
+
/**
|
|
124
|
+
* Is the lock file old enough to be considered abandoned?
|
|
125
|
+
*
|
|
126
|
+
* Age only. A pid liveness check was tried here and REMOVED on purpose: pids are recycled, so a
|
|
127
|
+
* lock left behind by a dead process whose number now belongs to an unrelated one would never be
|
|
128
|
+
* reclaimable again — a permanent lockout in exchange for a clock-skew problem nobody measured.
|
|
129
|
+
* Losing a lock occasionally beats never getting it back. The real defect was that reclaiming was
|
|
130
|
+
* not atomic; that is fixed at the call site.
|
|
131
|
+
*/
|
|
132
|
+
function isStaleLock(lockPath, staleMs) {
|
|
133
|
+
return Date.now() - fs.statSync(lockPath).mtimeMs > staleMs;
|
|
134
|
+
}
|
|
108
135
|
export function withLock(targetPath, fn) {
|
|
109
136
|
const lockPath = targetPath + ".lock";
|
|
110
137
|
const MAX_WAIT_MS = 1500; // well inside the tightest hook budget (5s)
|
|
111
138
|
const STALE_MS = 5000;
|
|
139
|
+
// A lock that vanishes between our open and our stat is worth retrying at once — but only a few
|
|
140
|
+
// times. Without a bound, any error we did not anticipate turns the retry into a busy-loop that
|
|
141
|
+
// saturates a core for the whole budget and then fails anyway (bug-352).
|
|
142
|
+
const MAX_IMMEDIATE_RETRIES = 8;
|
|
112
143
|
const start = Date.now();
|
|
113
144
|
let held = false;
|
|
114
145
|
let backoff = 2;
|
|
146
|
+
let immediateRetries = 0;
|
|
115
147
|
while (Date.now() - start < MAX_WAIT_MS) {
|
|
116
148
|
try {
|
|
117
149
|
const fd = fs.openSync(lockPath, "wx");
|
|
@@ -123,16 +155,50 @@ export function withLock(targetPath, fn) {
|
|
|
123
155
|
held = true;
|
|
124
156
|
break;
|
|
125
157
|
}
|
|
126
|
-
catch {
|
|
158
|
+
catch (openErr) {
|
|
159
|
+
const code = openErr?.code;
|
|
160
|
+
// EEXIST is the only failure that means "somebody else holds it" — the one case where
|
|
161
|
+
// waiting can help. Everything else is a property of the filesystem, not of a competitor,
|
|
162
|
+
// and will still be true in 1500ms, so spinning on it burns CPU to reach a certain failure.
|
|
163
|
+
if (code !== "EEXIST") {
|
|
164
|
+
// The one recoverable non-EEXIST case: the directory does not exist yet. The caller is
|
|
165
|
+
// about to write into it anyway, so create it and try once more.
|
|
166
|
+
if (code === "ENOENT" && immediateRetries < MAX_IMMEDIATE_RETRIES) {
|
|
167
|
+
immediateRetries++;
|
|
168
|
+
try {
|
|
169
|
+
fs.mkdirSync(path.dirname(lockPath), { recursive: true });
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
catch { /* fall through */ }
|
|
173
|
+
}
|
|
174
|
+
throw new LockUnavailableError(targetPath, code ?? "UNKNOWN");
|
|
175
|
+
}
|
|
127
176
|
try {
|
|
128
|
-
if (
|
|
129
|
-
|
|
177
|
+
if (isStaleLock(lockPath, STALE_MS)) {
|
|
178
|
+
// Claim the stale lock ATOMICALLY. `unlink` + create is not: B stats the old lock, A
|
|
179
|
+
// removes it and creates a fresh one, B then removes A's FRESH lock and creates its own
|
|
180
|
+
// — and both run the critical section. Measured with 20 processes and one stale lock
|
|
181
|
+
// left behind: 12 lost updates in 8 rounds, and 0 without it. Only the process whose
|
|
182
|
+
// rename succeeds may remove the file; everyone else falls through and waits.
|
|
183
|
+
const claim = `${lockPath}.stale-${process.pid}-${Date.now()}`;
|
|
184
|
+
try {
|
|
185
|
+
fs.renameSync(lockPath, claim);
|
|
186
|
+
fs.unlinkSync(claim);
|
|
187
|
+
}
|
|
188
|
+
catch { /* somebody claimed it first */ }
|
|
130
189
|
continue;
|
|
131
190
|
}
|
|
132
191
|
}
|
|
133
192
|
catch {
|
|
193
|
+
// Lock vanished between open and stat — retry immediately, but never unboundedly.
|
|
194
|
+
if (immediateRetries < MAX_IMMEDIATE_RETRIES) {
|
|
195
|
+
immediateRetries++;
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
sleepSync(backoff);
|
|
199
|
+
backoff = Math.min(backoff * 2, 50);
|
|
134
200
|
continue;
|
|
135
|
-
}
|
|
201
|
+
}
|
|
136
202
|
sleepSync(backoff);
|
|
137
203
|
backoff = Math.min(backoff * 2, 50);
|
|
138
204
|
}
|
|
@@ -559,7 +625,14 @@ export function compactMemoryIfLarge(wolfDir, maxBytes) {
|
|
|
559
625
|
flush();
|
|
560
626
|
if (!changed)
|
|
561
627
|
return;
|
|
562
|
-
|
|
628
|
+
// Same lock as appendMarkdown: without it an append landing between the read above and this
|
|
629
|
+
// rename is written to the old inode and vanishes.
|
|
630
|
+
try {
|
|
631
|
+
withLock(p, () => writeAtomic(p, () => out.join("\n")));
|
|
632
|
+
}
|
|
633
|
+
catch {
|
|
634
|
+
writeAtomic(p, () => out.join("\n"));
|
|
635
|
+
}
|
|
563
636
|
}
|
|
564
637
|
export function readBugLog(wolfDir) {
|
|
565
638
|
const raw = readJSON(path.join(wolfDir, "buglog.json"), { version: 1, bugs: [] });
|
|
@@ -568,6 +641,50 @@ export function readBugLog(wolfDir) {
|
|
|
568
641
|
const o = (raw ?? {});
|
|
569
642
|
return { version: o.version ?? 1, bugs: Array.isArray(o.bugs) ? o.bugs : [] };
|
|
570
643
|
}
|
|
644
|
+
/**
|
|
645
|
+
* Cap buglog.json WITHOUT losing anything.
|
|
646
|
+
*
|
|
647
|
+
* The old `bugs.slice(-max)` cut from the front, so it deleted the OLDEST hand-written entries —
|
|
648
|
+
* exactly the ones other knowledge files cite by id ("see bug-084"). On a real project that had
|
|
649
|
+
* grown to 295 curated entries against a cap of 200, a single auto-detected fix would have thrown
|
|
650
|
+
* away 95 of them with no message. A cross-referenced logbook must never lose an entry; it may
|
|
651
|
+
* only move it somewhere the citation can still be resolved.
|
|
652
|
+
*
|
|
653
|
+
* Overflow now goes to `.wolf/buglog-archive.json`. Auto-detected noise is spent first (it is
|
|
654
|
+
* regenerable), curated entries only once that is exhausted — and even those are archived, not
|
|
655
|
+
* dropped. The archive is append-only and deduplicated by id; it is meant to grow.
|
|
656
|
+
*/
|
|
657
|
+
export function capBuglogWithArchive(wolfDir, bugs, max) {
|
|
658
|
+
if (!Array.isArray(bugs) || bugs.length <= max || max < 0)
|
|
659
|
+
return { kept: bugs, archived: [] };
|
|
660
|
+
const isAuto = (b) => Array.isArray(b.tags) && b.tags.includes("auto-detected");
|
|
661
|
+
const overflow = bugs.length - max;
|
|
662
|
+
const drop = new Set();
|
|
663
|
+
for (let i = 0; i < bugs.length && drop.size < overflow; i++)
|
|
664
|
+
if (isAuto(bugs[i]))
|
|
665
|
+
drop.add(i);
|
|
666
|
+
for (let i = 0; i < bugs.length && drop.size < overflow; i++)
|
|
667
|
+
drop.add(i);
|
|
668
|
+
const kept = bugs.filter((_, i) => !drop.has(i));
|
|
669
|
+
const archived = bugs.filter((_, i) => drop.has(i));
|
|
670
|
+
appendBuglogArchive(wolfDir, archived);
|
|
671
|
+
return { kept, archived };
|
|
672
|
+
}
|
|
673
|
+
/** Append entries to `.wolf/buglog-archive.json`, skipping ids that are already in there. */
|
|
674
|
+
export function appendBuglogArchive(wolfDir, entries) {
|
|
675
|
+
if (!entries.length)
|
|
676
|
+
return 0;
|
|
677
|
+
const p = path.join(wolfDir, "buglog-archive.json");
|
|
678
|
+
const raw = readJSON(p, { version: 1, bugs: [] });
|
|
679
|
+
const o = Array.isArray(raw) ? { version: 1, bugs: raw } : (raw ?? {});
|
|
680
|
+
const existing = Array.isArray(o.bugs) ? o.bugs : [];
|
|
681
|
+
const seen = new Set(existing.map((b) => String(b.id)));
|
|
682
|
+
const added = entries.filter((b) => !seen.has(String(b.id)));
|
|
683
|
+
if (!added.length)
|
|
684
|
+
return 0;
|
|
685
|
+
writeJSON(p, { version: o.version ?? 1, bugs: [...existing, ...added] });
|
|
686
|
+
return added.length;
|
|
687
|
+
}
|
|
571
688
|
export function readMarkdown(filePath) {
|
|
572
689
|
try {
|
|
573
690
|
return fs.readFileSync(filePath, "utf-8");
|
|
@@ -576,11 +693,64 @@ export function readMarkdown(filePath) {
|
|
|
576
693
|
return "";
|
|
577
694
|
}
|
|
578
695
|
}
|
|
696
|
+
/**
|
|
697
|
+
* Append a line, under the same lock the compactors take.
|
|
698
|
+
*
|
|
699
|
+
* A bare `appendFileSync` races the tmp+rename of `compactMemoryIfLarge` and `consolidateMemory`:
|
|
700
|
+
* an append that lands between their read and their rename goes to the old inode and disappears
|
|
701
|
+
* with it. Both sides "work", the line is simply gone. If the lock cannot be had we still append —
|
|
702
|
+
* a rare race beats losing the line outright, which is what happens if we give up here.
|
|
703
|
+
*/
|
|
579
704
|
export function appendMarkdown(filePath, line) {
|
|
580
705
|
const dir = path.dirname(filePath);
|
|
581
706
|
if (!fs.existsSync(dir))
|
|
582
707
|
fs.mkdirSync(dir, { recursive: true });
|
|
583
|
-
fs.appendFileSync(filePath, line, "utf-8");
|
|
708
|
+
const write = () => fs.appendFileSync(filePath, line, "utf-8");
|
|
709
|
+
try {
|
|
710
|
+
withLock(filePath, write);
|
|
711
|
+
}
|
|
712
|
+
catch {
|
|
713
|
+
write();
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
/**
|
|
717
|
+
* Replace `previousLine` with `line`, or append if it is not there.
|
|
718
|
+
*
|
|
719
|
+
* The stop hook runs on EVERY turn and its numbers are cumulative for the whole session, so
|
|
720
|
+
* appending produced one near-identical "Session end" row per turn — 232 rows for 173 sessions in
|
|
721
|
+
* one real project, six of them consecutive and differing only in the timestamp. The ledger has
|
|
722
|
+
* replaced-by-id since 1.28.0; memory.md now does the same thing with the line itself as the key.
|
|
723
|
+
*/
|
|
724
|
+
export function replaceOrAppendMarkdown(filePath, previousLine, line) {
|
|
725
|
+
if (!previousLine) {
|
|
726
|
+
appendMarkdown(filePath, line);
|
|
727
|
+
return;
|
|
728
|
+
}
|
|
729
|
+
const swap = () => {
|
|
730
|
+
let content;
|
|
731
|
+
try {
|
|
732
|
+
content = fs.readFileSync(filePath, "utf-8");
|
|
733
|
+
}
|
|
734
|
+
catch {
|
|
735
|
+
fs.appendFileSync(filePath, line, "utf-8");
|
|
736
|
+
return;
|
|
737
|
+
}
|
|
738
|
+
const at = content.lastIndexOf(previousLine);
|
|
739
|
+
if (at === -1) {
|
|
740
|
+
fs.appendFileSync(filePath, line, "utf-8");
|
|
741
|
+
return;
|
|
742
|
+
}
|
|
743
|
+
writeAtomic(filePath, () => content.slice(0, at) + line + content.slice(at + previousLine.length));
|
|
744
|
+
};
|
|
745
|
+
try {
|
|
746
|
+
withLock(filePath, swap);
|
|
747
|
+
}
|
|
748
|
+
catch {
|
|
749
|
+
try {
|
|
750
|
+
swap();
|
|
751
|
+
}
|
|
752
|
+
catch { /* memory.md is a nicety */ }
|
|
753
|
+
}
|
|
584
754
|
}
|
|
585
755
|
export function parseAnatomy(content) {
|
|
586
756
|
const sections = new Map();
|
|
@@ -1176,14 +1346,38 @@ export function timeShort() {
|
|
|
1176
1346
|
const d = new Date();
|
|
1177
1347
|
return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
|
|
1178
1348
|
}
|
|
1349
|
+
/**
|
|
1350
|
+
* Read the hook payload from stdin.
|
|
1351
|
+
*
|
|
1352
|
+
* Every hook starts here, so this function sets the floor for hook latency — and under Bun it was
|
|
1353
|
+
* the whole story. Measured on this repo: Bun starts a trivial script in 9ms against Node's 23ms,
|
|
1354
|
+
* but reading stdin through the Node stream shim costs Bun ~15ms and Node ~2ms, which cancelled
|
|
1355
|
+
* the entire runtime advantage (Bun 23ms vs Node 24ms for start+stdin). Bun's own
|
|
1356
|
+
* `Bun.stdin.text()` does the same job in ~1ms, taking the floor to 8ms.
|
|
1357
|
+
*
|
|
1358
|
+
* So: a Bun fast path, and the Node path left exactly as it was — switching Node to
|
|
1359
|
+
* `readFileSync(0)` measured no better (26ms vs 25ms) and would risk the blocking-read edge cases
|
|
1360
|
+
* for nothing.
|
|
1361
|
+
*
|
|
1362
|
+
* The 4s guard applies to both. It is not decoration: on Windows, stdin delivery from Claude Code
|
|
1363
|
+
* hooks can lag, and a hook that never resolves takes the tool call with it.
|
|
1364
|
+
*/
|
|
1365
|
+
const STDIN_TIMEOUT_MS = 4000;
|
|
1179
1366
|
export function readStdin() {
|
|
1367
|
+
const bunStdin = globalThis.Bun?.stdin;
|
|
1368
|
+
if (typeof bunStdin?.text === "function") {
|
|
1369
|
+
return Promise.race([
|
|
1370
|
+
bunStdin.text().then((t) => t || "{}").catch(() => "{}"),
|
|
1371
|
+
new Promise((resolve) => setTimeout(() => resolve("{}"), STDIN_TIMEOUT_MS)),
|
|
1372
|
+
]);
|
|
1373
|
+
}
|
|
1180
1374
|
return new Promise((resolve) => {
|
|
1181
1375
|
const chunks = [];
|
|
1182
1376
|
process.stdin.on("data", (chunk) => chunks.push(chunk));
|
|
1183
1377
|
process.stdin.on("end", () => resolve(Buffer.concat(chunks).toString("utf-8")));
|
|
1184
1378
|
// If no stdin data after 4s, resolve with whatever we have so far.
|
|
1185
1379
|
// On Windows, stdin delivery from Claude Code hooks can be slow.
|
|
1186
|
-
setTimeout(() => resolve(chunks.length ? Buffer.concat(chunks).toString("utf-8") : "{}"),
|
|
1380
|
+
setTimeout(() => resolve(chunks.length ? Buffer.concat(chunks).toString("utf-8") : "{}"), STDIN_TIMEOUT_MS);
|
|
1187
1381
|
});
|
|
1188
1382
|
}
|
|
1189
1383
|
export function normalizePath(p) {
|
|
@@ -1352,6 +1546,16 @@ export function stripPrivate(text) {
|
|
|
1352
1546
|
export function blankPrivate(text) {
|
|
1353
1547
|
return redact(text, (m) => m.replace(/[^\n]/g, ""));
|
|
1354
1548
|
}
|
|
1549
|
+
/**
|
|
1550
|
+
* Same redaction, but the block is replaced by a visible marker.
|
|
1551
|
+
*
|
|
1552
|
+
* For a reader — the dashboard, a file viewer — silently deleting the region is worse than saying
|
|
1553
|
+
* that something is hidden: the reader cannot tell an empty section from a redacted one. Recall and
|
|
1554
|
+
* every egress path keep using strip/blankPrivate; this one is for display.
|
|
1555
|
+
*/
|
|
1556
|
+
export function maskPrivate(text) {
|
|
1557
|
+
return redact(text, () => "[private — hidden by OpenWolf]");
|
|
1558
|
+
}
|
|
1355
1559
|
// Structured session-summary scaffold written under each new session header in memory.md.
|
|
1356
1560
|
// An HTML comment → invisible in rendered markdown (no clutter), but a clear prompt for the
|
|
1357
1561
|
// agent to replace at session end with a consistent, greppable one-liner (see OPENWOLF.md).
|
|
@@ -1371,7 +1575,14 @@ function isStatusStub(s) {
|
|
|
1371
1575
|
return /\{\{PROJECT_NAME\}\}/.test(s) || (/_<[^>]*>_/.test(s) && /nothing yet/.test(s));
|
|
1372
1576
|
}
|
|
1373
1577
|
// Body of a markdown section identified by its heading, up to the next h1/h2 heading.
|
|
1374
|
-
|
|
1578
|
+
/**
|
|
1579
|
+
* Body of the first section whose heading matches `headingRe`, up to the next heading of the same
|
|
1580
|
+
* or a higher level. Exported because pre-write.ts needs the SAME anchored lookup: it used to find
|
|
1581
|
+
* its section with `split("## Do-Not-Repeat")`, a plain substring, which on a large cerebrum.md
|
|
1582
|
+
* matched a sentence that merely quoted the heading name — and then scanned 5 unrelated bullets
|
|
1583
|
+
* instead of the 175 real entries, silently, for as long as the file had grown.
|
|
1584
|
+
*/
|
|
1585
|
+
export function extractMarkdownSection(md, headingRe) {
|
|
1375
1586
|
const lines = md.split(/\r?\n/);
|
|
1376
1587
|
let start = -1;
|
|
1377
1588
|
for (let i = 0; i < lines.length; i++) {
|
|
@@ -1382,9 +1593,11 @@ function extractMarkdownSection(md, headingRe) {
|
|
|
1382
1593
|
}
|
|
1383
1594
|
if (start === -1)
|
|
1384
1595
|
return "";
|
|
1596
|
+
const level = (lines[start].match(/^#+/)?.[0].length) ?? 2;
|
|
1385
1597
|
const body = [];
|
|
1386
1598
|
for (let i = start + 1; i < lines.length; i++) {
|
|
1387
|
-
|
|
1599
|
+
const m = lines[i].match(/^(#{1,6})\s/);
|
|
1600
|
+
if (m && m[1].length <= level)
|
|
1388
1601
|
break;
|
|
1389
1602
|
body.push(lines[i]);
|
|
1390
1603
|
}
|
|
@@ -1422,11 +1635,30 @@ function memoryHeadline(md) {
|
|
|
1422
1635
|
const BULLET_RE = /^\s*[-*] /gm;
|
|
1423
1636
|
// Index of knowledge files the model can pull on demand, with entry counts and token cost —
|
|
1424
1637
|
// so it knows what's available without us pre-dumping it (progressive disclosure).
|
|
1638
|
+
/** `openwolf.cerebrum.max_tokens` from config.json, with the template's own default. */
|
|
1639
|
+
function cerebrumBudget(read) {
|
|
1640
|
+
try {
|
|
1641
|
+
const cfg = JSON.parse(read("config.json"));
|
|
1642
|
+
const v = cfg.openwolf?.cerebrum?.max_tokens;
|
|
1643
|
+
if (typeof v === "number" && v > 0)
|
|
1644
|
+
return v;
|
|
1645
|
+
}
|
|
1646
|
+
catch { /* no config, or not JSON */ }
|
|
1647
|
+
return 2000;
|
|
1648
|
+
}
|
|
1425
1649
|
function availabilityIndex(read, cerebrum, nativeDir) {
|
|
1426
1650
|
const items = [];
|
|
1427
1651
|
if (cerebrum.trim()) {
|
|
1428
1652
|
const entries = (cerebrum.match(BULLET_RE) || []).length;
|
|
1429
|
-
|
|
1653
|
+
const tok = estimateTokens(cerebrum, "prose");
|
|
1654
|
+
// `cerebrum.max_tokens` existed in every generated config.json since forever and was read by
|
|
1655
|
+
// nothing at all. Meanwhile OPENWOLF.md tells the model to read the whole file — which on a
|
|
1656
|
+
// grown project is ~126k tokens, 63× the configured budget. Honour the budget here: past it,
|
|
1657
|
+
// "Read" stops being offered and recall is the only route.
|
|
1658
|
+
const budget = cerebrumBudget(read);
|
|
1659
|
+
items.push(tok > budget
|
|
1660
|
+
? `- cerebrum.md — ${entries} entries, ~${tok} tok — TOO LARGE TO READ (budget ${budget}); use \`openwolf recall <query>\`, not Read`
|
|
1661
|
+
: `- cerebrum.md — ${entries} entries, ~${tok} tok (preferences, learnings, decisions, do-not-repeat) → Read or \`openwolf recall\``);
|
|
1430
1662
|
}
|
|
1431
1663
|
const buglog = read("buglog.json");
|
|
1432
1664
|
if (buglog.trim()) {
|
package/dist/hooks/stop.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import * as fs from "node:fs";
|
|
2
2
|
import * as path from "node:path";
|
|
3
|
-
import { getWolfDir, ensureWolfDir, readJSON, writeJSON,
|
|
3
|
+
import { getWolfDir, ensureWolfDir, readJSON, writeJSON, replaceOrAppendMarkdown, timeShort, getRetention, compactMemoryIfLarge, countSemanticEntries, tryWithLock, updateSession, sessionFileFor, readStdin, readTranscriptUsage, detectAgent, bookInjection } from "./shared.js";
|
|
4
4
|
import { estimateTokens, getTokenRatios } from "./token-estimator.js";
|
|
5
5
|
const NOTHING_BOOKED = {
|
|
6
6
|
reads: 0, writes: 0, tokens: 0, anatomy_hits: 0, anatomy_misses: 0, repeated_reads_blocked: 0,
|
|
@@ -28,7 +28,14 @@ async function main() {
|
|
|
28
28
|
// session into the ledger, so reading a different session's file here would attribute one
|
|
29
29
|
// session's whole turn to another.
|
|
30
30
|
const sessionFile = sessionFileFor(hooksDir, hookInput.session_id);
|
|
31
|
-
|
|
31
|
+
// NORMALISE, do not assume. readJSON only applies these defaults when the file is MISSING —
|
|
32
|
+
// but post-bash, pre-write and post-read each create the file with their OWN partial shape when
|
|
33
|
+
// they run first (`{files_written:[], edit_counts:{}}`, `{}`), which happens whenever the
|
|
34
|
+
// session predates an `openwolf update` or session-start skipped its write under contention.
|
|
35
|
+
// `Object.keys(session.files_read)` then threw, main().catch() swallowed it, and the whole
|
|
36
|
+
// session produced no ledger entry, no memory.md line and no reminder — without one line of
|
|
37
|
+
// output. A real project carried such a file for days.
|
|
38
|
+
const SESSION_DEFAULTS = {
|
|
32
39
|
session_id: "",
|
|
33
40
|
started: "",
|
|
34
41
|
files_read: {},
|
|
@@ -39,7 +46,15 @@ async function main() {
|
|
|
39
46
|
repeated_reads_warned: 0,
|
|
40
47
|
cerebrum_warnings: 0,
|
|
41
48
|
stop_count: 0,
|
|
42
|
-
}
|
|
49
|
+
};
|
|
50
|
+
const stored = readJSON(sessionFile, {});
|
|
51
|
+
const session = { ...SESSION_DEFAULTS, ...stored };
|
|
52
|
+
if (!session.files_read || typeof session.files_read !== "object")
|
|
53
|
+
session.files_read = {};
|
|
54
|
+
if (!Array.isArray(session.files_written))
|
|
55
|
+
session.files_written = [];
|
|
56
|
+
if (!session.edit_counts || typeof session.edit_counts !== "object")
|
|
57
|
+
session.edit_counts = {};
|
|
43
58
|
session.stop_count++;
|
|
44
59
|
// Only write to ledger if there's been activity
|
|
45
60
|
const readCount = Object.keys(session.files_read).length;
|
|
@@ -96,7 +111,10 @@ async function main() {
|
|
|
96
111
|
// the ledger lock — it's a plain file read; only the accumulation below runs under the lock.
|
|
97
112
|
const realUsage = hookInput.transcript_path ? readTranscriptUsage(hookInput.transcript_path) : null;
|
|
98
113
|
const sessionEntry = {
|
|
99
|
-
id:
|
|
114
|
+
// The harness session id, not the minute-granular one from session-start: two sessions started
|
|
115
|
+
// in the same minute shared an id, and this hook REPLACES by id — so the second one's stop
|
|
116
|
+
// overwrote the first one's entry while total_sessions still counted both.
|
|
117
|
+
id: hookInput.session_id || session.session_id,
|
|
100
118
|
agent: detectAgent(),
|
|
101
119
|
started: session.started,
|
|
102
120
|
ended: new Date().toISOString(),
|
|
@@ -151,6 +169,10 @@ async function main() {
|
|
|
151
169
|
}
|
|
152
170
|
// One entry per session, not per turn: the entry carries the session's cumulative state, so a
|
|
153
171
|
// later stop REPLACES the earlier one instead of appending a near-duplicate under the same id.
|
|
172
|
+
// session-start writes a `{version, lifetime}` stub when the ledger is missing; without this
|
|
173
|
+
// guard every stop hook from then on died in findIndex — silently, for good.
|
|
174
|
+
if (!Array.isArray(ledger.sessions))
|
|
175
|
+
ledger.sessions = [];
|
|
154
176
|
const existing = ledger.sessions.findIndex((s) => s.id === sessionEntry.id);
|
|
155
177
|
if (existing === -1)
|
|
156
178
|
ledger.sessions.push(sessionEntry);
|
|
@@ -164,12 +186,17 @@ async function main() {
|
|
|
164
186
|
const booked = { ...NOTHING_BOOKED, ...(session.booked ?? {}) };
|
|
165
187
|
const totalWritesNow = writeCount + unnamedWrites;
|
|
166
188
|
const tokensNow = inputTokens + outputTokens;
|
|
167
|
-
//
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
189
|
+
// Savings, counted honestly.
|
|
190
|
+
//
|
|
191
|
+
// This used to credit ~200 tokens per anatomy hit and the FULL file size for every repeated read
|
|
192
|
+
// — as if both had been prevented. Neither is true: those hints go to stderr with exit 0, which
|
|
193
|
+
// the model never sees, and no read is ever blocked. A report that invents its own credit is
|
|
194
|
+
// worse than no report, so only genuinely avoided tokens are booked. `repeated_reads_warned`
|
|
195
|
+
// and `anatomy_hits` are still recorded; they are counters, not savings.
|
|
196
|
+
//
|
|
197
|
+
// What IS avoided: nothing measurable from the hooks today. The honest number is zero until a
|
|
198
|
+
// hint actually reaches the model (via additionalContext) or a read is actually prevented.
|
|
199
|
+
const savingsNow = 0;
|
|
173
200
|
ledger.lifetime.total_reads += delta(readCount, booked.reads);
|
|
174
201
|
// Unnamed writes (shell / other working dirs) count toward the lifetime total — they were real
|
|
175
202
|
// work. They stay OUT of the session's named writes[] list (no path was recorded, by design).
|
|
@@ -227,6 +254,7 @@ async function main() {
|
|
|
227
254
|
});
|
|
228
255
|
// Write a session summary line to memory.md if there was meaningful activity
|
|
229
256
|
const memoryPath = path.join(wolfDir, "memory.md");
|
|
257
|
+
let memoryLineNow = session.memory_line ?? "";
|
|
230
258
|
// A session that wrote only through the shell, or only in another directory, still happened.
|
|
231
259
|
// Without this, memory.md shows a gap exactly where the work was — which is how someone later
|
|
232
260
|
// concludes the day was quiet.
|
|
@@ -236,7 +264,9 @@ async function main() {
|
|
|
236
264
|
externalWrites > 0 ? `${externalWrites} outside this project root` : "",
|
|
237
265
|
bashWrites > 0 ? `${bashWrites} through the shell` : "",
|
|
238
266
|
].filter(Boolean).join(", ");
|
|
239
|
-
|
|
267
|
+
const line = `| ${timeShort()} | Session end: ${unnamedWrites} untracked writes (${via}) | ${readCount} reads | ~${inputTokens + outputTokens} tok |\n`;
|
|
268
|
+
replaceOrAppendMarkdown(memoryPath, session.memory_line ?? "", line);
|
|
269
|
+
memoryLineNow = line;
|
|
240
270
|
}
|
|
241
271
|
catch { /* memory.md is a nicety, not a dependency */ }
|
|
242
272
|
}
|
|
@@ -244,7 +274,16 @@ async function main() {
|
|
|
244
274
|
try {
|
|
245
275
|
const uniqueFiles = new Set(session.files_written.map(w => path.basename(w.file)));
|
|
246
276
|
const fileList = [...uniqueFiles].slice(0, 5).join(", ");
|
|
247
|
-
|
|
277
|
+
const line = `| ${timeShort()} | Session end: ${writeCount} writes across ${uniqueFiles.size} files (${fileList}) | ${readCount} reads | ~${inputTokens + outputTokens} tok |\n`;
|
|
278
|
+
replaceOrAppendMarkdown(memoryPath, session.memory_line ?? "", line);
|
|
279
|
+
memoryLineNow = line;
|
|
280
|
+
}
|
|
281
|
+
catch { }
|
|
282
|
+
}
|
|
283
|
+
// Remember the row we just wrote so the next turn replaces it instead of adding another.
|
|
284
|
+
if (memoryLineNow !== (session.memory_line ?? "")) {
|
|
285
|
+
try {
|
|
286
|
+
updateSession(sessionFile, session, (t) => { t.memory_line = memoryLineNow; });
|
|
248
287
|
}
|
|
249
288
|
catch { }
|
|
250
289
|
}
|
|
@@ -344,4 +383,12 @@ function checkSemanticSummaries(wolfDir, writeCount) {
|
|
|
344
383
|
return null;
|
|
345
384
|
return `${writeCount} files were changed this session but no meaningful summary was written to memory.md. Consider recording what you did and why.`;
|
|
346
385
|
}
|
|
347
|
-
|
|
386
|
+
// A hook must never fail the turn — but it must not disappear either. Swallowing the error is how
|
|
387
|
+
// a broken session file cost a whole session's bookkeeping without a trace.
|
|
388
|
+
main().catch((err) => {
|
|
389
|
+
try {
|
|
390
|
+
process.stderr.write(`[openwolf] stop hook failed: ${err?.stack || err?.message || String(err)}\n`);
|
|
391
|
+
}
|
|
392
|
+
catch { /* stderr gone too — nothing left to do */ }
|
|
393
|
+
process.exit(0);
|
|
394
|
+
});
|
|
@@ -133,8 +133,10 @@ export async function doctorCommand(opts) {
|
|
|
133
133
|
if (nd) {
|
|
134
134
|
const h = nativeMemoryHealth(nd);
|
|
135
135
|
console.log("\nClaude native memory (~/.claude/…/memory):");
|
|
136
|
-
console.log(` ${h.topicFiles} topic files, ${humanBytes(h.footprintBytes)}; MEMORY.md index ${h.indexLines} lines (${h.indexedCount} referenced)`);
|
|
137
|
-
if (h.
|
|
136
|
+
console.log(` ${h.topicFiles} topic files, ${humanBytes(h.footprintBytes)}; MEMORY.md index ${h.indexLines} lines / ${humanBytes(h.indexBytes)} (${h.indexedCount} referenced)`);
|
|
137
|
+
if (h.indexBytesExceeded)
|
|
138
|
+
console.log(` ⚠ MEMORY.md is ${humanBytes(h.indexBytes)} — Claude Code stops loading it at ~24 KB, so the tail never reaches a session. Shorten the entries (one line, well under 200 chars each).`);
|
|
139
|
+
else if (h.indexCutoffExceeded)
|
|
138
140
|
console.log(` ⚠ MEMORY.md > 200 lines — only the first 200 load at session start; the rest is invisible until you trim it.`);
|
|
139
141
|
if (h.orphanCount)
|
|
140
142
|
console.log(` ⚠ ${h.orphanCount} topic files not in the index → never surface on resume. Search them: \`openwolf recall <query>\``);
|