sawbuck 0.1.44 → 0.1.46
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/CONTRACT.md +2 -2
- package/README.md +17 -16
- package/agent-block.mjs +2 -2
- package/contract-cmd.mjs +31 -22
- package/contract.mjs +1 -1
- package/debt.mjs +3 -3
- package/exclude.mjs +1 -1
- package/memory.mjs +16 -156
- package/package.json +3 -9
- package/rank.mjs +5 -5
- package/reflect.mjs +2 -2
- package/repo-state.mjs +1 -1
- package/rules.mjs +1 -1
- package/sawbuck-hook.mjs +6 -10
- package/sawbuck.mjs +49 -175
- package/shape.mjs +12 -51
- package/status.mjs +1 -1
- package/steers.mjs +1 -1
- package/dispatch.mjs +0 -34
- package/exposure.mjs +0 -104
- package/providers/claude.mjs +0 -49
- package/providers/local.mjs +0 -55
- package/providers/mem0.mjs +0 -120
- package/providers/obsidian.mjs +0 -119
package/exposure.mjs
DELETED
|
@@ -1,104 +0,0 @@
|
|
|
1
|
-
// What was not said. The shape records what you pushed on; this records what the agent did, from your transcripts, so a
|
|
2
|
-
// spoke can be read against its opportunity: an agent that often changes code and runs nothing, in history where you
|
|
3
|
-
// rarely ask for proof, is a blind spot. Plain counting of tool calls. No model, nothing leaves this machine.
|
|
4
|
-
import { readdirSync, readFileSync, existsSync } from 'node:fs';
|
|
5
|
-
import { join } from 'node:path';
|
|
6
|
-
import { homedir } from 'node:os';
|
|
7
|
-
import { readHistory, typedText } from './history.mjs';
|
|
8
|
-
|
|
9
|
-
const VERIFY = /\b(test|tests|pytest|vitest|jest|mocha|ava|playwright|cargo (test|check)|go (test|vet)|tsc|eslint|lint|typecheck|npm run (build|check)|curl|sawbuck check|promptwheel run|make (test|check))\b/i;
|
|
10
|
-
const RISKY_PATH = /(^|\/)(\.env[^/]*|\.npmrc|\.yarnrc(\.yml)?|\.github\/workflows\/[^/]+|Dockerfile[^/]*|docker-compose[^/]*|CODEOWNERS|\.claude\/settings(\.local)?\.json|[^/]*(secret|credential)[^/]*)$/i;
|
|
11
|
-
const RISKY_CMD = /(git push[^|;&]*(--force|-f\b)|git reset --hard|curl[^|]*\|\s*(sh|bash)|sudo |drop table|chmod 777)/i;
|
|
12
|
-
// deleting a temp or build folder is housekeeping, not risk: on real history two thirds of rm -rf were that
|
|
13
|
-
const RM = /rm -rf\s+(\S+)/i, RM_SAFE = /tmp|scratch|\$|dist|build|node_modules|cache|\.next|coverage/i;
|
|
14
|
-
const SHIP = /\b(git commit|git push|npm publish|gh pr (create|merge)|gh release|vercel|deploy)\b/i;
|
|
15
|
-
const UI = /\.(css|scss|sass|less|tsx|jsx|vue|svelte|astro|html|svg)$/i;
|
|
16
|
-
const COPY = /\.(md|mdx|txt|rst)$/i;
|
|
17
|
-
const CODE = /\.(m?[jt]sx?|py|go|rs|rb|java|kt|cs|php|c|cc|cpp|h|swift|sh)$/i;
|
|
18
|
-
const BIG = 200;
|
|
19
|
-
const SCRATCH = /(^|\/)(tmp|scratchpad|scratch)\/|^\/tmp\/|\/\.claude\//; // throwaway work does not need a test run
|
|
20
|
-
const DAYS = 90; // what to work on now, not what happened last year
|
|
21
|
-
|
|
22
|
-
// Rules you have already written, by spoke. A plain keyword match on your own files, so it is a hint: it finds that a
|
|
23
|
-
// rule on the subject exists, not that it says the right thing.
|
|
24
|
-
const WRITTEN = {
|
|
25
|
-
proof: /\b(verify|verified|run the tests?|tests? pass|prove|proof|before claiming|before trusting)\b/i,
|
|
26
|
-
safety: /\b(secrets?|credentials?|force.?push|destructive|protected paths?|never delete|before deleting)\b/i,
|
|
27
|
-
simplicity: /\b(simple|simplify|bloat|smallest|minimal|keep it short|overengineer\w*)\b/i,
|
|
28
|
-
process: /\b(commit|push|publish|release|deploy)\b/i,
|
|
29
|
-
voice: /\b(em dash\w*|plain (words|language|voice)|tone|wording|ai tells?|voice)\b/i,
|
|
30
|
-
visual: /\b(design|layout|css|ui|colou?rs?|spacing)\b/i,
|
|
31
|
-
naming: /\b(naming|names?|rename)\b/i,
|
|
32
|
-
autonomy: /\b(do not ask|don't ask|ask first|ask before|proceed without|keep going)\b/i,
|
|
33
|
-
};
|
|
34
|
-
export function writtenRules({ home = homedir(), repo = process.cwd() } = {}) {
|
|
35
|
-
const files = [join(home, '.claude', 'CLAUDE.md'), join(home, '.sawbuck', 'rules.md'), join(repo, 'CLAUDE.md'), join(repo, 'AGENTS.md')];
|
|
36
|
-
const root = join(home, '.claude', 'projects');
|
|
37
|
-
try { for (const d of readdirSync(root)) { const m = join(root, d, 'memory'); if (existsSync(m)) for (const f of readdirSync(m)) if (/^feedback.*\.md$/.test(f)) files.push(join(m, f)); } } catch {}
|
|
38
|
-
let text = '';
|
|
39
|
-
for (const f of files) { try { text += '\n' + readFileSync(f, 'utf8'); } catch {} }
|
|
40
|
-
return Object.fromEntries(Object.entries(WRITTEN).map(([s, re]) => [s, re.test(text)]));
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
function lines(s) { return s ? String(s).split('\n').length : 0; }
|
|
44
|
-
|
|
45
|
-
export function exposure({ root = join(homedir(), '.claude', 'projects'), days = DAYS } = {}) {
|
|
46
|
-
const since = days ? Date.now() - days * 864e5 : 0;
|
|
47
|
-
const WINDOW = 3; // a check may come a turn or two later: count code as unchecked only if none ran in the next 3 turns
|
|
48
|
-
const c = { turns: 0, working: 0, code: 0, unverified: 0, risky: 0, big: 0, shipped: 0, ui: 0, copy: 0, renames: 0, asked: 0 };
|
|
49
|
-
let turn = null, T = [];
|
|
50
|
-
const close = () => {
|
|
51
|
-
if (!turn) return;
|
|
52
|
-
c.turns++; T.push(turn);
|
|
53
|
-
if (turn.tools) {
|
|
54
|
-
c.working++;
|
|
55
|
-
if (turn.code) c.code++;
|
|
56
|
-
if (turn.risky) c.risky++; if (turn.added >= BIG) c.big++; if (turn.ship) c.shipped++; if (turn.ui) c.ui++; if (turn.copy) c.copy++; if (turn.rename) c.renames++;
|
|
57
|
-
if (/\?\s*$/.test(turn.last)) c.asked++;
|
|
58
|
-
}
|
|
59
|
-
turn = null;
|
|
60
|
-
};
|
|
61
|
-
const endSession = () => {
|
|
62
|
-
close();
|
|
63
|
-
T.forEach((t, i) => { if (t.code && !T.slice(i, i + 1 + WINDOW).some((x) => x.verified)) c.unverified++; });
|
|
64
|
-
T = [];
|
|
65
|
-
};
|
|
66
|
-
const onTool = (b) => {
|
|
67
|
-
turn.tools++;
|
|
68
|
-
const i = b.input || {};
|
|
69
|
-
if (b.name === 'Bash') {
|
|
70
|
-
const cmd = String(i.command || '');
|
|
71
|
-
if (VERIFY.test(cmd)) turn.verified = true;
|
|
72
|
-
const rm = cmd.match(RM);
|
|
73
|
-
if (RISKY_CMD.test(cmd) || (rm && !RM_SAFE.test(rm[1]))) turn.risky = true;
|
|
74
|
-
if (SHIP.test(cmd)) turn.ship = true;
|
|
75
|
-
if (/\bgit mv\b/.test(cmd)) turn.rename = true;
|
|
76
|
-
} else if (b.name === 'Edit' || b.name === 'Write' || b.name === 'MultiEdit') {
|
|
77
|
-
const p = String(i.file_path || '');
|
|
78
|
-
if (CODE.test(p) && !SCRATCH.test(p)) turn.code++;
|
|
79
|
-
if (UI.test(p)) turn.ui = true;
|
|
80
|
-
if (COPY.test(p)) turn.copy = true;
|
|
81
|
-
if (RISKY_PATH.test(p)) turn.risky = true;
|
|
82
|
-
if (i.replace_all) turn.rename = true;
|
|
83
|
-
turn.added += Math.max(0, lines(i.content || i.new_string) - lines(i.old_string));
|
|
84
|
-
}
|
|
85
|
-
};
|
|
86
|
-
readHistory(root, {
|
|
87
|
-
onSession: endSession,
|
|
88
|
-
onEvent: (e) => {
|
|
89
|
-
if (since && e.timestamp && Date.parse(e.timestamp) < since) return;
|
|
90
|
-
if (e.type === 'user') {
|
|
91
|
-
if (typedText(e.message) === null) return;
|
|
92
|
-
close();
|
|
93
|
-
turn = { tools: 0, code: 0, verified: false, risky: false, added: 0, ship: false, ui: false, copy: false, rename: false, last: '' };
|
|
94
|
-
} else if (turn && Array.isArray(e.message && e.message.content)) {
|
|
95
|
-
for (const b of e.message.content) {
|
|
96
|
-
if (b.type === 'text') turn.last = b.text || turn.last;
|
|
97
|
-
if (b.type === 'tool_use') onTool(b);
|
|
98
|
-
}
|
|
99
|
-
}
|
|
100
|
-
},
|
|
101
|
-
});
|
|
102
|
-
return c;
|
|
103
|
-
}
|
|
104
|
-
if (import.meta.url === `file://${process.argv[1]}`) console.log(JSON.stringify(exposure()));
|
package/providers/claude.mjs
DELETED
|
@@ -1,49 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
// claude.mjs, a REFERENCE read-only memory provider: your project's Claude Code memory (CLAUDE.md,
|
|
3
|
-
// AGENTS.md, and top-level .claude/*.md) as a recall source. It is the most UBIQUITOUS local memory -
|
|
4
|
-
// a Claude Code repo already has it - so it is zero-setup. READ-ONLY by design: recall only; debt and
|
|
5
|
-
// record EXIT NONZERO (unsupported). This is a view of memory you already keep, not a write target
|
|
6
|
-
// (writing findings into CLAUDE.md would bloat the always-loaded project context), and the contract
|
|
7
|
-
// lets a provider implement a subset. Inherently leaf-scoped (reads only this repo's files, so no
|
|
8
|
-
// cross-repo leak). Zero-dep, offline, local-notes axis.
|
|
9
|
-
//
|
|
10
|
-
// recall --file P [--file Q] --json relevant lines from your project memory -> Finding[] (kind note)
|
|
11
|
-
// debt | record EXIT NONZERO (unsupported)
|
|
12
|
-
|
|
13
|
-
import { readFileSync, readdirSync, existsSync, statSync } from 'node:fs';
|
|
14
|
-
import { join, basename, extname } from 'node:path';
|
|
15
|
-
|
|
16
|
-
const argv = process.argv.slice(2);
|
|
17
|
-
const cmd = argv[0];
|
|
18
|
-
const flagAll = (n) => argv.reduce((a, v, i) => (argv[i - 1] === n ? [...a, v] : a), []);
|
|
19
|
-
const die = (m, c = 2) => { process.stderr.write(`claude provider: ${m}\n`); process.exit(c); };
|
|
20
|
-
const CWD = process.cwd();
|
|
21
|
-
|
|
22
|
-
if (cmd === 'debt' || cmd === 'record') die(`${cmd} is unsupported (claude memory is a read-only recall source)`, 2);
|
|
23
|
-
|
|
24
|
-
if (cmd === 'recall') {
|
|
25
|
-
const paths = flagAll('--file');
|
|
26
|
-
if (!paths.length) { console.log('[]'); process.exit(0); }
|
|
27
|
-
const matches = (l) => paths.find((p) => l.includes(p) || (basename(p).length > 2 && l.includes(basename(p)))) || null;
|
|
28
|
-
// the project's Claude Code memory: CLAUDE.md / AGENTS.md at the root, plus top-level .claude/*.md.
|
|
29
|
-
const sources = [];
|
|
30
|
-
for (const f of ['CLAUDE.md', 'AGENTS.md']) { const p = join(CWD, f); if (existsSync(p)) sources.push(p); }
|
|
31
|
-
try { for (const e of readdirSync(join(CWD, '.claude'))) if (extname(e).toLowerCase() === '.md') sources.push(join(CWD, '.claude', e)); } catch {}
|
|
32
|
-
const out = [];
|
|
33
|
-
for (const src of sources) {
|
|
34
|
-
let text = ''; try { if (statSync(src).size > 512 * 1024) continue; text = readFileSync(src, 'utf8'); } catch { continue; }
|
|
35
|
-
const lines = text.split('\n');
|
|
36
|
-
let heading = basename(src);
|
|
37
|
-
for (let i = 0; i < lines.length; i++) {
|
|
38
|
-
const h = lines[i].match(/^#{1,6}\s+(.+)/);
|
|
39
|
-
if (h) { heading = h[1].trim(); continue; }
|
|
40
|
-
const hit = matches(lines[i]);
|
|
41
|
-
if (!hit) continue;
|
|
42
|
-
out.push({ kind: 'note', path: hit, ref: `${basename(src)}:${i + 1}`, date: null, summary: `${heading}: ${lines[i].trim()}`.slice(0, 280), score: 0.4 });
|
|
43
|
-
}
|
|
44
|
-
}
|
|
45
|
-
console.log(JSON.stringify(out.slice(0, 8)));
|
|
46
|
-
process.exit(0);
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
die(`unknown command '${cmd || ''}' (recall | debt | record)`, 2);
|
package/providers/local.mjs
DELETED
|
@@ -1,55 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
// local.mjs, a REFERENCE minimal local-file memory provider: the simplest way to fill the socket with
|
|
3
|
-
// no external tool. Findings live as JSON in <repo>/.sawbuck/memory-notes.json (git-excluded by attach).
|
|
4
|
-
// It is the cleanest conformance target and a "bring your own, just a file" option. Zero-dep, offline,
|
|
5
|
-
// in the local-notes axis alongside obsidian. Folder-scoped like the others.
|
|
6
|
-
//
|
|
7
|
-
// recall --file P [--scope ID ...] --json -> Finding[] (kind note)
|
|
8
|
-
// record --ref SHA --summary S [--scope ID] [--side X] [--evidence-file F] --by WHO
|
|
9
|
-
// debt --json EXITS NONZERO (a note store is not git-aware)
|
|
10
|
-
//
|
|
11
|
-
// Store: SAWBUCK_LOCAL_MEMORY, else <cwd>/.sawbuck/memory-notes.json.
|
|
12
|
-
|
|
13
|
-
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
14
|
-
import { join, basename, dirname } from 'node:path';
|
|
15
|
-
|
|
16
|
-
const argv = process.argv.slice(2);
|
|
17
|
-
const cmd = argv[0];
|
|
18
|
-
const flagAll = (n) => argv.reduce((a, v, i) => (argv[i - 1] === n ? [...a, v] : a), []);
|
|
19
|
-
const flag = (n, d) => { const i = argv.indexOf(n); return i > -1 ? (argv[i + 1] ?? true) : d; };
|
|
20
|
-
const die = (m, c = 2) => { process.stderr.write(`local provider: ${m}\n`); process.exit(c); };
|
|
21
|
-
|
|
22
|
-
const STORE = process.env.SAWBUCK_LOCAL_MEMORY || join(process.cwd(), '.sawbuck', 'memory-notes.json');
|
|
23
|
-
const load = () => { try { const v = JSON.parse(readFileSync(STORE, 'utf8')); return Array.isArray(v) ? v : []; } catch { return []; } };
|
|
24
|
-
|
|
25
|
-
if (cmd === 'debt') die('debt is unsupported by a note store (git-truth lives in a git provider like logbook)', 2);
|
|
26
|
-
|
|
27
|
-
if (cmd === 'recall') {
|
|
28
|
-
const paths = flagAll('--file');
|
|
29
|
-
if (!paths.length) { console.log('[]'); process.exit(0); }
|
|
30
|
-
const scopes = new Set(flagAll('--scope')); // self + ancestors; empty (direct call) = match any
|
|
31
|
-
const bases = paths.map((p) => basename(p));
|
|
32
|
-
const out = [];
|
|
33
|
-
for (const f of load()) {
|
|
34
|
-
if (scopes.size && f.scope != null && !scopes.has(f.scope)) continue; // isolation: a sibling scope is excluded
|
|
35
|
-
const hit = (f.path && paths.includes(f.path)) || (f.summary && bases.some((b) => f.summary.includes(b)));
|
|
36
|
-
if (!hit) continue;
|
|
37
|
-
out.push({ kind: 'note', path: f.path ?? null, ref: f.ref ?? null, date: f.date ?? null, summary: String(f.summary || '').slice(0, 280), scope: f.scope ?? '', score: 0.5 });
|
|
38
|
-
}
|
|
39
|
-
out.sort((a, b) => String(b.date).localeCompare(String(a.date)));
|
|
40
|
-
console.log(JSON.stringify(out.slice(0, 8)));
|
|
41
|
-
process.exit(0);
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
if (cmd === 'record') {
|
|
45
|
-
const ref = String(flag('--ref', '')), summary = String(flag('--summary', '')), by = String(flag('--by', 'sawbuck'));
|
|
46
|
-
const scope = flagAll('--scope')[0] ?? '';
|
|
47
|
-
const side = flag('--side', ''), evidence = flag('--evidence-file', '');
|
|
48
|
-
const notes = load();
|
|
49
|
-
if (notes.some((f) => f.ref === ref && f.summary === summary && f.scope === scope)) { console.log('recorded (already present)'); process.exit(0); }
|
|
50
|
-
notes.push({ ref, summary, path: evidence || null, scope, by, ...(side ? { side: String(side) } : {}), date: new Date().toISOString().slice(0, 10) });
|
|
51
|
-
try { mkdirSync(dirname(STORE), { recursive: true }); writeFileSync(STORE, JSON.stringify(notes, null, 2) + '\n'); console.log('recorded'); process.exit(0); }
|
|
52
|
-
catch (e) { die(`write failed: ${String(e.message).split('\n')[0]}`, 1); }
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
die(`unknown command '${cmd || ''}' (recall | debt | record)`, 2);
|
package/providers/mem0.mjs
DELETED
|
@@ -1,120 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
// mem0.mjs, a REFERENCE semantic memory provider for Sawbuck's Memory Contract: Mem0 (mem0.ai) as the
|
|
3
|
-
// memory.
|
|
4
|
-
//
|
|
5
|
-
// STATUS: VERIFIED against the live Mem0 platform v3 API on 2026-10-31. /v3/memories/search/ and
|
|
6
|
-
// /v3/memories/add/ both returned 200 with the documented shapes (search -> { results: [...] }, add ->
|
|
7
|
-
// { event_id, ... }) using the Token auth header. Offline degrade (no key -> [], debt unsupported) is
|
|
8
|
-
// also tested. Adapt the query/scoping to taste; the wire calls are known-good.
|
|
9
|
-
//
|
|
10
|
-
// It is the THIRD contract implementation and a deliberately DIFFERENT axis from the other two:
|
|
11
|
-
// a networked, LLM-backed semantic store, where logbook is deterministic git-truth and obsidian is
|
|
12
|
-
// local notes. Because of that it is the one provider that is NOT offline: it needs the NETWORK and a
|
|
13
|
-
// MEM0_API_KEY. So it is an OPTIONAL provider isolated behind the socket, never part of the zero-network
|
|
14
|
-
// glue. Zero npm deps (Node >= 18 global fetch).
|
|
15
|
-
//
|
|
16
|
-
// Opt in: SAWBUCK_MEMORY=logbook,mem0 and MEM0_API_KEY=... (compose it WITH logbook so logbook stays the
|
|
17
|
-
// git-truth + debt provider and Mem0 adds recalled semantic context).
|
|
18
|
-
//
|
|
19
|
-
// recall --file P [--file Q] --json semantic search of your Mem0 memories -> Finding[] (kind note)
|
|
20
|
-
// record --ref SHA --summary S --by WHO [--side X] [--evidence-file F] add the finding as a memory
|
|
21
|
-
// debt --json EXITS NONZERO: a semantic store is not git-aware (git-truth
|
|
22
|
-
// lives in a git provider like logbook); the contract skips it.
|
|
23
|
-
//
|
|
24
|
-
// Config: MEM0_API_KEY, or a `~/.mem0` key file (a plain token, or JSON with api_key/key); without a
|
|
25
|
-
// key recall degrades to [] and record errors. Also MEM0_USER_ID (scope override), MEM0_NAMESPACE
|
|
26
|
-
// (id prefix), MEM0_BASE_URL (default https://api.mem0.ai), MEM0_TIMEOUT_MS (default 8000). NOTE:
|
|
27
|
-
// record SENDS the finding text to Mem0's service (your opt-in).
|
|
28
|
-
|
|
29
|
-
import { readFileSync } from 'node:fs';
|
|
30
|
-
import { basename, join } from 'node:path';
|
|
31
|
-
import { homedir } from 'node:os';
|
|
32
|
-
|
|
33
|
-
// the API key: MEM0_API_KEY, else a ~/.mem0 file (a plain token, or JSON with api_key/apiKey/key).
|
|
34
|
-
function mem0Key() {
|
|
35
|
-
if (process.env.MEM0_API_KEY) return process.env.MEM0_API_KEY;
|
|
36
|
-
try {
|
|
37
|
-
const raw = readFileSync(join(homedir(), '.mem0'), 'utf8').trim();
|
|
38
|
-
if (raw.startsWith('{')) { const j = JSON.parse(raw); return j.api_key || j.apiKey || j.key || ''; }
|
|
39
|
-
return raw.split(/\s+/)[0] || '';
|
|
40
|
-
} catch { return ''; }
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
const argv = process.argv.slice(2);
|
|
44
|
-
const cmd = argv[0];
|
|
45
|
-
const flagAll = (n) => argv.reduce((a, v, i) => (argv[i - 1] === n ? [...a, v] : a), []);
|
|
46
|
-
const flag = (n, d) => { const i = argv.indexOf(n); return i > -1 ? (argv[i + 1] ?? true) : d; };
|
|
47
|
-
const die = (msg, code = 2) => { process.stderr.write(`mem0 provider: ${msg}\n`); process.exit(code); };
|
|
48
|
-
|
|
49
|
-
// FOLDER SCOPING: each folder scope maps to a Mem0 user_id. --scope carries the chain, leaf-first
|
|
50
|
-
// (paths relative to the memory root; "" is the root/shared scope). recall queries self + ancestors
|
|
51
|
-
// and merges; record writes to the LEAF scope. MEM0_NAMESPACE prefixes the ids (keep them from
|
|
52
|
-
// colliding with other Mem0 usage in the same account); MEM0_USER_ID, if set, overrides to a single
|
|
53
|
-
// flat scope. With no --scope (direct call), it falls back to the repo folder name.
|
|
54
|
-
const NS = process.env.MEM0_NAMESPACE || '';
|
|
55
|
-
const scopeUid = (id) => process.env.MEM0_USER_ID || [NS, id || 'root'].filter(Boolean).join(':');
|
|
56
|
-
const scopeUids = (scopes) => {
|
|
57
|
-
if (process.env.MEM0_USER_ID) return [process.env.MEM0_USER_ID];
|
|
58
|
-
if (scopes.length) return [...new Set(scopes.map(scopeUid))];
|
|
59
|
-
return [basename(process.cwd()) || 'sawbuck']; // back-compat: no scope passed
|
|
60
|
-
};
|
|
61
|
-
|
|
62
|
-
// one bounded POST to the Mem0 platform; null on any network/HTTP/auth failure, so a read never hangs
|
|
63
|
-
// or crashes the caller. (A semantic recall is on-demand, never per-turn, so the timeout is generous.)
|
|
64
|
-
async function post(path, body, key) {
|
|
65
|
-
const base = (process.env.MEM0_BASE_URL || 'https://api.mem0.ai').replace(/\/$/, '');
|
|
66
|
-
const ctrl = new AbortController();
|
|
67
|
-
const timer = setTimeout(() => ctrl.abort(), +(process.env.MEM0_TIMEOUT_MS) || 8000);
|
|
68
|
-
try {
|
|
69
|
-
const r = await fetch(base + path, {
|
|
70
|
-
method: 'POST',
|
|
71
|
-
headers: { Authorization: `Token ${key}`, 'Content-Type': 'application/json', Accept: 'application/json' },
|
|
72
|
-
body: JSON.stringify(body),
|
|
73
|
-
signal: ctrl.signal,
|
|
74
|
-
});
|
|
75
|
-
if (!r.ok) return null;
|
|
76
|
-
return await r.json();
|
|
77
|
-
} catch { return null; } finally { clearTimeout(timer); }
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
if (cmd === 'debt') {
|
|
81
|
-
die('debt is unsupported by a semantic store (git-truth lives in a git provider like logbook)', 2);
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
if (cmd === 'recall') {
|
|
85
|
-
const key = mem0Key();
|
|
86
|
-
if (!key) { console.log('[]'); process.exit(0); } // no key -> no memories, not an error
|
|
87
|
-
const paths = flagAll('--file');
|
|
88
|
-
if (!paths.length) { console.log('[]'); process.exit(0); }
|
|
89
|
-
const query = `code, decisions, and gotchas related to: ${paths.map((p) => basename(p)).join(', ')}`;
|
|
90
|
-
const seen = new Set();
|
|
91
|
-
const findings = [];
|
|
92
|
-
// one search per scope's user_id (self + ancestors), merged; isolated from sibling scopes.
|
|
93
|
-
for (const uid of scopeUids(flagAll('--scope'))) {
|
|
94
|
-
const res = await post('/v3/memories/search/', { query, filters: { user_id: uid }, top_k: 8 }, key);
|
|
95
|
-
for (const m of (res && Array.isArray(res.results) ? res.results : [])) {
|
|
96
|
-
if (!m.memory || seen.has(m.id)) continue;
|
|
97
|
-
seen.add(m.id);
|
|
98
|
-
findings.push({ kind: 'note', path: null, ref: m.id || null, date: (m.created_at || '').slice(0, 10) || null, summary: String(m.memory).slice(0, 280), scope: uid, score: typeof m.score === 'number' ? m.score : null });
|
|
99
|
-
}
|
|
100
|
-
}
|
|
101
|
-
console.log(JSON.stringify(findings.slice(0, 8)));
|
|
102
|
-
process.exit(0);
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
if (cmd === 'record') {
|
|
106
|
-
const key = mem0Key();
|
|
107
|
-
if (!key) die('cannot record: MEM0_API_KEY is unset', 2);
|
|
108
|
-
const ref = String(flag('--ref', '')), summary = String(flag('--summary', '')), by = String(flag('--by', 'sawbuck'));
|
|
109
|
-
const side = flag('--side', ''), evidence = flag('--evidence-file', '');
|
|
110
|
-
const leaf = flagAll('--scope')[0];
|
|
111
|
-
const userId = process.env.MEM0_USER_ID || (leaf !== undefined ? scopeUid(leaf) : (basename(process.cwd()) || 'sawbuck'));
|
|
112
|
-
const metadata = { sawbuck: 'finding', ref, by, scope: leaf ?? '', ...(side ? { side: String(side) } : {}), ...(evidence ? { evidence: String(evidence) } : {}) };
|
|
113
|
-
// infer:false stores the finding verbatim (no LLM extraction); we want the exact note, not a paraphrase.
|
|
114
|
-
const res = await post('/v3/memories/add/', { messages: [{ role: 'user', content: summary }], user_id: userId, metadata, infer: false }, key);
|
|
115
|
-
if (res === null) die('record failed (network or auth)', 1);
|
|
116
|
-
console.log('recorded');
|
|
117
|
-
process.exit(0);
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
die(`unknown command '${cmd || ''}' (recall | debt | record)`, 2);
|
package/providers/obsidian.mjs
DELETED
|
@@ -1,119 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
// obsidian.mjs, a REFERENCE memory provider for Sawbuck's Memory Contract: an Obsidian vault as the
|
|
3
|
-
// memory. Shows how to fill the socket, and the second contract implementation. Zero-dep. Opt in with
|
|
4
|
-
// SAWBUCK_MEMORY=logbook,obsidian and OBSIDIAN_VAULT=/path/to/vault (compose it WITH logbook: logbook
|
|
5
|
-
// stays the git-truth + debt provider, this adds your written notes).
|
|
6
|
-
//
|
|
7
|
-
// recall --file P [--file Q] [--scope ID ...] --json notes relevant to those paths -> Finding[] (kind note)
|
|
8
|
-
// record --ref SHA --summary S --by WHO [--scope ID] write a note into the vault
|
|
9
|
-
// debt --json EXITS NONZERO: a note vault cannot know what is silenced in HEAD
|
|
10
|
-
//
|
|
11
|
-
// FOLDER SCOPING (isolation): --scope carries the folder scope chain, leaf-first, as paths relative to
|
|
12
|
-
// the memory root (Sawbuck computes them; "" is the vault root = shared/global). The vault MIRRORS the
|
|
13
|
-
// project tree: notes for scope "group/repo" live in `<vault>/group/repo/`. recall searches the LEAF
|
|
14
|
-
// scope recursively (so the repo's own notes + its Sawbuck/ folder are found) and each ANCESTOR scope
|
|
15
|
-
// TOP-LEVEL ONLY (so it never descends into a sibling repo's folder). With no --scope it searches the
|
|
16
|
-
// whole vault (direct calls / back-compat). record writes to the LEAF scope's Sawbuck/ folder.
|
|
17
|
-
|
|
18
|
-
import { readFileSync, writeFileSync, readdirSync, statSync, mkdirSync, existsSync } from 'node:fs';
|
|
19
|
-
import { join, basename, relative, extname, resolve, sep } from 'node:path';
|
|
20
|
-
|
|
21
|
-
const argv = process.argv.slice(2);
|
|
22
|
-
const cmd = argv[0];
|
|
23
|
-
const flagAll = (n) => argv.reduce((a, v, i) => (argv[i - 1] === n ? [...a, v] : a), []);
|
|
24
|
-
const flag = (n, d) => { const i = argv.indexOf(n); return i > -1 ? (argv[i + 1] ?? true) : d; };
|
|
25
|
-
|
|
26
|
-
const VAULT = process.env.OBSIDIAN_VAULT ? resolve(process.env.OBSIDIAN_VAULT) : '';
|
|
27
|
-
const die = (msg, code = 2) => { process.stderr.write(`obsidian provider: ${msg}\n`); process.exit(code); };
|
|
28
|
-
|
|
29
|
-
const MAX_FILES = +(process.env.OBSIDIAN_MAX_FILES) || 4000;
|
|
30
|
-
const MAX_BYTES = 512 * 1024;
|
|
31
|
-
|
|
32
|
-
// resolve a scope id to a dir INSIDE the vault; null if it would escape (path-traversal guard).
|
|
33
|
-
function scopeDir(id) {
|
|
34
|
-
if (id === '' || id == null) return VAULT;
|
|
35
|
-
const p = resolve(VAULT, id);
|
|
36
|
-
return (p === VAULT || p.startsWith(VAULT + sep)) ? p : null;
|
|
37
|
-
}
|
|
38
|
-
// collect .md files; recursive for the leaf scope, top-level only for an ancestor (so an ancestor
|
|
39
|
-
// search never descends into a sibling repo's folder).
|
|
40
|
-
function walkMd(dir, recursive, out) {
|
|
41
|
-
let entries = [];
|
|
42
|
-
try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return; }
|
|
43
|
-
for (const e of entries) {
|
|
44
|
-
if (out.length >= MAX_FILES) return;
|
|
45
|
-
if (e.name.startsWith('.') || e.name === 'node_modules') continue;
|
|
46
|
-
const p = join(dir, e.name);
|
|
47
|
-
if (e.isDirectory()) { if (recursive) walkMd(p, true, out); }
|
|
48
|
-
else if (extname(e.name).toLowerCase() === '.md') out.push(p);
|
|
49
|
-
}
|
|
50
|
-
}
|
|
51
|
-
// the dirs to search, with their scope id and recursion, from the --scope chain (leaf-first).
|
|
52
|
-
function searchScopes(scopes) {
|
|
53
|
-
if (!scopes.length) return [{ id: '', dir: VAULT, recursive: true }]; // no scope -> whole vault
|
|
54
|
-
const out = []; const seen = new Set();
|
|
55
|
-
scopes.forEach((id, i) => {
|
|
56
|
-
const dir = scopeDir(id);
|
|
57
|
-
if (!dir || seen.has(dir)) return; seen.add(dir);
|
|
58
|
-
out.push({ id, dir, recursive: i === 0 }); // leaf recursive; ancestors top-level only
|
|
59
|
-
});
|
|
60
|
-
if (!seen.has(VAULT)) out.push({ id: '', dir: VAULT, recursive: false }); // vault root is the shared scope
|
|
61
|
-
return out;
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
if (cmd === 'debt') {
|
|
65
|
-
die('debt is unsupported by a note vault (git-truth lives in a git provider like logbook)', 2);
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
if (cmd === 'recall') {
|
|
69
|
-
if (!VAULT || !existsSync(VAULT)) { console.log('[]'); process.exit(0); }
|
|
70
|
-
const paths = flagAll('--file');
|
|
71
|
-
if (!paths.length) { console.log('[]'); process.exit(0); }
|
|
72
|
-
const needles = [...new Set(paths.flatMap((p) => [p, basename(p)]).filter(Boolean))];
|
|
73
|
-
const findings = [];
|
|
74
|
-
for (const { id, dir, recursive } of searchScopes(flagAll('--scope'))) {
|
|
75
|
-
const files = []; walkMd(dir, recursive, files);
|
|
76
|
-
for (const file of files) {
|
|
77
|
-
let text = '';
|
|
78
|
-
try { if (statSync(file).size > MAX_BYTES) continue; text = readFileSync(file, 'utf8'); } catch { continue; }
|
|
79
|
-
const hitPath = paths.find((p) => text.includes(p) || text.includes(basename(p)));
|
|
80
|
-
if (!hitPath) continue;
|
|
81
|
-
let hits = 0; for (const n of needles) hits += text.split(n).length - 1;
|
|
82
|
-
const title = (text.match(/^#\s+(.+)$/m) || [, basename(file, '.md')])[1].trim();
|
|
83
|
-
const lineWith = (text.split('\n').find((l) => needles.some((n) => l.includes(n))) || '').trim().slice(0, 160);
|
|
84
|
-
let date = null; try { date = new Date(statSync(file).mtimeMs).toISOString().slice(0, 10); } catch {}
|
|
85
|
-
findings.push({ kind: 'note', path: hitPath, ref: relative(VAULT, file), date, summary: `${title}: ${lineWith}`.slice(0, 280), scope: id, score: Math.min(1, hits / 5), _hits: hits });
|
|
86
|
-
}
|
|
87
|
-
}
|
|
88
|
-
findings.sort((a, b) => b._hits - a._hits || String(b.date).localeCompare(String(a.date)));
|
|
89
|
-
console.log(JSON.stringify(findings.slice(0, 8).map(({ _hits, ...f }) => f)));
|
|
90
|
-
process.exit(0);
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
if (cmd === 'record') {
|
|
94
|
-
if (!VAULT || !existsSync(VAULT)) die('cannot record: OBSIDIAN_VAULT is unset or missing', 2);
|
|
95
|
-
const ref = flag('--ref', ''), summary = String(flag('--summary', '')), by = String(flag('--by', 'sawbuck'));
|
|
96
|
-
const side = flag('--side', ''), evidence = flag('--evidence-file', '');
|
|
97
|
-
const leaf = flagAll('--scope')[0] ?? '';
|
|
98
|
-
const base = scopeDir(leaf) || VAULT; // the finding belongs to its most-specific scope
|
|
99
|
-
const dir = join(base, 'Sawbuck');
|
|
100
|
-
try { mkdirSync(dir, { recursive: true }); } catch {}
|
|
101
|
-
const safeRef = String(ref || Date.now()).replace(/[^\w.-]/g, '_').slice(0, 40);
|
|
102
|
-
const date = new Date().toISOString();
|
|
103
|
-
// a [[wikilink]] to the evidence path so Obsidian's graph connects the finding to the code note.
|
|
104
|
-
const link = evidence ? `\n- evidence: [[${String(evidence).replace(/[[\]]/g, '')}]]` : '';
|
|
105
|
-
const body = `---\nsawbuck: finding\nref: ${ref}\nby: ${by}\ndate: ${date}\nscope: ${leaf}\n---\n\n# ${summary.slice(0, 80)}\n\n${summary}\n\n- ref: \`${ref}\`${side ? `\n- side: ${side}` : ''}${link}\n`;
|
|
106
|
-
// no-clobber + dedupe: if this exact finding is already noted, skip; if a DIFFERENT note shares the
|
|
107
|
-
// ref, write a suffixed sibling instead of overwriting the earlier one.
|
|
108
|
-
let target = join(dir, `${safeRef}.md`);
|
|
109
|
-
if (existsSync(target)) {
|
|
110
|
-
let prev = ''; try { prev = readFileSync(target, 'utf8'); } catch {}
|
|
111
|
-
if (prev.includes(summary)) { console.log('recorded (already present)'); process.exit(0); }
|
|
112
|
-
let i = 2; while (existsSync(join(dir, `${safeRef}-${i}.md`))) i++;
|
|
113
|
-
target = join(dir, `${safeRef}-${i}.md`);
|
|
114
|
-
}
|
|
115
|
-
try { writeFileSync(target, body); console.log('recorded'); process.exit(0); }
|
|
116
|
-
catch (e) { die(`write failed: ${String(e.message).split('\n')[0]}`, 1); }
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
die(`unknown command '${cmd || ''}' (recall | debt | record)`, 2);
|