backpass 0.1.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/LICENSE +21 -0
- package/README.md +406 -0
- package/bin/backpass.js +4 -0
- package/package.json +62 -0
- package/src/acpx.js +576 -0
- package/src/agents.js +389 -0
- package/src/analyze.js +289 -0
- package/src/apply/lavish.js +128 -0
- package/src/apply/terminal.js +119 -0
- package/src/apply/writer.js +101 -0
- package/src/bootstrap.js +74 -0
- package/src/cli.js +261 -0
- package/src/commands/analyze.js +88 -0
- package/src/commands/apply.js +103 -0
- package/src/commands/bootstrap.js +172 -0
- package/src/commands/init.js +59 -0
- package/src/commands/propose.js +136 -0
- package/src/commands/run.js +95 -0
- package/src/commands/scan.js +90 -0
- package/src/commands/status.js +143 -0
- package/src/commands/usage.js +25 -0
- package/src/config.js +249 -0
- package/src/diff.js +305 -0
- package/src/discovery/adapters/claude.js +77 -0
- package/src/discovery/adapters/codex.js +162 -0
- package/src/discovery/adapters/cursor-cli.js +109 -0
- package/src/discovery/adapters/cursor-ide.js +130 -0
- package/src/discovery/adapters/grok.js +107 -0
- package/src/discovery/adapters/opencode.js +151 -0
- package/src/discovery/adapters/pi.js +87 -0
- package/src/discovery/adapters/shared.js +195 -0
- package/src/discovery/adapters/sqlite.js +50 -0
- package/src/discovery/association.js +100 -0
- package/src/discovery/index.js +226 -0
- package/src/discovery/self.js +62 -0
- package/src/distill.js +182 -0
- package/src/fold.js +214 -0
- package/src/gap-ledger.js +174 -0
- package/src/logger.js +74 -0
- package/src/memory.js +244 -0
- package/src/progress.js +29 -0
- package/src/prompts/analysis.md +48 -0
- package/src/prompts/annotate.md +48 -0
- package/src/prompts/synthesis.md +98 -0
- package/src/prompts.js +36 -0
- package/src/proposal.js +430 -0
- package/src/redact.js +36 -0
- package/src/repo.js +118 -0
- package/src/sample.js +99 -0
- package/src/skills.js +207 -0
- package/src/state.js +202 -0
- package/src/subprocess.js +47 -0
- package/src/synthesize.js +287 -0
- package/src/tokens.js +48 -0
- package/src/tui/index.js +336 -0
- package/src/tui/render.js +487 -0
- package/src/tui/term.js +130 -0
- package/src/tui/theme.js +111 -0
- package/src/workspace.js +162 -0
- package/templates/apply.html +928 -0
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* `node:sqlite` is still flagged experimental, so Node prints a warning on first use.
|
|
5
|
+
* backpass opens several read-only stores; the warning is noise the user cannot act on,
|
|
6
|
+
* so it is filtered here (and only here) rather than suppressed process-wide.
|
|
7
|
+
*/
|
|
8
|
+
let DatabaseSync = null;
|
|
9
|
+
let loadError = null;
|
|
10
|
+
|
|
11
|
+
function filterExperimentalWarning() {
|
|
12
|
+
const listeners = process.listeners("warning");
|
|
13
|
+
process.removeAllListeners("warning");
|
|
14
|
+
process.on("warning", (w) => {
|
|
15
|
+
if (w.name === "ExperimentalWarning" && /SQLite/i.test(w.message)) return;
|
|
16
|
+
for (const listener of listeners) listener(w);
|
|
17
|
+
if (!listeners.length) console.error(w.stack || String(w));
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async function loadDriver() {
|
|
22
|
+
if (DatabaseSync || loadError) return;
|
|
23
|
+
filterExperimentalWarning();
|
|
24
|
+
try {
|
|
25
|
+
({ DatabaseSync } = await import("node:sqlite"));
|
|
26
|
+
} catch (err) {
|
|
27
|
+
loadError = err;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Open a SQLite file read-only. Returns null (never throws) when the store is missing,
|
|
33
|
+
* locked, or the driver is unavailable - discovery is fail-soft per harness.
|
|
34
|
+
*/
|
|
35
|
+
export async function openReadOnly(file) {
|
|
36
|
+
if (!fs.existsSync(file)) return null;
|
|
37
|
+
await loadDriver();
|
|
38
|
+
if (!DatabaseSync) {
|
|
39
|
+
throw new Error(`node:sqlite unavailable (${loadError?.message || "unknown"}); Node >= 22.5 is required`);
|
|
40
|
+
}
|
|
41
|
+
return new DatabaseSync(file, { readOnly: true });
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function safeJsonParse(text) {
|
|
45
|
+
try {
|
|
46
|
+
return JSON.parse(text);
|
|
47
|
+
} catch {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
import { normalizeRemote } from "../repo.js";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Three-tier repo/worktree association (design section 2.1).
|
|
8
|
+
*
|
|
9
|
+
* tier 1 deterministic - transcript cwd is (or sits under) a live worktree path
|
|
10
|
+
* tier 2 deterministic - a recorded git remote matches one of the repo's remotes;
|
|
11
|
+
* survives worktree deletion (codex, grok)
|
|
12
|
+
* tier 3 best-effort - dead cwd whose last segment is the repo dir name, or that
|
|
13
|
+
* matches a user-supplied worktree glob; excluded by --strict
|
|
14
|
+
*
|
|
15
|
+
* Returns null when the transcript belongs to some other repo.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
function realpathOrResolve(p) {
|
|
19
|
+
try {
|
|
20
|
+
return fs.realpathSync(p);
|
|
21
|
+
} catch {
|
|
22
|
+
return path.resolve(p);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function isUnder(child, parent) {
|
|
27
|
+
if (child === parent) return true;
|
|
28
|
+
return child.startsWith(parent.endsWith(path.sep) ? parent : parent + path.sep);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Glob support is intentionally minimal: `*` (one segment) and `**` (many). */
|
|
32
|
+
export function globToRegExp(glob) {
|
|
33
|
+
const expanded = glob.startsWith("~/") ? path.join(process.env.HOME || "", glob.slice(2)) : glob;
|
|
34
|
+
let out = "";
|
|
35
|
+
for (let i = 0; i < expanded.length; i += 1) {
|
|
36
|
+
const c = expanded[i];
|
|
37
|
+
if (c === "*") {
|
|
38
|
+
if (expanded[i + 1] === "*") {
|
|
39
|
+
out += ".*";
|
|
40
|
+
i += 1;
|
|
41
|
+
if (expanded[i + 1] === "/") i += 1;
|
|
42
|
+
} else {
|
|
43
|
+
out += "[^/]*";
|
|
44
|
+
}
|
|
45
|
+
} else if ("\\^$.|?+()[]{}".includes(c)) {
|
|
46
|
+
out += `\\${c}`;
|
|
47
|
+
} else {
|
|
48
|
+
out += c;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return new RegExp(`^${out}/?$`);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function associate({ cwd, remotes = [], gitRoot = null }, repo, options = {}) {
|
|
55
|
+
const globs = options.worktreeGlobs || [];
|
|
56
|
+
const candidates = [cwd, gitRoot].filter(Boolean);
|
|
57
|
+
|
|
58
|
+
// Tier 1 - live path under a known worktree.
|
|
59
|
+
for (const candidate of candidates) {
|
|
60
|
+
const real = realpathOrResolve(candidate);
|
|
61
|
+
for (const worktree of repo.worktrees) {
|
|
62
|
+
if (real === worktree) {
|
|
63
|
+
return { tier: 1, confidence: "exact", reason: `cwd is worktree ${worktree}` };
|
|
64
|
+
}
|
|
65
|
+
if (isUnder(real, worktree)) {
|
|
66
|
+
return { tier: 1, confidence: "nested", reason: `cwd is inside worktree ${worktree}` };
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Tier 2 - recorded remote, valid even when the worktree is long gone.
|
|
72
|
+
const repoRemotes = new Set(repo.remotes);
|
|
73
|
+
for (const remote of remotes) {
|
|
74
|
+
const norm = normalizeRemote(remote);
|
|
75
|
+
if (norm && repoRemotes.has(norm)) {
|
|
76
|
+
return { tier: 2, confidence: "remote", reason: `recorded remote ${norm}` };
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Tier 3 - best-effort, only for paths that no longer exist.
|
|
81
|
+
for (const candidate of candidates) {
|
|
82
|
+
const resolved = path.resolve(candidate);
|
|
83
|
+
if (fs.existsSync(resolved)) continue;
|
|
84
|
+
if (path.basename(resolved.replace(/\/+$/, "")) === repo.name) {
|
|
85
|
+
return { tier: 3, confidence: "path", reason: `dead path ending in /${repo.name}` };
|
|
86
|
+
}
|
|
87
|
+
for (const glob of globs) {
|
|
88
|
+
if (globToRegExp(glob).test(resolved)) {
|
|
89
|
+
return { tier: 3, confidence: "glob", reason: `dead path matches glob ${glob}` };
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function passesStrict(association, strict) {
|
|
98
|
+
if (!association) return false;
|
|
99
|
+
return strict ? association.tier <= 2 : true;
|
|
100
|
+
}
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
import * as claude from "./adapters/claude.js";
|
|
2
|
+
import * as codex from "./adapters/codex.js";
|
|
3
|
+
import * as pi from "./adapters/pi.js";
|
|
4
|
+
import * as grok from "./adapters/grok.js";
|
|
5
|
+
import * as opencode from "./adapters/opencode.js";
|
|
6
|
+
import * as cursorCli from "./adapters/cursor-cli.js";
|
|
7
|
+
import * as cursorIde from "./adapters/cursor-ide.js";
|
|
8
|
+
|
|
9
|
+
import { associate, passesStrict } from "./association.js";
|
|
10
|
+
import { isSelfSession } from "./self.js";
|
|
11
|
+
import { sinceCutoff } from "../config.js";
|
|
12
|
+
import { emitProgress } from "../progress.js";
|
|
13
|
+
import { warn } from "../logger.js";
|
|
14
|
+
|
|
15
|
+
export const ADAPTERS = {
|
|
16
|
+
claude,
|
|
17
|
+
codex,
|
|
18
|
+
pi,
|
|
19
|
+
grok,
|
|
20
|
+
opencode,
|
|
21
|
+
cursor: cursorCli,
|
|
22
|
+
"cursor-ide": cursorIde,
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export function getAdapter(harness) {
|
|
26
|
+
return ADAPTERS[harness] || null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Discovery (design section 2).
|
|
31
|
+
*
|
|
32
|
+
* For file-backed stores the expensive step is reading each transcript's header, so
|
|
33
|
+
* results are memoised in `.backpass/scan-cache.json` keyed by path + mtime + size.
|
|
34
|
+
* Re-scans are then O(new files) - which matters: codex alone had 10,317 rollouts on
|
|
35
|
+
* the machine this was designed against.
|
|
36
|
+
*
|
|
37
|
+
* SQLite-backed stores (opencode, cursor IDE) answer the same question with one indexed
|
|
38
|
+
* query, so they skip the cache entirely.
|
|
39
|
+
*
|
|
40
|
+
* Every harness is fail-soft: a store that is missing, unreadable, or has drifted into
|
|
41
|
+
* an unrecognised format produces a named warning and is skipped, never a failed run.
|
|
42
|
+
*
|
|
43
|
+
* Sessions backpass itself created (its analysis and synthesis calls, which the harness
|
|
44
|
+
* files under this repo's cwd) are excluded after association and counted in
|
|
45
|
+
* `perHarness[h].self` - see `./self.js`.
|
|
46
|
+
*/
|
|
47
|
+
export async function discoverTranscripts({ repo, config, strict = false, harnesses = null, now = Date.now() }) {
|
|
48
|
+
const cutoffMs = sinceCutoff(config.discovery.since, now);
|
|
49
|
+
const selected = harnesses || config.discovery.harnesses;
|
|
50
|
+
const cache = config.state.readScanCache();
|
|
51
|
+
|
|
52
|
+
const transcripts = [];
|
|
53
|
+
const perHarness = {};
|
|
54
|
+
let cacheDirty = false;
|
|
55
|
+
|
|
56
|
+
emitProgress("discover:start", { harnesses: selected.filter((h) => getAdapter(h)) });
|
|
57
|
+
|
|
58
|
+
for (const harness of selected) {
|
|
59
|
+
const adapter = getAdapter(harness);
|
|
60
|
+
if (!adapter) {
|
|
61
|
+
warn(`no adapter for harness "${harness}" - skipped`);
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const stats = { scanned: 0, matched: 0, cached: 0, skipped: 0, self: 0, error: null };
|
|
66
|
+
perHarness[harness] = stats;
|
|
67
|
+
emitProgress("discover:harness:start", { harness });
|
|
68
|
+
|
|
69
|
+
try {
|
|
70
|
+
const found = adapter.discover
|
|
71
|
+
? await discoverDirect(adapter, { repo, config, cutoffMs, strict, stats })
|
|
72
|
+
: discoverFiles(adapter, {
|
|
73
|
+
repo,
|
|
74
|
+
config,
|
|
75
|
+
cutoffMs,
|
|
76
|
+
strict,
|
|
77
|
+
stats,
|
|
78
|
+
cache,
|
|
79
|
+
markDirty: () => {
|
|
80
|
+
cacheDirty = true;
|
|
81
|
+
},
|
|
82
|
+
});
|
|
83
|
+
transcripts.push(...found);
|
|
84
|
+
stats.matched = found.length;
|
|
85
|
+
emitProgress("discover:harness:done", {
|
|
86
|
+
harness,
|
|
87
|
+
scanned: stats.scanned,
|
|
88
|
+
cached: stats.cached,
|
|
89
|
+
matched: stats.matched,
|
|
90
|
+
self: stats.self,
|
|
91
|
+
tiers: tierCounts(found),
|
|
92
|
+
});
|
|
93
|
+
} catch (err) {
|
|
94
|
+
stats.error = err.message;
|
|
95
|
+
warn(`${harness}: transcript store unreadable (${err.message}) - harness skipped`);
|
|
96
|
+
emitProgress("discover:harness:done", { harness, error: err.message });
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (cacheDirty) config.state.writeScanCache(cache);
|
|
101
|
+
|
|
102
|
+
transcripts.sort((a, b) => (b.mtimeMs || 0) - (a.mtimeMs || 0));
|
|
103
|
+
emitProgress("discover:done", { total: transcripts.length });
|
|
104
|
+
return { transcripts, perHarness, cutoffMs };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function tierCounts(found) {
|
|
108
|
+
const tiers = {};
|
|
109
|
+
for (const transcript of found) {
|
|
110
|
+
const tier = transcript.association?.tier;
|
|
111
|
+
if (tier) tiers[tier] = (tiers[tier] || 0) + 1;
|
|
112
|
+
}
|
|
113
|
+
return tiers;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async function discoverDirect(adapter, { repo, config, cutoffMs, strict, stats }) {
|
|
117
|
+
const rows = await adapter.discover({ cutoffMs, repo, config });
|
|
118
|
+
const out = [];
|
|
119
|
+
for (const row of rows) {
|
|
120
|
+
stats.scanned += 1;
|
|
121
|
+
const association = associate({ cwd: row.cwd, remotes: row.remotes || [], gitRoot: row.gitRoot }, repo, {
|
|
122
|
+
worktreeGlobs: config.discovery.worktreeGlobs,
|
|
123
|
+
});
|
|
124
|
+
if (!passesStrict(association, strict)) {
|
|
125
|
+
stats.skipped += 1;
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
const transcript = toTranscript(adapter, row, association, row.id);
|
|
129
|
+
if (isSelfSession(transcript)) {
|
|
130
|
+
stats.self += 1;
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
out.push(transcript);
|
|
134
|
+
}
|
|
135
|
+
return out;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function discoverFiles(adapter, { repo, config, cutoffMs, strict, stats, cache, markDirty }) {
|
|
139
|
+
const candidates = adapter.enumerate({ cutoffMs, repo, config });
|
|
140
|
+
const out = [];
|
|
141
|
+
|
|
142
|
+
for (const candidate of candidates) {
|
|
143
|
+
if (cutoffMs && candidate.mtimeMs < cutoffMs) continue;
|
|
144
|
+
stats.scanned += 1;
|
|
145
|
+
// Large stores (codex holds 10k+ session files) get a live scan tick; the
|
|
146
|
+
// classify loop is synchronous, so this is the only paint opportunity.
|
|
147
|
+
if (stats.scanned % 25 === 0) {
|
|
148
|
+
emitProgress("discover:harness:tick", {
|
|
149
|
+
harness: adapter.name,
|
|
150
|
+
scanned: stats.scanned,
|
|
151
|
+
total: candidates.length,
|
|
152
|
+
matched: out.length,
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const cacheKey = `${adapter.name}:${candidate.key}`;
|
|
157
|
+
const cached = cache.entries[cacheKey];
|
|
158
|
+
let descriptor;
|
|
159
|
+
|
|
160
|
+
if (cached && cached.mtimeMs === candidate.mtimeMs && cached.bytes === candidate.bytes) {
|
|
161
|
+
stats.cached += 1;
|
|
162
|
+
descriptor = cached.descriptor;
|
|
163
|
+
} else {
|
|
164
|
+
descriptor = adapter.classify(candidate, { repo, config }) || null;
|
|
165
|
+
cache.entries[cacheKey] = { mtimeMs: candidate.mtimeMs, bytes: candidate.bytes, descriptor };
|
|
166
|
+
markDirty();
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (!descriptor) {
|
|
170
|
+
stats.skipped += 1;
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const association = associate(
|
|
175
|
+
{ cwd: descriptor.cwd, remotes: descriptor.remotes || [], gitRoot: descriptor.gitRoot },
|
|
176
|
+
repo,
|
|
177
|
+
{ worktreeGlobs: config.discovery.worktreeGlobs },
|
|
178
|
+
);
|
|
179
|
+
if (!passesStrict(association, strict)) {
|
|
180
|
+
stats.skipped += 1;
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const transcript = toTranscript(adapter, { ...candidate, ...descriptor }, association, descriptor.id);
|
|
185
|
+
// backpass's own acpx runs land in this store under this cwd; drop them here so
|
|
186
|
+
// they never reach sampling or analysis (see ./self.js).
|
|
187
|
+
if (isSelfSession(transcript)) {
|
|
188
|
+
stats.self += 1;
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
out.push(transcript);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
return out;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function toTranscript(adapter, row, association, id) {
|
|
198
|
+
return {
|
|
199
|
+
harness: adapter.name,
|
|
200
|
+
id: `${adapter.name}-${id}`,
|
|
201
|
+
nativeId: id,
|
|
202
|
+
path: row.path,
|
|
203
|
+
cwd: row.cwd || null,
|
|
204
|
+
gitBranch: row.gitBranch || null,
|
|
205
|
+
title: row.title || null,
|
|
206
|
+
model: row.model || null,
|
|
207
|
+
startedAt: row.startedAt || null,
|
|
208
|
+
mtimeMs: row.mtimeMs || 0,
|
|
209
|
+
bytes: row.bytes || 0,
|
|
210
|
+
experimental: Boolean(adapter.experimental),
|
|
211
|
+
association,
|
|
212
|
+
extra: row.extra || {},
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** Read one transcript through its adapter and normalize it to distiller events. */
|
|
217
|
+
export async function readTranscript(transcript) {
|
|
218
|
+
const adapter = getAdapter(transcript.harness);
|
|
219
|
+
if (!adapter) throw new Error(`no adapter for harness ${transcript.harness}`);
|
|
220
|
+
const result = await adapter.read(transcript);
|
|
221
|
+
return {
|
|
222
|
+
events: result.events || [],
|
|
223
|
+
model: result.model || transcript.model || null,
|
|
224
|
+
rawPath: adapter.rawPath ? adapter.rawPath(transcript) : transcript.path,
|
|
225
|
+
};
|
|
226
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
|
|
3
|
+
import { SELF_SESSION_SENTINEL } from "../prompts.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Self-session exclusion.
|
|
7
|
+
*
|
|
8
|
+
* backpass's own model calls run through acpx with `--cwd <repo root>`, so the harness
|
|
9
|
+
* files each one in its normal store under this repo's cwd - a tier-1 exact match for
|
|
10
|
+
* discovery. Left alone, the next run would analyze backpass talking to itself (and a
|
|
11
|
+
* synthesis session quotes the memory file verbatim, so the "evidence" would be
|
|
12
|
+
* backpass's own text). Every prompt backpass writes begins with
|
|
13
|
+
* `SELF_SESSION_SENTINEL`; a transcript whose first user message starts with it is
|
|
14
|
+
* backpass's and is dropped here, before sampling, so it never takes a slot or a
|
|
15
|
+
* model call.
|
|
16
|
+
*
|
|
17
|
+
* The check reads only the head of the file and keys on the JSON-encoded user text as
|
|
18
|
+
* every file-backed harness records it (`"text":"..."` / `"content":"..."` /
|
|
19
|
+
* `"message":"..."`). SQLite-backed stores (opencode, cursor IDE) have no file to
|
|
20
|
+
* inspect and acpx does not drive them, so they are passed through.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
const HEAD_BYTES = 256 * 1024;
|
|
24
|
+
|
|
25
|
+
const SENTINEL_PATTERN = new RegExp(`"(?:text|content|message)":"${escapeRegExp(jsonInner(SELF_SESSION_SENTINEL))}`);
|
|
26
|
+
|
|
27
|
+
function jsonInner(text) {
|
|
28
|
+
return JSON.stringify(text).slice(1, -1);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function escapeRegExp(text) {
|
|
32
|
+
return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** True when `text` (the head of a transcript file) opens a user message with the sentinel. */
|
|
36
|
+
export function headHasSelfSentinel(text) {
|
|
37
|
+
return SENTINEL_PATTERN.test(text || "");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* @param {{ path?: string | null }} transcript
|
|
42
|
+
* @returns {boolean}
|
|
43
|
+
*/
|
|
44
|
+
export function isSelfSession(transcript) {
|
|
45
|
+
const file = transcript?.path;
|
|
46
|
+
if (!file) return false;
|
|
47
|
+
let fd;
|
|
48
|
+
try {
|
|
49
|
+
fd = fs.openSync(file, "r");
|
|
50
|
+
const stat = fs.fstatSync(fd);
|
|
51
|
+
if (!stat.isFile()) return false;
|
|
52
|
+
const length = Math.min(stat.size, HEAD_BYTES);
|
|
53
|
+
const buffer = Buffer.alloc(length);
|
|
54
|
+
fs.readSync(fd, buffer, 0, length, 0);
|
|
55
|
+
return headHasSelfSentinel(buffer.toString("utf8"));
|
|
56
|
+
} catch {
|
|
57
|
+
// Unreadable here means unreadable for the adapter too; let the adapter report it.
|
|
58
|
+
return false;
|
|
59
|
+
} finally {
|
|
60
|
+
if (fd !== undefined) fs.closeSync(fd);
|
|
61
|
+
}
|
|
62
|
+
}
|
package/src/distill.js
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import { redact } from "./redact.js";
|
|
2
|
+
import { estimateTokens } from "./tokens.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Stage 0 of the pipeline (design section 3): turn a raw session log into a distilled
|
|
6
|
+
* markdown trace, with no model involved.
|
|
7
|
+
*
|
|
8
|
+
* The point is cheap-first analysis. Raw transcripts on this machine run to megabytes,
|
|
9
|
+
* almost all of it tool-call noise. Distillation keeps what carries the loss signal -
|
|
10
|
+
* what the human asked, what the agent said, and a one-line shape of each tool call -
|
|
11
|
+
* and drops the rest. The trace ends with the raw transcript path so the analysis agent
|
|
12
|
+
* can open the original when (and only when) a claim needs it.
|
|
13
|
+
*
|
|
14
|
+
* Adapters produce a normalized event stream; everything below is shared.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const TOOL_INPUT_CHARS = 160;
|
|
18
|
+
const TOOL_OUTPUT_CHARS = 200;
|
|
19
|
+
const MESSAGE_CHARS = 6000;
|
|
20
|
+
|
|
21
|
+
function oneLine(text, limit) {
|
|
22
|
+
const flat = String(text ?? "")
|
|
23
|
+
.replace(/\s+/g, " ")
|
|
24
|
+
.trim();
|
|
25
|
+
return flat.length > limit ? `${flat.slice(0, limit)}...` : flat;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function clampMessage(text) {
|
|
29
|
+
const trimmed = String(text ?? "").trim();
|
|
30
|
+
if (trimmed.length <= MESSAGE_CHARS) return trimmed;
|
|
31
|
+
const head = trimmed.slice(0, MESSAGE_CHARS - 1200);
|
|
32
|
+
const tail = trimmed.slice(-1000);
|
|
33
|
+
return `${head}\n\n[... ${trimmed.length - MESSAGE_CHARS} chars elided ...]\n\n${tail}`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function describeToolInput(input) {
|
|
37
|
+
if (input === null || input === undefined) return "";
|
|
38
|
+
if (typeof input === "string") return oneLine(input, TOOL_INPUT_CHARS);
|
|
39
|
+
// Prefer the field a human would recognise for the common tools.
|
|
40
|
+
for (const key of ["command", "cmd", "file_path", "path", "pattern", "query", "url", "description"]) {
|
|
41
|
+
if (typeof input[key] === "string" && input[key].trim()) {
|
|
42
|
+
return oneLine(input[key], TOOL_INPUT_CHARS);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
try {
|
|
46
|
+
return oneLine(JSON.stringify(input), TOOL_INPUT_CHARS);
|
|
47
|
+
} catch {
|
|
48
|
+
return "";
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function describeToolResult(result) {
|
|
53
|
+
if (result === null || result === undefined) return "";
|
|
54
|
+
const text = typeof result === "string" ? result : safeStringify(result);
|
|
55
|
+
const bytes = Buffer.byteLength(text, "utf8");
|
|
56
|
+
const summary = oneLine(text, TOOL_OUTPUT_CHARS);
|
|
57
|
+
return bytes > TOOL_OUTPUT_CHARS ? `${summary} (output ${formatBytes(bytes)}, truncated)` : summary;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function safeStringify(value) {
|
|
61
|
+
try {
|
|
62
|
+
return JSON.stringify(value);
|
|
63
|
+
} catch {
|
|
64
|
+
return String(value);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function formatBytes(bytes) {
|
|
69
|
+
if (bytes < 1024) return `${bytes}B`;
|
|
70
|
+
if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)}KB`;
|
|
71
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Injected harness scaffolding (system reminders, environment dumps, plugin lists) is
|
|
76
|
+
* not user intent and is identical across every session; keeping it would drown the
|
|
77
|
+
* real signal and inflate every prompt.
|
|
78
|
+
*/
|
|
79
|
+
const BOILERPLATE = [
|
|
80
|
+
/^<system-reminder>/,
|
|
81
|
+
/^<user_info>/,
|
|
82
|
+
/^<recommended_plugins>/,
|
|
83
|
+
/^<permissions instructions>/,
|
|
84
|
+
/^<env>/,
|
|
85
|
+
/^Caveat: The messages below were generated/,
|
|
86
|
+
];
|
|
87
|
+
|
|
88
|
+
export function isBoilerplate(text) {
|
|
89
|
+
const trimmed = String(text ?? "").trim();
|
|
90
|
+
if (!trimmed) return true;
|
|
91
|
+
return BOILERPLATE.some((re) => re.test(trimmed));
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Normalized event shapes accepted from adapters:
|
|
96
|
+
* { kind: 'message', role: 'user'|'assistant', text }
|
|
97
|
+
* { kind: 'tool', name, input, result, status }
|
|
98
|
+
* { kind: 'thinking', text } (dropped - reasoning traces are noise for this purpose)
|
|
99
|
+
*/
|
|
100
|
+
export function distill(events, meta, options = {}) {
|
|
101
|
+
const maxTraceTokens = options.maxTraceTokens ?? 12000;
|
|
102
|
+
const lines = [];
|
|
103
|
+
let userTurns = 0;
|
|
104
|
+
let assistantTurns = 0;
|
|
105
|
+
let toolCalls = 0;
|
|
106
|
+
let turn = 0;
|
|
107
|
+
|
|
108
|
+
for (const event of events) {
|
|
109
|
+
if (!event) continue;
|
|
110
|
+
if (event.kind === "message") {
|
|
111
|
+
const text = clampMessage(redact(event.text));
|
|
112
|
+
if (!text || isBoilerplate(text)) continue;
|
|
113
|
+
turn += 1;
|
|
114
|
+
if (event.role === "user") userTurns += 1;
|
|
115
|
+
else assistantTurns += 1;
|
|
116
|
+
lines.push(`### turn ${turn} · ${event.role}`);
|
|
117
|
+
lines.push(text);
|
|
118
|
+
lines.push("");
|
|
119
|
+
} else if (event.kind === "tool") {
|
|
120
|
+
toolCalls += 1;
|
|
121
|
+
const input = redact(describeToolInput(event.input));
|
|
122
|
+
const result = redact(describeToolResult(event.result));
|
|
123
|
+
const status = event.status && event.status !== "completed" ? ` [${event.status}]` : "";
|
|
124
|
+
const arrow = result ? ` -> ${result}` : "";
|
|
125
|
+
lines.push(`tool: ${event.name || "unknown"}${input ? ` ${JSON.stringify(input)}` : ""}${status}${arrow}`);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const header = [
|
|
130
|
+
`# session ${meta.id}`,
|
|
131
|
+
`harness: ${meta.harness}`,
|
|
132
|
+
meta.model ? `model: ${meta.model}` : null,
|
|
133
|
+
meta.startedAt ? `date: ${new Date(meta.startedAt).toISOString()}` : null,
|
|
134
|
+
meta.cwd ? `cwd: ${meta.cwd}` : null,
|
|
135
|
+
meta.gitBranch ? `branch: ${meta.gitBranch}` : null,
|
|
136
|
+
`association: tier ${meta.association?.tier} (${meta.association?.confidence})`,
|
|
137
|
+
"",
|
|
138
|
+
]
|
|
139
|
+
.filter((l) => l !== null)
|
|
140
|
+
.join("\n");
|
|
141
|
+
|
|
142
|
+
const footer = [
|
|
143
|
+
"",
|
|
144
|
+
"---",
|
|
145
|
+
`raw transcript: ${meta.rawPath}`,
|
|
146
|
+
"Tool calls above are one-line summaries and tool output is truncated. Open the raw",
|
|
147
|
+
"transcript only if a specific claim needs the full text.",
|
|
148
|
+
].join("\n");
|
|
149
|
+
|
|
150
|
+
const { body, elided } = capTrace(lines.join("\n").trim(), maxTraceTokens);
|
|
151
|
+
const trace = `${header}\n${body}\n${footer}\n`;
|
|
152
|
+
|
|
153
|
+
return {
|
|
154
|
+
trace,
|
|
155
|
+
stats: {
|
|
156
|
+
userTurns,
|
|
157
|
+
assistantTurns,
|
|
158
|
+
toolCalls,
|
|
159
|
+
elided,
|
|
160
|
+
distilledTokens: estimateTokens(trace),
|
|
161
|
+
},
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Long sessions still blow past what a cheap analysis pass should read. Keep the head
|
|
167
|
+
* (the task as stated) and the tail (how it actually ended) and elide the middle - the
|
|
168
|
+
* raw transcript path in the footer remains the escape hatch for anything in between.
|
|
169
|
+
*/
|
|
170
|
+
function capTrace(body, maxTraceTokens) {
|
|
171
|
+
if (estimateTokens(body) <= maxTraceTokens) return { body, elided: false };
|
|
172
|
+
const budgetChars = maxTraceTokens * 4;
|
|
173
|
+
const headChars = Math.floor(budgetChars * 0.45);
|
|
174
|
+
const tailChars = Math.floor(budgetChars * 0.45);
|
|
175
|
+
const head = body.slice(0, headChars);
|
|
176
|
+
const tail = body.slice(-tailChars);
|
|
177
|
+
const droppedTokens = estimateTokens(body) - estimateTokens(head) - estimateTokens(tail);
|
|
178
|
+
return {
|
|
179
|
+
body: `${head}\n\n[... middle of session elided: ~${droppedTokens} tokens. Open the raw transcript below if a claim needs it ...]\n\n${tail}`,
|
|
180
|
+
elided: true,
|
|
181
|
+
};
|
|
182
|
+
}
|