swarmdo 1.27.0 → 1.29.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/helpers/auto-memory-hook.mjs +5 -1
- package/.claude-plugin/README.md +3 -3
- package/.claude-plugin/docs/INSTALLATION.md +1 -1
- package/.claude-plugin/docs/PLUGIN_SUMMARY.md +1 -1
- package/.claude-plugin/docs/QUICKSTART.md +3 -3
- package/.claude-plugin/hooks/hooks.json +19 -1
- package/.claude-plugin/marketplace.json +17 -17
- package/.claude-plugin/plugin.json +7 -39
- package/.claude-plugin/scripts/install.sh +3 -3
- package/.claude-plugin/scripts/swarmdo-hook.sh +1 -1
- package/.claude-plugin/scripts/verify.sh +2 -2
- package/README.md +10 -8
- package/package.json +1 -1
- package/v3/@swarmdo/cli/.claude/helpers/auto-memory-hook.mjs +5 -1
- package/v3/@swarmdo/cli/bin/cli.js +38 -0
- package/v3/@swarmdo/cli/dist/src/apply/apply.d.ts +6 -0
- package/v3/@swarmdo/cli/dist/src/apply/apply.js +41 -8
- package/v3/@swarmdo/cli/dist/src/commands/apply.js +17 -4
- package/v3/@swarmdo/cli/dist/src/commands/compact-snapshot.d.ts +17 -0
- package/v3/@swarmdo/cli/dist/src/commands/compact-snapshot.js +133 -0
- package/v3/@swarmdo/cli/dist/src/commands/index.js +4 -3
- package/v3/@swarmdo/cli/dist/src/commands/pack.d.ts +8 -0
- package/v3/@swarmdo/cli/dist/src/commands/pack.js +28 -2
- package/v3/@swarmdo/cli/dist/src/compact/compact.js +17 -7
- package/v3/@swarmdo/cli/dist/src/compact-snapshot/compact-snapshot.d.ts +58 -0
- package/v3/@swarmdo/cli/dist/src/compact-snapshot/compact-snapshot.js +104 -0
- package/v3/@swarmdo/cli/dist/src/config-lint/lint.js +16 -2
- package/v3/@swarmdo/cli/dist/src/init/helpers-generator.js +33 -3
- package/v3/@swarmdo/cli/dist/src/redact/redact.js +1 -1
- package/v3/@swarmdo/cli/dist/src/sbom/sbom.js +5 -2
- package/v3/@swarmdo/cli/dist/src/swarmvector/model-prices.js +12 -2
- package/v3/@swarmdo/cli/dist/src/testreport/testreport.js +11 -2
- package/v3/@swarmdo/cli/dist/src/transcript/export.d.ts +5 -0
- package/v3/@swarmdo/cli/dist/src/transcript/export.js +8 -1
- package/v3/@swarmdo/cli/dist/src/usage/claude-pricing.d.ts +6 -2
- package/v3/@swarmdo/cli/dist/src/usage/claude-pricing.js +33 -4
- package/v3/@swarmdo/cli/dist/src/usage/transcript-usage.js +1 -1
- package/v3/@swarmdo/cli/package.json +2 -2
|
@@ -32,6 +32,7 @@ async function run(ctx) {
|
|
|
32
32
|
const root = ctx.cwd || process.cwd();
|
|
33
33
|
const dryRun = ctx.flags['dry-run'] === true;
|
|
34
34
|
const partial = ctx.flags.partial === true;
|
|
35
|
+
const strict = ctx.flags.strict === true;
|
|
35
36
|
// The parser may deliver --fuzz as a number or a string; accept both.
|
|
36
37
|
const fuzz = typeof ctx.flags.fuzz === 'number' ? ctx.flags.fuzz
|
|
37
38
|
: typeof ctx.flags.fuzz === 'string' ? parseInt(ctx.flags.fuzz, 10)
|
|
@@ -61,6 +62,7 @@ async function run(ctx) {
|
|
|
61
62
|
}
|
|
62
63
|
let totalApplied = 0;
|
|
63
64
|
let totalRejected = 0;
|
|
65
|
+
let totalAmbiguous = 0;
|
|
64
66
|
const writes = [];
|
|
65
67
|
for (const fp of patches) {
|
|
66
68
|
const file = targetFile(root, fp);
|
|
@@ -85,8 +87,16 @@ async function run(ctx) {
|
|
|
85
87
|
totalApplied += applied;
|
|
86
88
|
totalRejected += rejected;
|
|
87
89
|
const fuzzy = res.hunks.filter((h) => h.applied && (h.fuzzUsed ?? 0) > 0).length;
|
|
90
|
+
const ambiguous = res.hunks.filter((h) => h.applied && h.ambiguous).length;
|
|
91
|
+
totalAmbiguous += ambiguous;
|
|
88
92
|
const tag = rejected === 0 ? output.dim('ok') : output.bold(`${rejected} rejected`);
|
|
89
|
-
|
|
93
|
+
const ambTag = ambiguous ? output.error(` ⚠ ${ambiguous} ambiguous`) : '';
|
|
94
|
+
output.writeln(`${rel} ${applied}/${res.hunks.length} hunks${fuzzy ? output.dim(` (${fuzzy} fuzzy)`) : ''} ${tag}${ambTag}`);
|
|
95
|
+
if (ambiguous) {
|
|
96
|
+
for (const h of res.hunks.filter((x) => x.applied && x.ambiguous)) {
|
|
97
|
+
output.writeln(output.dim(` ⚠ hunk @ line ${(h.at ?? 0) + 1}: matched a block that also appears elsewhere — verify it landed on the intended one`));
|
|
98
|
+
}
|
|
99
|
+
}
|
|
90
100
|
if (rejected === 0 || partial) {
|
|
91
101
|
if (res.result !== source)
|
|
92
102
|
writes.push({ file, content: res.result });
|
|
@@ -97,9 +107,11 @@ async function run(ctx) {
|
|
|
97
107
|
fs.writeFileSync(w.file, w.content);
|
|
98
108
|
}
|
|
99
109
|
const verb = dryRun ? 'would apply' : 'applied';
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
110
|
+
const ambSummary = totalAmbiguous ? `, ${totalAmbiguous} ambiguous` : '';
|
|
111
|
+
output.writeln(output.dim(`${verb} ${totalApplied} hunks, ${totalRejected} rejected${ambSummary}${dryRun ? ' (dry run)' : ''}`));
|
|
112
|
+
// Exit 1 if anything was rejected — a CI/agent can branch on it. With --strict,
|
|
113
|
+
// an ambiguous match (possibly landed on the wrong duplicate) also fails.
|
|
114
|
+
const code = totalRejected > 0 || (strict && totalAmbiguous > 0) ? 1 : 0;
|
|
103
115
|
return { success: code === 0, exitCode: code };
|
|
104
116
|
}
|
|
105
117
|
export const applyCommand = {
|
|
@@ -109,6 +121,7 @@ export const applyCommand = {
|
|
|
109
121
|
{ name: 'dry-run', description: 'report what would apply/reject without writing', type: 'boolean' },
|
|
110
122
|
{ name: 'fuzz', description: 'max context lines to drop when matching a drifted hunk (default 2)', type: 'string' },
|
|
111
123
|
{ name: 'partial', description: 'write files even when some of their hunks are rejected', type: 'boolean' },
|
|
124
|
+
{ name: 'strict', description: 'exit 1 if any hunk matched an ambiguous (duplicated) block, even if it applied', type: 'boolean' },
|
|
112
125
|
],
|
|
113
126
|
examples: [
|
|
114
127
|
{ command: 'swarmdo apply changes.patch', description: 'Apply a patch file' },
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `swarmdo compact-snapshot` — capture / restore a compaction-survival digest.
|
|
3
|
+
*
|
|
4
|
+
* swarmdo compact-snapshot write # snapshot working state (call on PreCompact)
|
|
5
|
+
* swarmdo compact-snapshot read # print the digest, then consume it (first
|
|
6
|
+
* # prompt after compaction re-injects it)
|
|
7
|
+
* swarmdo compact-snapshot read --keep # print without consuming
|
|
8
|
+
*
|
|
9
|
+
* The digest (recently edited files, uncommitted changes, branch) lets an agent
|
|
10
|
+
* re-ground after context compaction instead of re-exploring. The engine is
|
|
11
|
+
* pure + tested (../compact-snapshot/compact-snapshot.ts); this wrapper does the
|
|
12
|
+
* fs read (edit ledger), git calls, and one-shot persistence.
|
|
13
|
+
*/
|
|
14
|
+
import type { Command } from '../types.js';
|
|
15
|
+
export declare const compactSnapshotCommand: Command;
|
|
16
|
+
export default compactSnapshotCommand;
|
|
17
|
+
//# sourceMappingURL=compact-snapshot.d.ts.map
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `swarmdo compact-snapshot` — capture / restore a compaction-survival digest.
|
|
3
|
+
*
|
|
4
|
+
* swarmdo compact-snapshot write # snapshot working state (call on PreCompact)
|
|
5
|
+
* swarmdo compact-snapshot read # print the digest, then consume it (first
|
|
6
|
+
* # prompt after compaction re-injects it)
|
|
7
|
+
* swarmdo compact-snapshot read --keep # print without consuming
|
|
8
|
+
*
|
|
9
|
+
* The digest (recently edited files, uncommitted changes, branch) lets an agent
|
|
10
|
+
* re-ground after context compaction instead of re-exploring. The engine is
|
|
11
|
+
* pure + tested (../compact-snapshot/compact-snapshot.ts); this wrapper does the
|
|
12
|
+
* fs read (edit ledger), git calls, and one-shot persistence.
|
|
13
|
+
*/
|
|
14
|
+
import { execFileSync } from 'node:child_process';
|
|
15
|
+
import fs from 'node:fs';
|
|
16
|
+
import path from 'node:path';
|
|
17
|
+
import { output } from '../output.js';
|
|
18
|
+
import { buildDigest, formatDigest, isDigestEmpty, } from '../compact-snapshot/compact-snapshot.js';
|
|
19
|
+
const DIGEST_FILE = 'compact-digest.json';
|
|
20
|
+
const LEDGER_FILE = 'pending-insights.jsonl';
|
|
21
|
+
function dataDir(root) {
|
|
22
|
+
return path.join(root, '.swarmdo', 'data');
|
|
23
|
+
}
|
|
24
|
+
/** Best-effort git; returns '' on any failure (not-a-repo, no git). */
|
|
25
|
+
function git(root, args) {
|
|
26
|
+
try {
|
|
27
|
+
return execFileSync('git', args, { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 8 * 1024 * 1024 });
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
return '';
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
/** Read edit records from the pending-insights ledger. Tolerant of junk lines. */
|
|
34
|
+
function readEdits(root) {
|
|
35
|
+
const p = path.join(dataDir(root), LEDGER_FILE);
|
|
36
|
+
let raw;
|
|
37
|
+
try {
|
|
38
|
+
raw = fs.readFileSync(p, 'utf8');
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
return [];
|
|
42
|
+
}
|
|
43
|
+
const out = [];
|
|
44
|
+
for (const line of raw.split('\n')) {
|
|
45
|
+
if (!line.trim())
|
|
46
|
+
continue;
|
|
47
|
+
try {
|
|
48
|
+
const rec = JSON.parse(line);
|
|
49
|
+
if (rec && rec.type === 'edit' && typeof rec.file === 'string') {
|
|
50
|
+
out.push({ file: rec.file, timestamp: typeof rec.timestamp === 'number' ? rec.timestamp : 0 });
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
catch { /* skip malformed line */ }
|
|
54
|
+
}
|
|
55
|
+
return out;
|
|
56
|
+
}
|
|
57
|
+
function doWrite(root) {
|
|
58
|
+
// Drop swarmdo's own state dirs — they're not the user's working set and
|
|
59
|
+
// are usually gitignored anyway; filtering keeps the digest signal clean.
|
|
60
|
+
const gitStatus = git(root, ['status', '--porcelain'])
|
|
61
|
+
.split('\n')
|
|
62
|
+
.filter(Boolean)
|
|
63
|
+
.filter((l) => !/(^|\s|"|-> )\.(swarmdo|swarm|git)\//.test(l));
|
|
64
|
+
const digest = buildDigest({
|
|
65
|
+
now: Date.now(),
|
|
66
|
+
edits: readEdits(root),
|
|
67
|
+
gitStatus,
|
|
68
|
+
branch: git(root, ['rev-parse', '--abbrev-ref', 'HEAD']).trim() || undefined,
|
|
69
|
+
});
|
|
70
|
+
if (isDigestEmpty(digest)) {
|
|
71
|
+
// Nothing worth restoring — remove any stale digest so a later `read` is clean.
|
|
72
|
+
try {
|
|
73
|
+
fs.rmSync(path.join(dataDir(root), DIGEST_FILE));
|
|
74
|
+
}
|
|
75
|
+
catch { /* none */ }
|
|
76
|
+
output.writeln('compact-snapshot: no working state to capture');
|
|
77
|
+
return { success: true, exitCode: 0 };
|
|
78
|
+
}
|
|
79
|
+
try {
|
|
80
|
+
fs.mkdirSync(dataDir(root), { recursive: true });
|
|
81
|
+
fs.writeFileSync(path.join(dataDir(root), DIGEST_FILE), JSON.stringify(digest, null, 2) + '\n');
|
|
82
|
+
}
|
|
83
|
+
catch (e) {
|
|
84
|
+
output.printError(`compact-snapshot: could not write digest — ${e.message}`);
|
|
85
|
+
return { success: false, exitCode: 1 };
|
|
86
|
+
}
|
|
87
|
+
output.writeln(`compact-snapshot: captured ${digest.recentFiles.length} edited + ${digest.uncommitted.length} uncommitted`);
|
|
88
|
+
return { success: true, exitCode: 0 };
|
|
89
|
+
}
|
|
90
|
+
function doRead(root, keep) {
|
|
91
|
+
const file = path.join(dataDir(root), DIGEST_FILE);
|
|
92
|
+
let digest;
|
|
93
|
+
try {
|
|
94
|
+
digest = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
return { success: true, exitCode: 0 };
|
|
98
|
+
} // no digest → nothing to inject
|
|
99
|
+
const text = formatDigest(digest, Date.now());
|
|
100
|
+
if (text)
|
|
101
|
+
process.stdout.write(text + '\n');
|
|
102
|
+
if (!keep) {
|
|
103
|
+
try {
|
|
104
|
+
fs.rmSync(file);
|
|
105
|
+
}
|
|
106
|
+
catch { /* already gone */ }
|
|
107
|
+
} // consume-once
|
|
108
|
+
return { success: true, exitCode: 0 };
|
|
109
|
+
}
|
|
110
|
+
async function run(ctx) {
|
|
111
|
+
const root = ctx.cwd || process.cwd();
|
|
112
|
+
const mode = (ctx.args[0] ?? 'read').toLowerCase();
|
|
113
|
+
if (mode === 'write')
|
|
114
|
+
return doWrite(root);
|
|
115
|
+
if (mode === 'read')
|
|
116
|
+
return doRead(root, ctx.flags.keep === true);
|
|
117
|
+
output.printError(`compact-snapshot: unknown mode "${mode}" (use write | read)`);
|
|
118
|
+
return { success: false, exitCode: 1 };
|
|
119
|
+
}
|
|
120
|
+
export const compactSnapshotCommand = {
|
|
121
|
+
name: 'compact-snapshot',
|
|
122
|
+
description: 'Capture/restore a working-state digest that survives context compaction — recent edits, uncommitted changes, branch — so an agent re-grounds instead of re-exploring',
|
|
123
|
+
options: [
|
|
124
|
+
{ name: 'keep', description: 'on read, print the digest without consuming it', type: 'boolean' },
|
|
125
|
+
],
|
|
126
|
+
examples: [
|
|
127
|
+
{ command: 'swarmdo compact-snapshot write', description: 'Snapshot working state (wire to a PreCompact hook)' },
|
|
128
|
+
{ command: 'swarmdo compact-snapshot read', description: 'Print + consume the digest (wire to the first post-compaction prompt)' },
|
|
129
|
+
],
|
|
130
|
+
action: run,
|
|
131
|
+
};
|
|
132
|
+
export default compactSnapshotCommand;
|
|
133
|
+
//# sourceMappingURL=compact-snapshot.js.map
|
|
@@ -22,6 +22,7 @@ const commandLoaders = {
|
|
|
22
22
|
// Deterministic command-output compression (rtk/headroom demand) — a
|
|
23
23
|
// zero-token stream filter, distinct from caveman `compress` for files.
|
|
24
24
|
compact: () => import('./compact.js'),
|
|
25
|
+
'compact-snapshot': () => import('./compact-snapshot.js'),
|
|
25
26
|
// Secret detection + redaction on the agent data path (gitleaks/trufflehog
|
|
26
27
|
// demand) — mask API keys/tokens before they reach an LLM/log/memory.
|
|
27
28
|
redact: () => import('./redact.js'),
|
|
@@ -286,7 +287,7 @@ export async function getCommandsByCategory() {
|
|
|
286
287
|
// three slots from 'statusline' onward (completionsCmd received the
|
|
287
288
|
// statusline module, analyzeCmd received completions, …), scrambling the
|
|
288
289
|
// categorized help. Every load now has a named slot.
|
|
289
|
-
const [daemonCmd, doctorCmd, embeddingsCmd, neuralCmd, performanceCmd, securityCmd, swarmvectorCmd, hiveMindCmd, configCmd, statuslineCmd, compressCmd, efficiencyCmd, completionsCmd, migrateCmd, workflowCmd, analyzeCmd, routeCmd, progressCmd, providersCmd, pluginsCmd, deploymentCmd, claimsCmd, issuesCmd, updateCmd, processCmd, guidanceCmd, applianceCmd, cleanupCmd, autopilotCmd, demoCmd, usageCmd, repairCmd, hudCmd, compactCmd, codegraphCmd, redactCmd, packCmd, envCmd, licenseCmd, sbomCmd, applyCmd, hotspotsCmd, affectedCmd, cyclesCmd, testreportCmd,] = await Promise.all([
|
|
290
|
+
const [daemonCmd, doctorCmd, embeddingsCmd, neuralCmd, performanceCmd, securityCmd, swarmvectorCmd, hiveMindCmd, configCmd, statuslineCmd, compressCmd, efficiencyCmd, completionsCmd, migrateCmd, workflowCmd, analyzeCmd, routeCmd, progressCmd, providersCmd, pluginsCmd, deploymentCmd, claimsCmd, issuesCmd, updateCmd, processCmd, guidanceCmd, applianceCmd, cleanupCmd, autopilotCmd, demoCmd, usageCmd, repairCmd, hudCmd, compactCmd, codegraphCmd, redactCmd, packCmd, envCmd, licenseCmd, sbomCmd, applyCmd, hotspotsCmd, affectedCmd, cyclesCmd, testreportCmd, compactSnapshotCmd,] = await Promise.all([
|
|
290
291
|
loadCommand('daemon'), loadCommand('doctor'), loadCommand('embeddings'), loadCommand('neural'),
|
|
291
292
|
loadCommand('performance'), loadCommand('security'), loadCommand('swarmvector'), loadCommand('hive-mind'),
|
|
292
293
|
loadCommand('config'), loadCommand('statusline'), loadCommand('compress'), loadCommand('efficiency'),
|
|
@@ -294,7 +295,7 @@ export async function getCommandsByCategory() {
|
|
|
294
295
|
loadCommand('analyze'), loadCommand('route'), loadCommand('progress'), loadCommand('providers'),
|
|
295
296
|
loadCommand('plugins'), loadCommand('deployment'), loadCommand('claims'), loadCommand('issues'),
|
|
296
297
|
loadCommand('update'), loadCommand('process'), loadCommand('guidance'), loadCommand('appliance'),
|
|
297
|
-
loadCommand('cleanup'), loadCommand('autopilot'), loadCommand('demo'), loadCommand('usage'), loadCommand('repair'), loadCommand('hud'), loadCommand('compact'), loadCommand('codegraph'), loadCommand('redact'), loadCommand('pack'), loadCommand('env'), loadCommand('license'), loadCommand('sbom'), loadCommand('apply'), loadCommand('hotspots'), loadCommand('affected'), loadCommand('cycles'), loadCommand('testreport'),
|
|
298
|
+
loadCommand('cleanup'), loadCommand('autopilot'), loadCommand('demo'), loadCommand('usage'), loadCommand('repair'), loadCommand('hud'), loadCommand('compact'), loadCommand('codegraph'), loadCommand('redact'), loadCommand('pack'), loadCommand('env'), loadCommand('license'), loadCommand('sbom'), loadCommand('apply'), loadCommand('hotspots'), loadCommand('affected'), loadCommand('cycles'), loadCommand('testreport'), loadCommand('compact-snapshot'),
|
|
298
299
|
]);
|
|
299
300
|
return {
|
|
300
301
|
primary: [
|
|
@@ -310,7 +311,7 @@ export async function getCommandsByCategory() {
|
|
|
310
311
|
utility: [
|
|
311
312
|
configCmd, doctorCmd, daemonCmd, completionsCmd,
|
|
312
313
|
migrateCmd, workflowCmd, demoCmd,
|
|
313
|
-
statuslineCmd, compressCmd, compactCmd, redactCmd, packCmd, envCmd, licenseCmd, sbomCmd, applyCmd, hotspotsCmd, affectedCmd, cyclesCmd, testreportCmd, efficiencyCmd,
|
|
314
|
+
statuslineCmd, compressCmd, compactCmd, redactCmd, packCmd, envCmd, licenseCmd, sbomCmd, applyCmd, hotspotsCmd, affectedCmd, cyclesCmd, testreportCmd, compactSnapshotCmd, efficiencyCmd,
|
|
314
315
|
].filter(Boolean),
|
|
315
316
|
analysis: [
|
|
316
317
|
analyzeCmd, routeCmd, progressCmd, usageCmd, hudCmd, codegraphCmd,
|
|
@@ -13,6 +13,14 @@
|
|
|
13
13
|
* land in the bundle.
|
|
14
14
|
*/
|
|
15
15
|
import type { Command } from '../types.js';
|
|
16
|
+
/**
|
|
17
|
+
* Decode a file buffer to bundle text, honoring BOMs (#10). UTF-16 LE/BE
|
|
18
|
+
* files decode to text (previously their NUL bytes tripped the binary
|
|
19
|
+
* heuristic and the file was silently dropped); a UTF-8 BOM is stripped
|
|
20
|
+
* instead of leaking into the bundle. Returns null for genuinely-binary
|
|
21
|
+
* content (NUL byte without a text BOM). Exported for tests.
|
|
22
|
+
*/
|
|
23
|
+
export declare function decodeText(buf: Buffer): string | null;
|
|
16
24
|
export declare const packCommand: Command;
|
|
17
25
|
export default packCommand;
|
|
18
26
|
//# sourceMappingURL=pack.d.ts.map
|
|
@@ -35,6 +35,31 @@ function looksBinary(buf) {
|
|
|
35
35
|
return true;
|
|
36
36
|
return false;
|
|
37
37
|
}
|
|
38
|
+
/**
|
|
39
|
+
* Decode a file buffer to bundle text, honoring BOMs (#10). UTF-16 LE/BE
|
|
40
|
+
* files decode to text (previously their NUL bytes tripped the binary
|
|
41
|
+
* heuristic and the file was silently dropped); a UTF-8 BOM is stripped
|
|
42
|
+
* instead of leaking into the bundle. Returns null for genuinely-binary
|
|
43
|
+
* content (NUL byte without a text BOM). Exported for tests.
|
|
44
|
+
*/
|
|
45
|
+
export function decodeText(buf) {
|
|
46
|
+
if (buf.length >= 3 && buf[0] === 0xef && buf[1] === 0xbb && buf[2] === 0xbf) {
|
|
47
|
+
return buf.subarray(3).toString('utf8');
|
|
48
|
+
}
|
|
49
|
+
if (buf.length >= 2 && buf[0] === 0xff && buf[1] === 0xfe) {
|
|
50
|
+
const body = buf.subarray(2);
|
|
51
|
+
if (body.length % 2 !== 0)
|
|
52
|
+
return null;
|
|
53
|
+
return body.toString('utf16le');
|
|
54
|
+
}
|
|
55
|
+
if (buf.length >= 2 && buf[0] === 0xfe && buf[1] === 0xff) {
|
|
56
|
+
const body = Buffer.from(buf.subarray(2)); // copy: swap16 mutates in place
|
|
57
|
+
if (body.length % 2 !== 0)
|
|
58
|
+
return null;
|
|
59
|
+
return body.swap16().toString('utf16le');
|
|
60
|
+
}
|
|
61
|
+
return looksBinary(buf) ? null : buf.toString('utf8');
|
|
62
|
+
}
|
|
38
63
|
function walk(o) {
|
|
39
64
|
const files = [];
|
|
40
65
|
const stack = [o.root];
|
|
@@ -82,9 +107,10 @@ function walk(o) {
|
|
|
82
107
|
catch {
|
|
83
108
|
continue;
|
|
84
109
|
}
|
|
85
|
-
|
|
110
|
+
const text = decodeText(buf);
|
|
111
|
+
if (text === null)
|
|
86
112
|
continue;
|
|
87
|
-
files.push({ path: rel, content:
|
|
113
|
+
files.push({ path: rel, content: text });
|
|
88
114
|
}
|
|
89
115
|
}
|
|
90
116
|
}
|
|
@@ -95,21 +95,31 @@ function foldNodeFrames(lines) {
|
|
|
95
95
|
}
|
|
96
96
|
return out;
|
|
97
97
|
}
|
|
98
|
-
/**
|
|
98
|
+
/**
|
|
99
|
+
* Collapse runs of ≥3 consecutive blank lines to a single blank. Runs of 1 or 2
|
|
100
|
+
* blanks are preserved (a 2-blank run is meaningful — e.g. PEP 8 spacing between
|
|
101
|
+
* top-level defs), so the full run length is counted before deciding.
|
|
102
|
+
*/
|
|
99
103
|
function collapseBlankLines(lines) {
|
|
100
104
|
const out = [];
|
|
101
105
|
let blanks = 0;
|
|
106
|
+
const flush = () => {
|
|
107
|
+
if (blanks === 0)
|
|
108
|
+
return;
|
|
109
|
+
const keep = blanks >= 3 ? 1 : blanks; // ≥3 → 1; 1 or 2 → unchanged
|
|
110
|
+
for (let i = 0; i < keep; i++)
|
|
111
|
+
out.push('');
|
|
112
|
+
blanks = 0;
|
|
113
|
+
};
|
|
102
114
|
for (const line of lines) {
|
|
103
115
|
if (line.trim() === '') {
|
|
104
116
|
blanks++;
|
|
105
|
-
|
|
106
|
-
out.push('');
|
|
107
|
-
}
|
|
108
|
-
else {
|
|
109
|
-
blanks = 0;
|
|
110
|
-
out.push(line);
|
|
117
|
+
continue;
|
|
111
118
|
}
|
|
119
|
+
flush();
|
|
120
|
+
out.push(line);
|
|
112
121
|
}
|
|
122
|
+
flush();
|
|
113
123
|
return out;
|
|
114
124
|
}
|
|
115
125
|
/** Keep head + tail lines, eliding the middle with a marker. */
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* compact-snapshot.ts — build a "survival digest" that lets an agent re-ground
|
|
3
|
+
* after context compaction. Post-compaction, the model loses its working set
|
|
4
|
+
* and tends to re-explore files it just edited and repeat corrected mistakes
|
|
5
|
+
* (a widely-reported pain, #45). This captures the cheap signals swarmdo
|
|
6
|
+
* already tracks — recently edited files (the pending-insights edit ledger),
|
|
7
|
+
* uncommitted changes (`git status --porcelain`), and the current branch —
|
|
8
|
+
* into a compact digest that the first post-compaction prompt re-injects.
|
|
9
|
+
*
|
|
10
|
+
* Pure + deterministic: inputs in (including `now`, so no clock dependency),
|
|
11
|
+
* a digest / rendered string out. The fs read (edit ledger), git calls, and
|
|
12
|
+
* digest persistence live in ../commands/compact-snapshot.ts, so this is
|
|
13
|
+
* fixture-testable.
|
|
14
|
+
*/
|
|
15
|
+
/** One edit-ledger record (a line of .swarmdo/data/pending-insights.jsonl). */
|
|
16
|
+
export interface EditRecord {
|
|
17
|
+
file: string;
|
|
18
|
+
timestamp: number;
|
|
19
|
+
}
|
|
20
|
+
export interface DigestInput {
|
|
21
|
+
/** recent edit-ledger records; most-recent-wins when a file repeats */
|
|
22
|
+
edits?: EditRecord[];
|
|
23
|
+
/** raw `git status --porcelain` lines (uncommitted changes) */
|
|
24
|
+
gitStatus?: string[];
|
|
25
|
+
/** current branch name, if known */
|
|
26
|
+
branch?: string;
|
|
27
|
+
/** epoch ms the snapshot was taken (injected, not read from a clock) */
|
|
28
|
+
now: number;
|
|
29
|
+
}
|
|
30
|
+
export interface CompactDigest {
|
|
31
|
+
takenAt: number;
|
|
32
|
+
branch?: string;
|
|
33
|
+
/** distinct edited files, most-recently-edited first, capped */
|
|
34
|
+
recentFiles: string[];
|
|
35
|
+
/** distinct uncommitted file paths, capped */
|
|
36
|
+
uncommitted: string[];
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Extract the working-tree path from one `git status --porcelain` line.
|
|
40
|
+
* Format is `XY <path>` (two status chars + space); a rename is
|
|
41
|
+
* `R <old> -> <new>` — we want the destination. Quoted paths (git quotes
|
|
42
|
+
* names with special chars) are unwrapped. Returns null for a blank line.
|
|
43
|
+
* Pure.
|
|
44
|
+
*/
|
|
45
|
+
export declare function porcelainPath(line: string): string | null;
|
|
46
|
+
/** Build the digest from raw session signals. Pure. */
|
|
47
|
+
export declare function buildDigest(input: DigestInput, opts?: {
|
|
48
|
+
maxFiles?: number;
|
|
49
|
+
}): CompactDigest;
|
|
50
|
+
/** A digest with no files and no branch carries nothing worth re-injecting. Pure. */
|
|
51
|
+
export declare function isDigestEmpty(d: CompactDigest): boolean;
|
|
52
|
+
/**
|
|
53
|
+
* Render the digest as a re-grounding block for the first post-compaction
|
|
54
|
+
* prompt. Empty digest → '' (nothing to inject). `now` is passed so the
|
|
55
|
+
* relative age is deterministic. Pure.
|
|
56
|
+
*/
|
|
57
|
+
export declare function formatDigest(d: CompactDigest, now: number): string;
|
|
58
|
+
//# sourceMappingURL=compact-snapshot.d.ts.map
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* compact-snapshot.ts — build a "survival digest" that lets an agent re-ground
|
|
3
|
+
* after context compaction. Post-compaction, the model loses its working set
|
|
4
|
+
* and tends to re-explore files it just edited and repeat corrected mistakes
|
|
5
|
+
* (a widely-reported pain, #45). This captures the cheap signals swarmdo
|
|
6
|
+
* already tracks — recently edited files (the pending-insights edit ledger),
|
|
7
|
+
* uncommitted changes (`git status --porcelain`), and the current branch —
|
|
8
|
+
* into a compact digest that the first post-compaction prompt re-injects.
|
|
9
|
+
*
|
|
10
|
+
* Pure + deterministic: inputs in (including `now`, so no clock dependency),
|
|
11
|
+
* a digest / rendered string out. The fs read (edit ledger), git calls, and
|
|
12
|
+
* digest persistence live in ../commands/compact-snapshot.ts, so this is
|
|
13
|
+
* fixture-testable.
|
|
14
|
+
*/
|
|
15
|
+
const DEFAULT_MAX_FILES = 12;
|
|
16
|
+
/**
|
|
17
|
+
* Extract the working-tree path from one `git status --porcelain` line.
|
|
18
|
+
* Format is `XY <path>` (two status chars + space); a rename is
|
|
19
|
+
* `R <old> -> <new>` — we want the destination. Quoted paths (git quotes
|
|
20
|
+
* names with special chars) are unwrapped. Returns null for a blank line.
|
|
21
|
+
* Pure.
|
|
22
|
+
*/
|
|
23
|
+
export function porcelainPath(line) {
|
|
24
|
+
if (!line || line.length < 4)
|
|
25
|
+
return null;
|
|
26
|
+
let rest = line.slice(3); // strip the 2 status chars + the separating space
|
|
27
|
+
const arrow = rest.indexOf(' -> ');
|
|
28
|
+
if (arrow >= 0)
|
|
29
|
+
rest = rest.slice(arrow + 4); // rename/copy → destination path
|
|
30
|
+
rest = rest.trim();
|
|
31
|
+
if (rest.startsWith('"') && rest.endsWith('"') && rest.length >= 2) {
|
|
32
|
+
rest = rest.slice(1, -1); // unwrap git's quoted path (escapes left as-is)
|
|
33
|
+
}
|
|
34
|
+
return rest || null;
|
|
35
|
+
}
|
|
36
|
+
/** Build the digest from raw session signals. Pure. */
|
|
37
|
+
export function buildDigest(input, opts = {}) {
|
|
38
|
+
const max = opts.maxFiles ?? DEFAULT_MAX_FILES;
|
|
39
|
+
// Distinct edited files, most-recent edit first. A file edited repeatedly
|
|
40
|
+
// keeps its latest timestamp; ties preserve first-seen (stable) order.
|
|
41
|
+
const latest = new Map();
|
|
42
|
+
for (const e of input.edits ?? []) {
|
|
43
|
+
if (!e || typeof e.file !== 'string' || !e.file)
|
|
44
|
+
continue;
|
|
45
|
+
const t = typeof e.timestamp === 'number' ? e.timestamp : 0;
|
|
46
|
+
const prev = latest.get(e.file);
|
|
47
|
+
if (prev === undefined || t >= prev)
|
|
48
|
+
latest.set(e.file, t);
|
|
49
|
+
}
|
|
50
|
+
const recentFiles = [...latest.entries()]
|
|
51
|
+
.sort((a, b) => b[1] - a[1] || (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0))
|
|
52
|
+
.slice(0, max)
|
|
53
|
+
.map(([f]) => f);
|
|
54
|
+
const seenUncommitted = new Set();
|
|
55
|
+
const uncommitted = [];
|
|
56
|
+
for (const line of input.gitStatus ?? []) {
|
|
57
|
+
const p = porcelainPath(line);
|
|
58
|
+
if (p && !seenUncommitted.has(p)) {
|
|
59
|
+
seenUncommitted.add(p);
|
|
60
|
+
uncommitted.push(p);
|
|
61
|
+
}
|
|
62
|
+
if (uncommitted.length >= max)
|
|
63
|
+
break;
|
|
64
|
+
}
|
|
65
|
+
return {
|
|
66
|
+
takenAt: input.now,
|
|
67
|
+
...(input.branch ? { branch: input.branch } : {}),
|
|
68
|
+
recentFiles,
|
|
69
|
+
uncommitted,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
/** A digest with no files and no branch carries nothing worth re-injecting. Pure. */
|
|
73
|
+
export function isDigestEmpty(d) {
|
|
74
|
+
return d.recentFiles.length === 0 && d.uncommitted.length === 0;
|
|
75
|
+
}
|
|
76
|
+
/** Compact human "Nm ago" for the snapshot age. Pure. */
|
|
77
|
+
function ago(fromMs, now) {
|
|
78
|
+
const s = Math.max(0, Math.round((now - fromMs) / 1000));
|
|
79
|
+
if (s < 60)
|
|
80
|
+
return `${s}s ago`;
|
|
81
|
+
const m = Math.round(s / 60);
|
|
82
|
+
if (m < 60)
|
|
83
|
+
return `${m}m ago`;
|
|
84
|
+
return `${Math.round(m / 60)}h ago`;
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Render the digest as a re-grounding block for the first post-compaction
|
|
88
|
+
* prompt. Empty digest → '' (nothing to inject). `now` is passed so the
|
|
89
|
+
* relative age is deterministic. Pure.
|
|
90
|
+
*/
|
|
91
|
+
export function formatDigest(d, now) {
|
|
92
|
+
if (isDigestEmpty(d))
|
|
93
|
+
return '';
|
|
94
|
+
const lines = [`[swarmdo] Working context restored after compaction (snapshot ${ago(d.takenAt, now)}):`];
|
|
95
|
+
if (d.branch)
|
|
96
|
+
lines.push(`- Branch: ${d.branch}`);
|
|
97
|
+
if (d.recentFiles.length)
|
|
98
|
+
lines.push(`- Recently edited (${d.recentFiles.length}): ${d.recentFiles.join(', ')}`);
|
|
99
|
+
if (d.uncommitted.length)
|
|
100
|
+
lines.push(`- Uncommitted changes (${d.uncommitted.length}): ${d.uncommitted.join(', ')}`);
|
|
101
|
+
lines.push('Resume this work; re-read these files only as needed rather than re-exploring from scratch.');
|
|
102
|
+
return lines.join('\n');
|
|
103
|
+
}
|
|
104
|
+
//# sourceMappingURL=compact-snapshot.js.map
|
|
@@ -13,9 +13,23 @@ export const TOPOLOGIES = ['hierarchical', 'mesh', 'hierarchical-mesh', 'ring',
|
|
|
13
13
|
export const MEMORY_BACKENDS = ['agentdb', 'sqlite', 'hybrid', 'memory'];
|
|
14
14
|
import { parseOpenRouterConfig } from '../providers/openrouter-config.js';
|
|
15
15
|
export const KNOWN_CONFIG_KEYS = ['topology', 'maxAgents', 'strategy', 'consensus', 'memory', 'memoryBackend', 'hnsw', 'neural', 'embeddings', 'providers', 'mcp', 'logging', 'daemon', 'hooks', 'version', 'openrouter', '$schema'];
|
|
16
|
+
// Current Claude Code hook events (source: code.claude.com/docs/en/hooks).
|
|
17
|
+
// Kept in sync with the runtime; a stale list here false-warns on valid hooks.
|
|
16
18
|
export const HOOK_EVENTS = [
|
|
17
|
-
|
|
18
|
-
'SessionStart', '
|
|
19
|
+
// session lifecycle
|
|
20
|
+
'SessionStart', 'Setup', 'SessionEnd',
|
|
21
|
+
// per-turn
|
|
22
|
+
'UserPromptSubmit', 'UserPromptExpansion', 'Stop', 'StopFailure',
|
|
23
|
+
// agentic loop
|
|
24
|
+
'PreToolUse', 'PostToolUse', 'PostToolUseFailure', 'PostToolBatch', 'PermissionRequest', 'PermissionDenied',
|
|
25
|
+
// subagent & task
|
|
26
|
+
'SubagentStart', 'SubagentStop', 'TeammateIdle', 'TaskCreated', 'TaskCompleted',
|
|
27
|
+
// system & file
|
|
28
|
+
'Notification', 'MessageDisplay', 'CwdChanged', 'FileChanged', 'ConfigChange', 'InstructionsLoaded', 'PreCompact', 'PostCompact',
|
|
29
|
+
// MCP elicitation
|
|
30
|
+
'Elicitation', 'ElicitationResult',
|
|
31
|
+
// worktree
|
|
32
|
+
'WorktreeCreate', 'WorktreeRemove',
|
|
19
33
|
];
|
|
20
34
|
/** Parse a JSON file's raw text; a null raw means "file absent" (fine). */
|
|
21
35
|
export function lintJson(file, raw) {
|
|
@@ -790,7 +790,20 @@ export function generateIntelligenceStub() {
|
|
|
790
790
|
' return { entry: e, score: matchScore(promptWords, e.words || tokenize(e.content + " " + e.summary)) };',
|
|
791
791
|
' }).filter(function(s) { return s.score > 0.05; });',
|
|
792
792
|
' scored.sort(function(a, b) { return b.score - a.score; });',
|
|
793
|
-
'
|
|
793
|
+
' // Dedupe by content signature before taking the top 5. The store can',
|
|
794
|
+
' // accumulate many copies of one memory (re-imported each session) with',
|
|
795
|
+
' // DISTINCT ids, so an id-only dedupe leaves identical memories that fill',
|
|
796
|
+
' // every slot; key on normalized content so the 5 slots hold 5 DISTINCT',
|
|
797
|
+
' // memories (parity with the full helper deduplicateByContent).',
|
|
798
|
+
' var top = [];',
|
|
799
|
+
' var seenSig = {};',
|
|
800
|
+
' for (var si = 0; si < scored.length && top.length < 5; si++) {',
|
|
801
|
+
' var sEntry = scored[si].entry;',
|
|
802
|
+
' var sig = ((sEntry.summary || "") + "\\u0001" + (sEntry.content || "")).replace(/\\s+/g, " ").trim().toLowerCase().substring(0, 200);',
|
|
803
|
+
' if (seenSig[sig]) continue;',
|
|
804
|
+
' seenSig[sig] = true;',
|
|
805
|
+
' top.push(scored[si]);',
|
|
806
|
+
' }',
|
|
794
807
|
' if (!top.length) return null;',
|
|
795
808
|
' var prevMatched = sessionGet("lastMatchedPatterns");',
|
|
796
809
|
' var matchedIds = top.map(function(s) { return s.entry.id; });',
|
|
@@ -800,11 +813,28 @@ export function generateIntelligenceStub() {
|
|
|
800
813
|
' for (var i = 0; i < matchedIds.length; i++) newSet[matchedIds[i]] = true;',
|
|
801
814
|
' }',
|
|
802
815
|
' var lines2 = ["[INTELLIGENCE] Relevant patterns for this task:"];',
|
|
816
|
+
' // Inject the matched memory CONTENT (budgeted), not just an 80-char',
|
|
817
|
+
' // label, so stored memories are usable mid-session without a separate',
|
|
818
|
+
' // lookup (#43 Phase 1). Total budget ~800 tokens; lexical match reused',
|
|
819
|
+
' // so there is no per-prompt embedding cost.',
|
|
820
|
+
' var BUDGET = 3200;',
|
|
821
|
+
' var used = 0;',
|
|
803
822
|
' for (var j = 0; j < top.length; j++) {',
|
|
804
823
|
' var e = top[j];',
|
|
805
824
|
' var conf = e.entry.confidence || 0.5;',
|
|
806
|
-
' var
|
|
807
|
-
'
|
|
825
|
+
' var label = (e.entry.summary || e.entry.category || "memory").substring(0, 80);',
|
|
826
|
+
' var body = (e.entry.content || "").replace(/\\s+/g, " ").trim();',
|
|
827
|
+
' var remaining = BUDGET - used;',
|
|
828
|
+
' if (remaining <= 0) break;',
|
|
829
|
+
' var perEntry = Math.max(160, Math.floor(remaining / (top.length - j)));',
|
|
830
|
+
' var snippet = body.substring(0, perEntry);',
|
|
831
|
+
' if (snippet.length < body.length) snippet += "\\u2026";',
|
|
832
|
+
' used += label.length + snippet.length;',
|
|
833
|
+
' if (snippet && snippet !== label) {',
|
|
834
|
+
' lines2.push(" * (" + conf.toFixed(2) + ") " + label + " \\u2014 " + snippet);',
|
|
835
|
+
' } else {',
|
|
836
|
+
' lines2.push(" * (" + conf.toFixed(2) + ") " + label);',
|
|
837
|
+
' }',
|
|
808
838
|
' }',
|
|
809
839
|
' return lines2.join("\\n");',
|
|
810
840
|
' },',
|
|
@@ -47,7 +47,7 @@ export const RULES = [
|
|
|
47
47
|
* anchors it, so `keyboard=`/`monkey_val=` don't match; the entropy + length
|
|
48
48
|
* gates keep low-entropy values (paths, short flags) from being flagged.
|
|
49
49
|
*/
|
|
50
|
-
const ASSIGNMENT_RE = /(?:pass(?:word|wd)?|pwd|secret|token|api[_-]?key|apikey|access[_-]?key|auth[_-]?token|client[_-]?secret|credential|creds|key)["']?\s*[:=]\s*["']?([^\s"'`,;]{8,})/gi;
|
|
50
|
+
const ASSIGNMENT_RE = /(?:pass(?:word|phrase|wd)?|pwd|secret|token|api[_-]?key|apikey|access[_-]?key|auth[_-]?token|client[_-]?secret|credential|creds|key)["']?\s*[:=]\s*["']?([^\s"'`,;]{8,})/gi;
|
|
51
51
|
/** Shannon entropy in bits/char. Pure; used by the assignment fallback. */
|
|
52
52
|
export function shannonEntropy(s) {
|
|
53
53
|
if (!s)
|
|
@@ -139,8 +139,11 @@ export function buildSpdx(components, meta) {
|
|
|
139
139
|
documentNamespace: `https://swarmdo/spdx/${safe(meta.name)}-${meta.version}`,
|
|
140
140
|
packages: [
|
|
141
141
|
{ SPDXID: 'SPDXRef-Package-root', name: meta.name, versionInfo: meta.version, downloadLocation: 'NOASSERTION' },
|
|
142
|
-
...components.map((c) => ({
|
|
143
|
-
|
|
142
|
+
...components.map((c, i) => ({
|
|
143
|
+
// Index prefix makes the ID self-delimiting (#11): name and version can
|
|
144
|
+
// both contain `-` after sanitizing, so `${name}-${version}` alone can
|
|
145
|
+
// collide across distinct (name, version) pairs.
|
|
146
|
+
SPDXID: `SPDXRef-Package-${i}-${safe(c.name)}-${safe(c.version)}`,
|
|
144
147
|
name: c.name,
|
|
145
148
|
versionInfo: c.version,
|
|
146
149
|
downloadLocation: 'NOASSERTION',
|
|
@@ -35,14 +35,24 @@ export const MODEL_PRICES = {
|
|
|
35
35
|
// Mid tier
|
|
36
36
|
'anthropic/claude-haiku-4.5': { in: 1.00, out: 5.00 },
|
|
37
37
|
'openai/gpt-4.1': { in: 2.00, out: 8.00 },
|
|
38
|
-
// Strong tier
|
|
38
|
+
// Strong tier — current-gen Claude (first-party list prices; OpenRouter
|
|
39
|
+
// mirrors them for anthropic/* slugs). Opus dropped to $5/$25 at 4.5 and
|
|
40
|
+
// held there through 4.8 — do NOT price these at the legacy $15/$75.
|
|
41
|
+
'anthropic/claude-sonnet-5': { in: 3.00, out: 15.00 },
|
|
39
42
|
'anthropic/claude-sonnet-4-6': { in: 3.00, out: 15.00 },
|
|
43
|
+
'anthropic/claude-opus-4-8': { in: 5.00, out: 25.00 },
|
|
44
|
+
'anthropic/claude-opus-4-5': { in: 5.00, out: 25.00 },
|
|
45
|
+
'anthropic/claude-fable-5': { in: 10.00, out: 50.00 },
|
|
46
|
+
// Legacy Opus 4.0 kept — its $15/$75 is correct for THAT model, still a
|
|
47
|
+
// valid slug; the current opus tier is priced by claude-opus-4-8 above.
|
|
40
48
|
'anthropic/claude-opus-4': { in: 15.00, out: 75.00 },
|
|
41
49
|
// Tier-label fallbacks (when the trajectory only carries a coarse tier
|
|
42
50
|
// and not a concrete modelId — happens before iter 13 wiring landed).
|
|
51
|
+
// "opus tier" today resolves to Opus 4.8-class ($5/$25); pricing it at the
|
|
52
|
+
// retired $15/$75 made the cost-optimal router avoid opus 3x too eagerly.
|
|
43
53
|
haiku: { in: 1.00, out: 5.00 },
|
|
44
54
|
sonnet: { in: 3.00, out: 15.00 },
|
|
45
|
-
opus: { in:
|
|
55
|
+
opus: { in: 5.00, out: 25.00 },
|
|
46
56
|
inherit: { in: 3.00, out: 15.00 },
|
|
47
57
|
};
|
|
48
58
|
/**
|