Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,7 @@ Percentages from different taps overlap the same bill; they add, they don't mult

| Platform | MCP tools | Auto-compression | Slash commands | Notes |
|---|---|---|---|---|
| Claude Code | ✅ | ✅ hooks (deepest) | `/meter` `/handoff` `/terse` | full lifecycle hooks |
| Claude Code | ✅ | ✅ hooks (deepest) | `/goal` `/meter` `/handoff` `/terse` | full lifecycle hooks |
| Cursor · Windsurf · Cline | ✅ | via `knitbrain_read` | — | native config written by `setup` |
| Copilot (VS Code + CLI) | ✅ | via `knitbrain_read` | — | `.vscode/mcp.json` + `.github/instructions` |
| Codex and any MCP client | ✅ | via `knitbrain_read` | — | one universal server entry |
Expand Down
34 changes: 28 additions & 6 deletions src/ccr/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,8 +155,19 @@ export function createFileCCRStore(root: string): CCRStore {
return {
put(original: string): string {
const handle = sha256(original);
if (!existsSync(hotPath(handle)) && !existsSync(coldPath(handle))) {
const hot = existsSync(hotPath(handle));
const cold = existsSync(coldPath(handle));
if (!hot && !cold) {
writeAtomic(hotPath(handle), original);
} else if (hot) {
// L1: self-heal a corrupt hot file — we hold the true bytes now, so a
// pre-corrupted file (which would make every future get throw
// CCRIntegrityError forever) gets rewritten instead of left broken.
try {
if (sha256(readFileSync(hotPath(handle), "utf8")) !== handle) writeAtomic(hotPath(handle), original);
} catch {
writeAtomic(hotPath(handle), original);
}
}
meta.set(handle, { lastUsed: Date.now(), retrievals: meta.get(handle)?.retrievals ?? 0 });
saveManifestBatched();
Expand All @@ -178,11 +189,22 @@ export function createFileCCRStore(root: string): CCRStore {
get(handle: string): string {
assertHandle(handle);
if (existsSync(hotPath(handle))) {
const data = readFileSync(hotPath(handle), "utf8");
if (sha256(data) !== handle) throw new CCRIntegrityError(handle);
const m = meta.get(handle) ?? { lastUsed: 0, retrievals: 0 };
meta.set(handle, { lastUsed: Date.now(), retrievals: m.retrievals + 1 });
return data;
// M7: TOCTOU — a concurrent demote/maintain can rmSync the hot file
// between existsSync and readFileSync. A raw ENOENT here would bypass
// the typed error contract AND skip the cold copy demote just wrote.
// Fall through to the cold tier on ENOENT instead of throwing.
let data: string | null = null;
try {
data = readFileSync(hotPath(handle), "utf8");
} catch (e) {
if ((e as NodeJS.ErrnoException).code !== "ENOENT") throw e;
}
if (data !== null) {
if (sha256(data) !== handle) throw new CCRIntegrityError(handle);
const m = meta.get(handle) ?? { lastUsed: 0, retrievals: 0 };
meta.set(handle, { lastUsed: Date.now(), retrievals: m.retrievals + 1 });
return data;
}
}
if (existsSync(coldPath(handle))) {
const data = gunzipSync(readFileSync(coldPath(handle))).toString("utf8");
Expand Down
23 changes: 19 additions & 4 deletions src/engine/memory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,10 +99,25 @@ export function createMemory(root: string): Memory {
// Dedup on the cleansed summary so it matches what's actually stored.
const summary = cleanseForBrain(input.summary.trim());
const lesson = cleanseForBrain(input.lesson);
// Dedup by substring match on summary.
const dup = all.find(
(l) => l.summary.includes(summary) || summary.includes(l.summary),
);
// M5: dedup safely. Raw bidirectional substring silently dropped distinct
// learnings — a short/generic summary swallowed longer ones, and an empty
// summary matched everything. Require a meaningful anchor length, and only
// treat as duplicate on normalized equality or when a SUBSTANTIAL prefix
// overlaps — never on a tiny/empty summary.
const norm = (s: string): string => s.toLowerCase().replace(/\s+/g, " ").trim();
const ns = norm(summary);
const DEDUP_MIN = 12;
const dup = all.find((l) => {
const nl = norm(l.summary);
// Exact normalized equality dedups at ANY length (re-recording the same
// summary is a duplicate) — but never treat an empty summary as a match.
if (ns.length > 0 && nl === ns) return true;
// Fuzzy near-duplicate (prefix overlap) ONLY above the anchor length, so
// a short/generic summary can't silently swallow a distinct learning.
if (ns.length < DEDUP_MIN || nl.length < DEDUP_MIN) return false;
const [short, long] = ns.length <= nl.length ? [ns, nl] : [nl, ns];
return long.startsWith(short) && short.length / long.length > 0.8;
});
if (dup) return { id: dup.id, duplicate: true };

const id = createHash("sha256")
Expand Down
2 changes: 1 addition & 1 deletion src/engine/onboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -310,7 +310,7 @@ type GitRunner = (args: string) => string;
function makeGit(cwd: string): GitRunner {
return (args) => {
try {
return execSync(`git ${args}`, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
return execSync(`git ${args}`, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 15_000, killSignal: "SIGKILL" }).trim();
} catch {
return "";
}
Expand Down
33 changes: 30 additions & 3 deletions src/engine/teams.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { createHash } from "node:crypto";
import { existsSync, mkdirSync, readFileSync } from "node:fs";
import { existsSync, mkdirSync, readFileSync, statSync } from "node:fs";
import { writeAtomic } from "../atomic.js";
import { join } from "node:path";
import type { CCRStore } from "../ccr/store.js";
import { compress } from "../optimizer/router.js";
import { scrubSecrets } from "./cleanse.js";

/** One posting on the shared board: a compressed skeleton + a CCR handle to the full original. */
export interface BoardEntry {
Expand Down Expand Up @@ -50,7 +51,12 @@ export function createTeamBoard(root: string, ccr: CCRStore): TeamBoard {
};

return {
post(author, content) {
post(author, rawContent) {
// H2: scrub secrets at the storage layer so EVERY caller (MCP tool, hub,
// a direct engine call) is protected — a pasted key never enters the
// board, the CCR original, or a hub mirror. Defense-in-depth with the
// tool-layer scrub. Matches wiki.ingest's cleanse-on-write.
const content = scrubSecrets(rawContent);
const r = compress(content, ccr);
// Always keep the pristine original recoverable, compressed or not.
const handle = r.compressed ? r.handle : ccr.put(content);
Expand All @@ -61,7 +67,28 @@ export function createTeamBoard(root: string, ccr: CCRStore): TeamBoard {
handle,
ts: new Date().toISOString(),
};
save([...load(), entry]);
// M6: two agents posting concurrently each load N and write N+1, silently
// dropping one — the exact "N parallel agents" case the board exists for.
// Reload immediately before the write and retry while the file keeps
// changing under us; merge by id so a retry re-picks-up a rival's entry
// instead of clobbering it. Narrows the lost-update window to the atomic
// write itself. (A single-writer lock would fully close it; the board is
// best-effort shared context, so a bounded CAS is the right tradeoff.)
const mtime = (): number => {
try {
return statSync(path).mtimeMs;
} catch {
return 0;
}
};
for (let attempt = 0; attempt < 5; attempt += 1) {
const before = mtime();
const byId = new Map<string, BoardEntry>();
for (const e of load()) byId.set(e.id, e);
byId.set(entry.id, entry);
save([...byId.values()]);
if (mtime() === before) break; // nobody wrote between our load and save
}
return entry;
},
board() {
Expand Down
39 changes: 33 additions & 6 deletions src/engine/wiki.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
import { existsSync, mkdirSync, readFileSync, readdirSync, statSync } from "node:fs";
import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, appendFileSync } from "node:fs";
import { join } from "node:path";
import { writeAtomic } from "../atomic.js";
import { scrubSecrets } from "./cleanse.js";

/** Log rotation caps (H4). The log grows on EVERY prompt via the hook, so it is
* appended O(1) and rotated (keep the newest lines) once it crosses the byte cap. */
const LOG_MAX_BYTES = 512_000;
const LOG_KEEP_LINES = 500;

/**
* Wiki-brain (leg 5): a compounding markdown knowledge base the LLM maintains.
Expand Down Expand Up @@ -240,13 +246,17 @@ export function createWikiStore(root: string): WikiStore {
ingest(input) {
const s = slug(input.title);
const links = (input.links ?? []).map(slug);
const summary = input.content.split(/\r?\n/).find((l) => l.trim().length > 0)?.slice(0, 100) ?? input.title;
// M1: scrub secrets BEFORE anything is written to the brain — a wiki page
// persists and is re-injected every session, so an unscrubbed key would
// leak forever. Matches the memory route's cleanse-on-write.
const content = scrubSecrets(input.content);
const summary = content.split(/\r?\n/).find((l) => l.trim().length > 0)?.slice(0, 100) ?? input.title;
// Strip newlines from the title before it goes into frontmatter — a raw
// newline would inject a bogus `key: value` line and corrupt the index parse.
const safeTitle = input.title.replace(/[\r\n]+/g, " ").trim();
const safeTitle = scrubSecrets(input.title).replace(/[\r\n]+/g, " ").trim();
const fm = `---\nkind: ${input.kind}\ntitle: ${safeTitle}\ndate: ${today()}\nsummary: ${summary.replace(/\n/g, " ")}\n---\n`;
const linkLine = links.length ? `\nrelated: ${links.map((l) => `[[${l}]]`).join(" · ")}\n` : "";
writeAtomic(pagePath(s), `${fm}\n${input.content.trim()}\n${linkLine}`);
writeAtomic(pagePath(s), `${fm}\n${content.trim()}\n${linkLine}`);
const touched = [s];
// Stub any linked page that doesn't exist yet (so cross-refs resolve).
for (const l of links) {
Expand All @@ -264,8 +274,25 @@ export function createWikiStore(root: string): WikiStore {

log(event, title) {
const line = `## [${today()}] ${event} | ${title}\n`;
const prior = existsSync(logPath) ? readFileSync(logPath, "utf8") : "# Wiki Log\n\n";
writeAtomic(logPath, prior + line);
// H4: this runs on EVERY prompt (userpromptsubmit hook). Read-whole +
// rewrite-whole was O(n) per call on an unbounded file = quadratic hot
// path. Seed the header once, then O(1) append; rotate (keep newest lines)
// when it crosses the cap. Best-effort — a log write never breaks a tool.
try {
if (!existsSync(logPath)) {
writeAtomic(logPath, "# Wiki Log\n\n");
} else if (statSync(logPath).size > LOG_MAX_BYTES) {
const kept = readFileSync(logPath, "utf8")
.split(/\r?\n/)
.filter((l) => l.startsWith("## ["))
.slice(-LOG_KEEP_LINES)
.join("\n");
writeAtomic(logPath, `# Wiki Log\n\n${kept}\n`);
}
appendFileSync(logPath, line);
} catch {
/* best-effort logging — never break a tool on a log write */
}
},

recentLog(n) {
Expand Down
Loading
Loading