sawbuck 0.1.43 → 0.1.45
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 +20 -21
- package/agent-block.mjs +9 -16
- package/contract-cmd.mjs +32 -23
- package/contract.mjs +1 -1
- package/debt.mjs +3 -3
- package/exclude.mjs +16 -10
- package/memory.mjs +16 -156
- package/package.json +3 -8
- package/rank.mjs +5 -5
- package/reflect.mjs +2 -2
- package/repo-state.mjs +22 -0
- package/rules.mjs +1 -1
- package/sawbuck-hook.mjs +22 -37
- package/sawbuck.mjs +76 -223
- package/shape.mjs +35 -50
- package/status.mjs +6 -12
- 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/shape.mjs
CHANGED
|
@@ -1,17 +1,17 @@
|
|
|
1
|
-
// Your steering shape: what you push on, as
|
|
2
|
-
// totals are kept.
|
|
3
|
-
// never shown. It says what you pushed on, not how good you are. Each run is saved with its date
|
|
4
|
-
import { readFileSync, writeFileSync, appendFileSync, mkdirSync, existsSync } from 'node:fs';
|
|
5
|
-
import {
|
|
1
|
+
// Your steering shape: what you push on, as 11 spokes in 4 groups. The model labels a sample of your messages; only the
|
|
2
|
+
// totals are kept. Three models agreed on single messages about 70% of the time and on the shape at 0.87 or better, so a
|
|
3
|
+
// single label is never shown. It says what you pushed on, not how good you are. Each run is saved with its date.
|
|
4
|
+
import { readFileSync, writeFileSync, appendFileSync, mkdirSync, existsSync, rmSync, copyFileSync } from 'node:fs';
|
|
5
|
+
import { execFileSync } from 'node:child_process';
|
|
6
|
+
import { join, dirname } from 'node:path';
|
|
6
7
|
import { homedir } from 'node:os';
|
|
7
8
|
import { prepareRules } from './rules.mjs';
|
|
8
|
-
import { exposure, writtenRules } from './exposure.mjs';
|
|
9
9
|
|
|
10
|
-
export const GROUPS = { Truth: ['correctness', 'proof', 'honesty'], Intent: ['
|
|
10
|
+
export const GROUPS = { Truth: ['correctness', 'proof', 'honesty'], Intent: ['scope', 'autonomy'], Craft: ['voice', 'naming', 'visual', 'simplicity'], Control: ['process', 'safety'] };
|
|
11
11
|
const SPOKES = Object.values(GROUPS).flat();
|
|
12
12
|
const MIN_SPOKES = 200; // below this, spokes are noise: show the 4 groups
|
|
13
13
|
const SAMPLE = 600;
|
|
14
|
-
const paths = (home) => ({ dir: join(home, '.sawbuck'), rules: join(home, '.sawbuck', 'rule-input.txt'), input: join(home, '.sawbuck', 'shape-input.txt'), hist: join(home, '.sawbuck', 'shape-history.jsonl'),
|
|
14
|
+
const paths = (home) => ({ dir: join(home, '.sawbuck'), rules: join(home, '.sawbuck', 'rule-input.txt'), input: join(home, '.sawbuck', 'shape-input.txt'), hist: join(home, '.sawbuck', 'shape-history.jsonl'), svg: join(home, '.sawbuck', 'shape.svg') });
|
|
15
15
|
const title = (s) => s[0].toUpperCase() + s.slice(1);
|
|
16
16
|
|
|
17
17
|
export function prepareShape({ home = homedir() } = {}) {
|
|
@@ -21,14 +21,14 @@ export function prepareShape({ home = homedir() } = {}) {
|
|
|
21
21
|
for (let i = lines.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [lines[i], lines[j]] = [lines[j], lines[i]]; }
|
|
22
22
|
const pick = lines.slice(0, SAMPLE);
|
|
23
23
|
writeFileSync(p.input, pick.map((l, i) => `${i + 1}\t${l}`).join('\n') + '\n', { mode: 0o600 });
|
|
24
|
-
writeFileSync(p.expo, JSON.stringify({ ...exposure({ root: join(home, '.claude', 'projects') }), written: writtenRules({ home }) }) + '\n');
|
|
25
24
|
return { input: p.input, lines: pick.length, secrets: r.secrets };
|
|
26
25
|
}
|
|
27
26
|
|
|
28
27
|
export function tally(labels) {
|
|
29
28
|
const counts = Object.fromEntries(SPOKES.map((s) => [s, 0]));
|
|
30
29
|
let none = 0;
|
|
31
|
-
|
|
30
|
+
// two models split scope and direction at random (shape r 0.55 with both, 0.97 merged), so they count as one
|
|
31
|
+
for (const raw of Object.values(labels)) { const v = raw === 'direction' ? 'scope' : raw; if (v === 'none') none++; else if (v in counts) counts[v]++; }
|
|
32
32
|
const total = SPOKES.reduce((a, s) => a + counts[s], 0);
|
|
33
33
|
const groups = Object.fromEntries(Object.entries(GROUPS).map(([g, ss]) => [g, total ? Math.round(100 * ss.reduce((a, s) => a + counts[s], 0) / total) : 0]));
|
|
34
34
|
const top = Math.max(1, ...SPOKES.map((s) => counts[s]));
|
|
@@ -39,39 +39,32 @@ export function tally(labels) {
|
|
|
39
39
|
}
|
|
40
40
|
|
|
41
41
|
// What was not said: each spoke we can measure from what the agent did, read against how often you push on it.
|
|
42
|
-
|
|
43
|
-
const SEEN = {
|
|
44
|
-
proof: { fault: true, of: 'code', n: 'unverified', says: 'changed code and ran no check within 3 turns', rule: 'Run the tests before saying a change works.' },
|
|
45
|
-
safety: { fault: true, of: 'working', n: 'risky', says: 'touched something risky', rule: 'Ask before force pushing, deleting outside temp folders, or touching secrets, workflows or deploy files.' },
|
|
46
|
-
process: { of: 'working', n: 'shipped', says: 'committed, pushed or published', rule: 'Do not commit or push unless I ask.' },
|
|
47
|
-
voice: { of: 'working', n: 'copy', says: 'wrote docs or copy', rule: 'Write docs in plain, short sentences.' },
|
|
48
|
-
visual: { of: 'working', n: 'ui', says: 'changed the interface', rule: 'Show me interface changes before calling them done.' },
|
|
49
|
-
simplicity: { fault: true, of: 'working', n: 'big', says: 'added 200 lines or more', rule: 'Keep changes small. Ask before adding more than 200 lines.' },
|
|
50
|
-
naming: { of: 'working', n: 'renames', says: 'renamed something', rule: 'Ask before renaming anything public.' },
|
|
51
|
-
autonomy: { of: 'working', n: 'asked', says: 'stopped to ask you', rule: 'Keep going on reversible work without asking.' },
|
|
52
|
-
};
|
|
53
|
-
export function patterns(t, c) {
|
|
54
|
-
if (!c || !c.working) return [];
|
|
55
|
-
return Object.entries(SEEN).map(([s, m]) => {
|
|
56
|
-
const pct = Math.round(100 * (c[m.n] || 0) / Math.max(1, c[m.of]));
|
|
57
|
-
const push = t.shares[s], often = pct >= (m.fault ? HIGH_FAULT : HIGH_DOING), pushed = push >= HIGH_PUSH;
|
|
58
|
-
// a fault you keep raising is a fight; activity you raise is just attention, since committing is not a failure
|
|
59
|
-
const has = !!(c.written && c.written[s]);
|
|
60
|
-
const kind = often && pushed ? (m.fault ? 'fighting it' : 'watched') : often ? (has ? 'covered' : 'blind spot') : pushed ? 'handled' : 'quiet';
|
|
61
|
-
return { spoke: s, kind, pct, count: c[m.n] || 0, of: c[m.of], push, says: m.says, rule: m.rule, has };
|
|
62
|
-
});
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
export function shapeSvg(t, found = []) {
|
|
42
|
+
export function shapeSvg(t) {
|
|
66
43
|
const names = t.spokes ? SPOKES : Object.keys(GROUPS);
|
|
67
44
|
const vals = t.spokes ? names.map((s) => t.ratings[s] / 99) : names.map((g) => 0.25 + 0.75 * t.groups[g] / Math.max(1, ...Object.values(t.groups)));
|
|
68
45
|
const pt = (i, v) => { const a = 2 * Math.PI * i / names.length; return [(500 + 330 * v * Math.sin(a)).toFixed(1), (520 - 330 * v * Math.cos(a)).toFixed(1)]; };
|
|
69
|
-
const doing = Object.fromEntries(found.map((f) => [f.spoke, f.pct]));
|
|
70
|
-
const topDoing = Math.max(1, ...Object.values(doing));
|
|
71
|
-
const seenPts = t.spokes && found.length ? names.flatMap((n, i) => (n in doing ? [pt(i, 0.4 + 0.59 * Math.sqrt(doing[n] / topDoing))] : [])) : [];
|
|
72
46
|
const ring = (v) => names.map((_, i) => pt(i, v).join(',')).join(' ');
|
|
73
47
|
const labels = names.map((n, i) => { const [x, y] = pt(i, 1.14); return `<text x="${x}" y="${y}" text-anchor="middle" fill="#e8e6e1" font-size="22" font-weight="700">${title(n)}</text><text x="${x}" y="${+y + 28}" text-anchor="middle" fill="#c9a24a" font-size="24" font-weight="700">${t.spokes ? t.ratings[n] : t.groups[n] + '%'}</text>`; }).join('');
|
|
74
|
-
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1000 1000" font-family="system-ui,sans-serif"><rect width="1000" height="1000" fill="#0b0e13"/><text x="50" y="70" fill="#e8e6e1" font-size="40" font-weight="700">My steering shape</text>${[0.25, 0.5, 0.75, 1].map((v) => `<polygon points="${ring(v)}" fill="none" stroke="#2a313b"/>`).join('')}<polygon points="${names.map((_, i) => pt(i, vals[i]).join(',')).join(' ')}" fill="#c9a24a" fill-opacity="0.35" stroke="#c9a24a" stroke-width="4"/>${
|
|
48
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1000 1000" font-family="system-ui,sans-serif"><rect width="1000" height="1000" fill="#0b0e13"/><text x="50" y="70" fill="#e8e6e1" font-size="40" font-weight="700">My steering shape</text>${[0.25, 0.5, 0.75, 1].map((v) => `<polygon points="${ring(v)}" fill="none" stroke="#2a313b"/>`).join('')}<polygon points="${names.map((_, i) => pt(i, vals[i]).join(',')).join(' ')}" fill="#c9a24a" fill-opacity="0.35" stroke="#c9a24a" stroke-width="4"/>${labels}<text x="50" y="960" fill="#8d94a0" font-size="19">Gold: what I push on, from ${t.n} messages. Relative to my own top. Not a score. sawbuck.ai</text></svg>\n`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// A PNG of the card, with what is already on the machine: sawbuck ships no image library. A snap browser sees neither
|
|
52
|
+
// a hidden folder nor the real /tmp, so it works in a plain folder in home and the result is copied over.
|
|
53
|
+
function pngCard(svg) {
|
|
54
|
+
const png = svg.replace(/\.svg$/, '.png');
|
|
55
|
+
rmSync(png, { force: true });
|
|
56
|
+
const run = (bin, args) => { try { execFileSync(bin, args, { stdio: 'ignore', timeout: 30000 }); return true; } catch { return false; } };
|
|
57
|
+
if (run('rsvg-convert', ['-w', '1200', '-o', png, svg]) && existsSync(png)) return png;
|
|
58
|
+
const tmp = join(dirname(dirname(svg)), `sawbuck-shape-${process.pid}`);
|
|
59
|
+
try {
|
|
60
|
+
mkdirSync(tmp, { recursive: true });
|
|
61
|
+
const a = join(tmp, 'shape.svg'), b = join(tmp, 'shape.png');
|
|
62
|
+
copyFileSync(svg, a);
|
|
63
|
+
for (const bin of ['google-chrome', 'chromium', 'chromium-browser']) {
|
|
64
|
+
if (run(bin, ['--headless', '--disable-gpu', '--hide-scrollbars', '--window-size=1000,1000', `--screenshot=${b}`, `file://${a}`]) && existsSync(b)) { copyFileSync(b, png); return png; }
|
|
65
|
+
}
|
|
66
|
+
} catch {} finally { rmSync(tmp, { recursive: true, force: true }); }
|
|
67
|
+
return '';
|
|
75
68
|
}
|
|
76
69
|
|
|
77
70
|
export function shapeCli(args, { home = homedir() } = {}) {
|
|
@@ -87,24 +80,16 @@ export function shapeCli(args, { home = homedir() } = {}) {
|
|
|
87
80
|
mkdirSync(p.dir, { recursive: true });
|
|
88
81
|
let prev = null;
|
|
89
82
|
if (existsSync(p.hist)) { const l = readFileSync(p.hist, 'utf8').trim().split('\n').filter(Boolean); if (l.length) prev = JSON.parse(l[l.length - 1]); }
|
|
90
|
-
|
|
91
|
-
const found = patterns(t, c);
|
|
92
|
-
writeFileSync(p.svg, shapeSvg(t, found));
|
|
83
|
+
writeFileSync(p.svg, shapeSvg(t));
|
|
93
84
|
const out = [`🐴 your steering shape, from ${t.n} messages that pushed on something (${t.none} did not). What you push on, not a score.`, ''];
|
|
94
85
|
for (const [g, ss] of Object.entries(GROUPS)) {
|
|
95
86
|
const was = prev && prev.groups[g] != null ? ` (was ${prev.groups[g]}%)` : '';
|
|
96
87
|
out.push(` ${g.padEnd(8)} ${String(t.groups[g]).padStart(3)}%${was}`);
|
|
97
88
|
if (t.spokes) out.push(` ${ss.map((s) => `${title(s)} ${t.ratings[s]}`).join(' ')}`);
|
|
98
89
|
}
|
|
99
|
-
if (!t.spokes) out.push('', ` Too few messages for the
|
|
100
|
-
appendFileSync(p.hist, JSON.stringify({ at: new Date().toISOString(), n: t.n, groups: t.groups, shares: t.shares
|
|
101
|
-
const fight = found.filter((f) => f.kind === 'fighting it'), blind = found.filter((f) => f.kind === 'blind spot');
|
|
102
|
-
if (fight.length || blind.length) out.push('', ' Patterns to work on:');
|
|
103
|
-
for (const f of fight) out.push(` Fighting it: ${title(f.spoke)}. You push on it (${f.push}%), and the agent still ${f.says} in ${f.pct}% of turns (${f.count} of ${f.of}).`, f.has ? ' You seem to have a written rule on this already, and it is not holding. Make it specific, or protect it in the contract.' : ` Try: ${f.rule}`);
|
|
104
|
-
for (const f of blind) out.push(` Not said: ${title(f.spoke)}. The agent ${f.says} in ${f.pct}% of turns (${f.count} of ${f.of}), and only ${f.push}% of your pushes touch it. Is that what you want?`, ` Try: ${f.rule}`);
|
|
105
|
-
const covered = found.filter((f) => f.kind === 'covered');
|
|
106
|
-
if (covered.length) out.push('', ` Written down already, so left out: ${covered.map((f) => title(f.spoke)).join(', ')}. Matched by keyword, so check they say what you mean.`);
|
|
107
|
-
if (found.length) out.push('', ' What the agent did covers your last 90 days, not counting scratch work. Direction, scope, correctness and honesty have no behaviour signal, so they show only what you said.');
|
|
90
|
+
if (!t.spokes) out.push('', ` Too few messages for the 11 spokes (${t.n} of ${MIN_SPOKES}), so this shows the 4 groups.`);
|
|
91
|
+
appendFileSync(p.hist, JSON.stringify({ at: new Date().toISOString(), n: t.n, groups: t.groups, shares: t.shares }) + '\n');
|
|
108
92
|
out.push('', ` Card: ${p.svg}`);
|
|
93
|
+
if (args.includes('--png')) { const png = pngCard(p.svg); out.push(png ? ` Image to share: ${png}` : ' No image tool here (rsvg-convert or Chrome): open the card in a browser to save an image.'); }
|
|
109
94
|
return out.join('\n');
|
|
110
95
|
}
|
package/status.mjs
CHANGED
|
@@ -12,6 +12,7 @@ import { execFileSync } from 'node:child_process';
|
|
|
12
12
|
import { readFileSync, existsSync } from 'node:fs';
|
|
13
13
|
import { join, dirname, resolve, basename } from 'node:path';
|
|
14
14
|
import { fileURLToPath } from 'node:url';
|
|
15
|
+
import { repoState } from './repo-state.mjs';
|
|
15
16
|
|
|
16
17
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
17
18
|
const flag = (n, d) => { const i = process.argv.indexOf(n); return i > -1 ? (process.argv[i + 1] ?? true) : d; };
|
|
@@ -27,26 +28,19 @@ const ICON = { verified: '✓', gamed: '🚩', regression: '✗', inconclusive:
|
|
|
27
28
|
// --- 1) the outcome record: what the check has done here -------------------
|
|
28
29
|
// results.json holds the all-time catch tally (gamed + regression, never trimmed); activity.jsonl is
|
|
29
30
|
// the recent per-check feed (verified included, capped ~200). We read both and keep each honest.
|
|
30
|
-
const results
|
|
31
|
+
const { results, caught: catchesAllTime, state: kind, steer, armed, hasConfig } = repoState(repo);
|
|
31
32
|
const feed = readLines(join(repo, '.sawbuck', 'activity.jsonl')).map((l) => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean);
|
|
32
33
|
const tally = { verified: 0, gamed: 0, regression: 0, inconclusive: 0, allowed: 0, mandate: 0 };
|
|
33
34
|
for (const e of feed) if (e && tally[e.kind] != null) tally[e.kind]++;
|
|
34
35
|
const seen = feed.length;
|
|
35
|
-
const catchesAllTime = (results.regressions || 0) + (results.gamed || 0) + (results.mandates || 0);
|
|
36
36
|
const latest = results.latest || (feed.length ? feed[feed.length - 1] : null);
|
|
37
37
|
const since = feed.length ? (feed[0].at || '').slice(0, 10) : null;
|
|
38
|
-
// the steer rate, if the reins judge has been scoring turns here (an OFFLINE read of its file; the
|
|
38
|
+
// the steer rate, if the reins judge has been scoring turns here (an OFFLINE read of its file, in repoState; the
|
|
39
39
|
// recent-window average over roughly the last twenty scored turns, as the band and statusline show it)
|
|
40
|
-
const steerRec = readJSON(join(repo, '.sawbuck', 'steer.json'));
|
|
41
|
-
const steer = steerRec && steerRec.scored ? { rate: Math.round(((steerRec.ema != null ? steerRec.ema : steerRec.steeringRate) || 0) * 100), scored: steerRec.scored } : null;
|
|
42
40
|
|
|
43
|
-
// --- 2) the armed state: what it can measure here (
|
|
41
|
+
// --- 2) the armed state: what it can measure here (shared with the statusline) ---
|
|
44
42
|
const isGit = !!existsSync(join(repo, '.git'));
|
|
45
|
-
const
|
|
46
|
-
const watching = existsSync(join(repo, '.sawbuck', 'state.json'));
|
|
47
|
-
const armed = hasConfig || existsSync(join(repo, 'package.json'));
|
|
48
|
-
const mem = existsSync(join(repo, 'LOGBOOK.md')) || existsSync(join(repo, 'events.jsonl'));
|
|
49
|
-
const state = watching ? 'watching' : armed ? 'ready' : mem ? 'memory' : 'unarmed';
|
|
43
|
+
const state = kind || 'unarmed';
|
|
50
44
|
|
|
51
45
|
// --- 3) the live debt ledger: best-effort, bounded -------------------------
|
|
52
46
|
// A full-repo HEAD grep + gating; fast on normal repos, can be heavy on a giant one. Bounded so
|
|
@@ -143,7 +137,7 @@ if (!isGit) {
|
|
|
143
137
|
} else if (state === 'watching') {
|
|
144
138
|
out.push(` Measuring here · ${hasConfig ? '✓ wired in with a contract (promptwheel.config.json)' : '✓ wired in (zero-config auto-detect)'}. It verifies each commit/turn automatically.`);
|
|
145
139
|
} else if (state === 'ready') {
|
|
146
|
-
out.push(` Measuring here · checkable but not wired yet. Try it now: \`sawbuck check\` • make it automatic: \`sawbuck attach
|
|
140
|
+
out.push(` Measuring here · checkable but not wired yet. Try it now: \`sawbuck check\` • make it automatic: \`sawbuck attach\``);
|
|
147
141
|
} else {
|
|
148
142
|
out.push(` Measuring here · ${hasConfig ? 'a contract exists' : 'no test command detected yet'}. \`sawbuck init\` to set up the check, or \`sawbuck context\` for the history alone.`);
|
|
149
143
|
}
|
package/steers.mjs
CHANGED
|
@@ -37,7 +37,7 @@ export function steersReport({ json = false, home } = {}) {
|
|
|
37
37
|
const groups = groupSteers(steers);
|
|
38
38
|
const repeated = groups.filter((g) => g.n >= 3 && g.sessions >= 2);
|
|
39
39
|
if (json) return JSON.stringify({ steers: steers.length, scored: stats.scored, unscored: stats.lost, repeated });
|
|
40
|
-
if (!steers.length && !stats.scored) return '🐴 sawbuck: no steers recorded
|
|
40
|
+
if (!steers.length && !stats.scored) return '🐴 sawbuck: no steers recorded. The plugin\'s live judge is off by default; /sawbuck-reins:rules reads your history instead.';
|
|
41
41
|
const out = [`🐴 ${steers.length} steers kept, from ${stats.scored} replies scored${stats.lost ? ` (${stats.lost} could not be scored, so this undercounts)` : ''}. Measured, not estimated.`];
|
|
42
42
|
if (repeated.length) {
|
|
43
43
|
out.push('', 'You keep saying:');
|
package/dispatch.mjs
DELETED
|
@@ -1,34 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
// dispatch.mjs, the dispatch: a compact proof-of-work receipt from the results.
|
|
3
|
-
// A receipt, not an ad. Silent when nothing's been recorded to show (unless --force).
|
|
4
|
-
// Zero-dep. Usage: node dispatch.mjs [--repo <path>] [--force] [--json]
|
|
5
|
-
|
|
6
|
-
import { readFileSync } from 'node:fs';
|
|
7
|
-
import { join } from 'node:path';
|
|
8
|
-
|
|
9
|
-
const flag = (n, d) => { const i = process.argv.indexOf(n); return i > -1 ? (process.argv[i + 1] ?? true) : d; };
|
|
10
|
-
const repo = flag('--repo', process.cwd());
|
|
11
|
-
const force = process.argv.includes('--force');
|
|
12
|
-
|
|
13
|
-
let rec = null;
|
|
14
|
-
try { rec = JSON.parse(readFileSync(join(repo, '.sawbuck', 'results.json'), 'utf8')); } catch {}
|
|
15
|
-
|
|
16
|
-
const total = rec ? (rec.regressions || 0) + (rec.gamed || 0) + (rec.mandates || 0) : 0;
|
|
17
|
-
if (!rec || (!total && !force)) process.exit(0); // nothing recorded → stay quiet
|
|
18
|
-
if (process.argv.includes('--json')) { console.log(JSON.stringify(rec)); process.exit(0); }
|
|
19
|
-
|
|
20
|
-
const n = (x, s) => `${x} ${s}${x === 1 ? '' : 's'}`;
|
|
21
|
-
const L = rec.latest;
|
|
22
|
-
// Attribute the latest catch to the agent that made it (the Claude hook passes 'claude').
|
|
23
|
-
let head;
|
|
24
|
-
if (L && L.kind === 'gamed') head = L.by ? `🐴 Sawbuck caught ${L.by} gaming a metric` : `🐴 Sawbuck caught a gamed change`;
|
|
25
|
-
else if (L && L.kind === 'regression') head = L.by ? `🐴 Sawbuck caught a regression from ${L.by}` : `🐴 Sawbuck caught a regression`;
|
|
26
|
-
else head = `🐴 Sawbuck`;
|
|
27
|
-
let line = head;
|
|
28
|
-
if (L) {
|
|
29
|
-
const why = String(L.reason || L.kind).replace(/^a change here was gamed,\s*/i, '').replace(/^a change here\s+/i, '');
|
|
30
|
-
line += `\n ${L.file}: ${why}`;
|
|
31
|
-
}
|
|
32
|
-
const tally = [rec.gamed ? n(rec.gamed, 'gamed change') : '', rec.regressions ? n(rec.regressions, 'regression') : ''].filter(Boolean).join(', ');
|
|
33
|
-
if (tally) line += `\n (${tally} caught here so far)`;
|
|
34
|
-
console.log(line);
|
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);
|