@semalt-ai/code 1.8.5 → 1.20.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/settings.local.json +7 -1
- package/.github/workflows/ci.yml +69 -0
- package/ARCHITECTURE.md +6 -95
- package/CLAUDE.md +196 -316
- package/README.md +148 -4
- package/docs/ARCHITECTURE.md +1321 -0
- package/docs/CONFIG.md +340 -0
- package/docs/HISTORY.md +245 -0
- package/examples/embed.js +74 -0
- package/index.js +251 -10
- package/lib/agent.js +856 -120
- package/lib/api.js +239 -50
- package/lib/args.js +74 -2
- package/lib/audit.js +23 -1
- package/lib/background.js +584 -0
- package/lib/checkpoints.js +757 -0
- package/lib/commands/auth.js +94 -0
- package/lib/commands/chat-session.js +489 -0
- package/lib/commands/chat-slash.js +415 -0
- package/lib/commands/chat-turn.js +669 -0
- package/lib/commands/chat.js +407 -0
- package/lib/commands/custom.js +157 -0
- package/lib/commands/history-utils.js +66 -0
- package/lib/commands/index.js +268 -0
- package/lib/commands/mcp.js +113 -0
- package/lib/commands/oneshot.js +193 -0
- package/lib/commands/registry.js +269 -0
- package/lib/commands/tasks.js +89 -0
- package/lib/compact.js +87 -0
- package/lib/config.js +360 -11
- package/lib/constants.js +401 -3
- package/lib/deny.js +199 -0
- package/lib/doctor.js +160 -0
- package/lib/headless.js +202 -0
- package/lib/hooks.js +286 -0
- package/lib/images.js +270 -0
- package/lib/internals.js +49 -0
- package/lib/mcp/boundary.js +131 -0
- package/lib/mcp/client.js +270 -0
- package/lib/mcp/oauth.js +134 -0
- package/lib/memory.js +209 -0
- package/lib/metrics.js +37 -2
- package/lib/payload.js +54 -0
- package/lib/permission-rules.js +401 -0
- package/lib/permissions.js +123 -26
- package/lib/pricing.js +67 -0
- package/lib/proc.js +62 -0
- package/lib/prompts.js +99 -8
- package/lib/sandbox.js +568 -0
- package/lib/sdk.js +328 -0
- package/lib/secrets.js +211 -0
- package/lib/skills.js +223 -0
- package/lib/subagents.js +516 -0
- package/lib/tool_registry.js +2862 -0
- package/lib/tool_specs.js +263 -9
- package/lib/tools.js +352 -1039
- package/lib/ui/anim.js +86 -0
- package/lib/ui/ansi.js +17 -27
- package/lib/ui/chat-history.js +253 -71
- package/lib/ui/create-ui.js +67 -24
- package/lib/ui/diff.js +90 -25
- package/lib/ui/file-activity.js +236 -0
- package/lib/ui/format.js +195 -29
- package/lib/ui/input-field.js +21 -11
- package/lib/ui/md-stream.js +234 -0
- package/lib/ui/render-operation.js +113 -0
- package/lib/ui/select.js +1 -4
- package/lib/ui/status-bar.js +146 -36
- package/lib/ui/stream.js +20 -13
- package/lib/ui/theme.js +190 -44
- package/lib/ui/tool-operation.js +190 -0
- package/lib/ui/utils.js +9 -5
- package/lib/ui/web-activity.js +270 -0
- package/lib/ui/writer.js +159 -45
- package/lib/ui.js +1 -1
- package/lib/verify.js +229 -0
- package/lib/web-extract.js +213 -0
- package/lib/web-summarize.js +68 -0
- package/package.json +19 -4
- package/scripts/lint.js +57 -0
- package/test/agent-loop.test.js +389 -0
- package/test/anim-driver.test.js +153 -0
- package/test/ask-user-display.test.js +226 -0
- package/test/ask-user-gate.test.js +231 -0
- package/test/background.test.js +414 -0
- package/test/chat-history-nocolor.test.js +155 -0
- package/test/chat-relogin.test.js +207 -0
- package/test/chat.test.js +114 -0
- package/test/checkpoints-agent.test.js +181 -0
- package/test/checkpoints.test.js +650 -0
- package/test/command-registry.test.js +160 -0
- package/test/compact.test.js +116 -0
- package/test/completion-lazy.test.js +52 -0
- package/test/config-merge.test.js +324 -0
- package/test/config-quarantine.test.js +128 -0
- package/test/config-write-guard-allow-anywhere.test.js +56 -0
- package/test/config-write-guard-skip.test.js +46 -0
- package/test/config-write-guard.test.js +153 -0
- package/test/context-split.test.js +215 -0
- package/test/cost-doctor.test.js +142 -0
- package/test/custom-commands-chat.test.js +106 -0
- package/test/custom-commands.test.js +230 -0
- package/test/defer-detail-band.test.js +403 -0
- package/test/deny-windows.test.js +120 -0
- package/test/deny.test.js +83 -0
- package/test/detail-band-tab-flatten.test.js +242 -0
- package/test/download-allow-anywhere.test.js +66 -0
- package/test/download-confine.test.js +153 -0
- package/test/exec-diff.test.js +268 -0
- package/test/executors.test.js +599 -0
- package/test/extract-tool-calls.test.js +349 -0
- package/test/fetch-url-validation.test.js +219 -0
- package/test/file-activity.test.js +522 -0
- package/test/fixtures/tool-calls.js +57 -0
- package/test/fixtures/web-page.js +91 -0
- package/test/git-tools.test.js +384 -0
- package/test/grep-glob-serialize.test.js +242 -0
- package/test/grep-glob.test.js +268 -0
- package/test/grep-path-target.test.js +227 -0
- package/test/harness/README.md +57 -0
- package/test/harness/chat-harness.js +143 -0
- package/test/harness/memwarn-headless-child.js +65 -0
- package/test/harness/mock-llm.js +120 -0
- package/test/harness/mock-mcp-server.js +142 -0
- package/test/harness/sse-server.js +69 -0
- package/test/headless.test.js +348 -0
- package/test/history-utils.test.js +88 -0
- package/test/hooks-agent.test.js +238 -0
- package/test/hooks-verify-sandbox.test.js +232 -0
- package/test/hooks.test.js +216 -0
- package/test/http-get-user-agent.test.js +142 -0
- package/test/images-api.test.js +208 -0
- package/test/images.test.js +238 -0
- package/test/input-field-ctrl-o.test.js +37 -0
- package/test/live-height-physical.test.js +281 -0
- package/test/max-iterations.test.js +218 -0
- package/test/mcp-boundary.test.js +57 -0
- package/test/mcp-client.test.js +267 -0
- package/test/mcp-oauth.test.js +86 -0
- package/test/md-stream.test.js +183 -0
- package/test/memory-truncation-warning.test.js +222 -0
- package/test/memory.test.js +198 -0
- package/test/native-dispatch.test.js +409 -0
- package/test/native-live-narration.test.js +254 -0
- package/test/output-chokepoint.test.js +188 -0
- package/test/output-heredoc-leak.test.js +195 -0
- package/test/output-preview.test.js +245 -0
- package/test/path-guards.test.js +134 -0
- package/test/payload.test.js +99 -0
- package/test/permission-rules-agent.test.js +210 -0
- package/test/permission-rules.test.js +297 -0
- package/test/permissions.test.js +362 -0
- package/test/plan-mode.test.js +167 -0
- package/test/read-paginate.test.js +275 -0
- package/test/readonly-tools.test.js +177 -0
- package/test/render-operation.test.js +317 -0
- package/test/replay-descriptor-xml.test.js +216 -0
- package/test/replay-descriptor.test.js +189 -0
- package/test/replay-web-aggregate.test.js +291 -0
- package/test/replay-web-persist.test.js +241 -0
- package/test/result-cap.test.js +233 -0
- package/test/running-glyph-anim.test.js +111 -0
- package/test/sandbox-agent.test.js +147 -0
- package/test/sandbox-integration.test.js +216 -0
- package/test/sandbox.test.js +408 -0
- package/test/sdk.test.js +234 -0
- package/test/shell-output-cap.test.js +181 -0
- package/test/skills-chat.test.js +110 -0
- package/test/skills.test.js +295 -0
- package/test/smoke.test.js +68 -0
- package/test/status-bar-driver.test.js +93 -0
- package/test/status-bar-pause.test.js +164 -0
- package/test/status-bar-resync.test.js +188 -0
- package/test/stream-parser.test.js +171 -0
- package/test/subagents-agent.test.js +178 -0
- package/test/subagents.test.js +222 -0
- package/test/theme-palette.test.js +166 -0
- package/test/tool-registry.test.js +85 -0
- package/test/trim-budget.test.js +101 -0
- package/test/truncate-visible.test.js +78 -0
- package/test/verify-agent.test.js +317 -0
- package/test/verify.test.js +141 -0
- package/test/view-image.test.js +199 -0
- package/test/web-activity-ordering.test.js +203 -0
- package/test/web-activity.test.js +207 -0
- package/test/web-data-extraction-guidance.test.js +71 -0
- package/test/web-extract.test.js +185 -0
- package/test/web-fetch-agent.test.js +291 -0
- package/test/web-fetch-mode.test.js +193 -0
- package/test/web-search.test.js +380 -0
- package/lib/commands.js +0 -1438
- package/path +0 -1
|
@@ -0,0 +1,757 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
// Checkpoints & rewind (Task 4.3)
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
//
|
|
7
|
+
// Before each file-tool mutation the prior state of the affected file(s) is
|
|
8
|
+
// snapshotted to ~/.semalt-ai/checkpoints/<session>/<seq>.json so `/rewind`
|
|
9
|
+
// (and `semalt-code rewind`) can restore it. Restoration is a straight
|
|
10
|
+
// content-restore (write the prior bytes back, or delete a file that did not
|
|
11
|
+
// exist before) — never a fragile reverse-diff replay.
|
|
12
|
+
//
|
|
13
|
+
// "checkpoints": {
|
|
14
|
+
// "enabled": true, // master switch
|
|
15
|
+
// "max_file_bytes": 5242880, // per-file snapshot cap; oversize → not
|
|
16
|
+
// // snapshotted, recorded rewind-unavailable
|
|
17
|
+
// "max_per_session": 100 // retention cap; oldest pruned
|
|
18
|
+
// }
|
|
19
|
+
//
|
|
20
|
+
// SCOPE LIMIT (load-bearing, surfaced to the user): checkpoints cover FILE-TOOL
|
|
21
|
+
// mutations only (write, append, edit_file, replace_in_file, delete_file,
|
|
22
|
+
// move_file, copy_file, upload). Shell side effects — a command that created a
|
|
23
|
+
// file, touched a DB, or hit the network — are NOT reversible and are out of
|
|
24
|
+
// scope. `/rewind` must say so; a false sense of "full undo" is worse than no
|
|
25
|
+
// undo.
|
|
26
|
+
//
|
|
27
|
+
// CONVERSATION-REWIND FORWARD-COMPAT (load-bearing): this task ships
|
|
28
|
+
// file-rewind only, but every checkpoint records its conversation linkage —
|
|
29
|
+
// `turn` = { turnId, promptId, promptIndex, messageCountAtStart } — so a future
|
|
30
|
+
// Task 4.3b can add code/conversation/both rewind WITHOUT changing the on-disk
|
|
31
|
+
// format. Do not remove these fields.
|
|
32
|
+
//
|
|
33
|
+
// Load-bearing properties:
|
|
34
|
+
// * Capture point — capture happens in the executor (agentExecFile) AFTER the
|
|
35
|
+
// permission gate approves and BEFORE the mutation; a denied/withheld call
|
|
36
|
+
// never reaches the executor, and a call the executor itself refuses
|
|
37
|
+
// (--readonly, sandbox, dry-run) produces no committed checkpoint because
|
|
38
|
+
// commit only runs on a status:'ok' result.
|
|
39
|
+
// * Subagents — a subagent reuses the parent's agentExecFile, so its
|
|
40
|
+
// mutations are checkpointed into the PARENT session space automatically and
|
|
41
|
+
// are rewindable.
|
|
42
|
+
// * Fail-safe, not fail-blocking — if snapshotting fails (disk full, EACCES),
|
|
43
|
+
// the mutation still proceeds; checkpointing is a safety net, never a gate.
|
|
44
|
+
// * External-modification integrity — `/rewind` records what the agent LEFT
|
|
45
|
+
// (post-mutation existence + content hash) and, before overwriting, compares
|
|
46
|
+
// the current file against it. A file changed out-of-band (user/other
|
|
47
|
+
// process) is reported and NOT clobbered unless the user forces it.
|
|
48
|
+
|
|
49
|
+
const crypto = require('crypto');
|
|
50
|
+
const os = require('os');
|
|
51
|
+
const path = require('path');
|
|
52
|
+
const realFs = require('fs');
|
|
53
|
+
|
|
54
|
+
const {
|
|
55
|
+
DEFAULT_CHECKPOINT_MAX_FILE_BYTES,
|
|
56
|
+
DEFAULT_CHECKPOINT_MAX_PER_SESSION,
|
|
57
|
+
} = require('./constants');
|
|
58
|
+
|
|
59
|
+
// The file-tool actions that mutate file content/existence and are therefore
|
|
60
|
+
// checkpointable. Directory ops (make_dir/remove_dir) are not snapshotted (a
|
|
61
|
+
// directory tree is out of scope for this task); shell is never reversible.
|
|
62
|
+
const CHECKPOINTABLE_ACTIONS = new Set([
|
|
63
|
+
'write', 'append', 'edit_file', 'replace_in_file',
|
|
64
|
+
'delete_file', 'move_file', 'copy_file', 'upload',
|
|
65
|
+
]);
|
|
66
|
+
|
|
67
|
+
const DEFAULT_ROOT = path.join(os.homedir(), '.semalt-ai', 'checkpoints');
|
|
68
|
+
|
|
69
|
+
// Validate + canonicalize the `config.checkpoints` section. Pure; consumed by
|
|
70
|
+
// lib/config.js normalizeConfig. Unknown/invalid fields fall back to defaults.
|
|
71
|
+
function normalizeCheckpoints(raw) {
|
|
72
|
+
const out = {
|
|
73
|
+
enabled: true,
|
|
74
|
+
max_file_bytes: DEFAULT_CHECKPOINT_MAX_FILE_BYTES,
|
|
75
|
+
max_per_session: DEFAULT_CHECKPOINT_MAX_PER_SESSION,
|
|
76
|
+
};
|
|
77
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return out;
|
|
78
|
+
if (raw.enabled === false) out.enabled = false;
|
|
79
|
+
if (Number.isInteger(raw.max_file_bytes) && raw.max_file_bytes > 0) out.max_file_bytes = raw.max_file_bytes;
|
|
80
|
+
if (Number.isInteger(raw.max_per_session) && raw.max_per_session > 0) out.max_per_session = raw.max_per_session;
|
|
81
|
+
return out;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function _sha256(buf) {
|
|
85
|
+
return crypto.createHash('sha256').update(buf).digest('hex');
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Compute the set of file targets a given mutating action will touch, each
|
|
89
|
+
// tagged with a role so rewind knows how to reverse it.
|
|
90
|
+
// write/append/edit/replace/upload → the single primary file
|
|
91
|
+
// delete_file → the file being removed (recreated on rewind)
|
|
92
|
+
// move_file → the src (returns) + the dst (overwritten)
|
|
93
|
+
// copy_file → the dst only (src is untouched)
|
|
94
|
+
function _targetsFor(action, args) {
|
|
95
|
+
const abs = (p) => (typeof p === 'string' && p ? path.resolve(p) : null);
|
|
96
|
+
switch (action) {
|
|
97
|
+
case 'write':
|
|
98
|
+
case 'append':
|
|
99
|
+
case 'edit_file':
|
|
100
|
+
case 'replace_in_file':
|
|
101
|
+
case 'upload':
|
|
102
|
+
case 'delete_file': {
|
|
103
|
+
const p = abs(args[0]);
|
|
104
|
+
return p ? [{ path: p, role: 'primary' }] : [];
|
|
105
|
+
}
|
|
106
|
+
case 'move_file': {
|
|
107
|
+
const src = abs(args[0]);
|
|
108
|
+
const dst = abs(args[1]);
|
|
109
|
+
const out = [];
|
|
110
|
+
if (src) out.push({ path: src, role: 'move_src' });
|
|
111
|
+
if (dst) out.push({ path: dst, role: 'move_dst' });
|
|
112
|
+
return out;
|
|
113
|
+
}
|
|
114
|
+
case 'copy_file': {
|
|
115
|
+
const dst = abs(args[1]);
|
|
116
|
+
return dst ? [{ path: dst, role: 'copy_dst' }] : [];
|
|
117
|
+
}
|
|
118
|
+
default:
|
|
119
|
+
return [];
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Build the checkpoint store. Injectable seams (`fs`, `now`, `log`, `audit`,
|
|
124
|
+
// `rootDir`) keep it unit-testable without touching the real filesystem/clock.
|
|
125
|
+
// getConfig — live config (read per op so a config change takes effect)
|
|
126
|
+
// sessionId — initial session id; setSession() can realign it before any
|
|
127
|
+
// capture (chat aligns it with the chat session.id)
|
|
128
|
+
function createCheckpointStore({
|
|
129
|
+
getConfig,
|
|
130
|
+
sessionId = null,
|
|
131
|
+
rootDir = DEFAULT_ROOT,
|
|
132
|
+
fs = realFs,
|
|
133
|
+
now = () => new Date().toISOString(),
|
|
134
|
+
log,
|
|
135
|
+
audit,
|
|
136
|
+
restoreGuard,
|
|
137
|
+
} = {}) {
|
|
138
|
+
const warn = typeof log === 'function' ? log : () => {};
|
|
139
|
+
// Restore-path re-validation (Task 4.3b, Part 1). Before each file write/delete
|
|
140
|
+
// in the restore path, the target is re-checked against the CURRENT guards
|
|
141
|
+
// (isPathSafe / secret-file / protected-config / active `deny` rules) so a
|
|
142
|
+
// rewind can never re-write a path the guards now forbid — e.g. a path that
|
|
143
|
+
// was inside the CWD at capture but a `deny` rule (or --allow-anywhere being
|
|
144
|
+
// off) now covers. Injected by the executor owner (index.js); undefined in the
|
|
145
|
+
// store's own unit tests defaults to "allow" (a no-op). This is a per-target
|
|
146
|
+
// refusal (the offending file is skipped, the rest of the rewind proceeds),
|
|
147
|
+
// and `force` does NOT bypass it — force overrides only the external-mod check.
|
|
148
|
+
const _restoreGuard = typeof restoreGuard === 'function'
|
|
149
|
+
? restoreGuard
|
|
150
|
+
: () => ({ ok: true });
|
|
151
|
+
const logCheckpoint = (audit && typeof audit.logCheckpoint === 'function')
|
|
152
|
+
? audit.logCheckpoint
|
|
153
|
+
: require('./audit').logCheckpoint;
|
|
154
|
+
|
|
155
|
+
let _session = sessionId || _genId();
|
|
156
|
+
let _turnCounter = 0;
|
|
157
|
+
let _turn = { turnId: null, promptId: null, promptIndex: null, messageCountAtStart: null };
|
|
158
|
+
|
|
159
|
+
function _genId() { return crypto.randomBytes(4).toString('hex'); }
|
|
160
|
+
|
|
161
|
+
function _cfg() {
|
|
162
|
+
let c = {};
|
|
163
|
+
try { c = (getConfig ? getConfig() : {}) || {}; } catch { c = {}; }
|
|
164
|
+
return normalizeCheckpoints(c.checkpoints);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function enabled() { return _cfg().enabled; }
|
|
168
|
+
function getSession() { return _session; }
|
|
169
|
+
function setSession(id) { if (id && typeof id === 'string') _session = id; }
|
|
170
|
+
|
|
171
|
+
// Called by the agent loop at the start of each user turn so every checkpoint
|
|
172
|
+
// captured during that turn (including a subagent's) is linked to it. This is
|
|
173
|
+
// the forward-compat hook for conversation-rewind (Task 4.3b).
|
|
174
|
+
function setTurnContext({ promptIndex = null, messageCountAtStart = null, promptText = '' } = {}) {
|
|
175
|
+
_turnCounter += 1;
|
|
176
|
+
const promptId = promptText
|
|
177
|
+
? _sha256(Buffer.from(String(promptText))).slice(0, 12)
|
|
178
|
+
: null;
|
|
179
|
+
_turn = {
|
|
180
|
+
turnId: `turn-${_turnCounter}`,
|
|
181
|
+
promptId,
|
|
182
|
+
promptIndex: Number.isInteger(promptIndex) ? promptIndex : null,
|
|
183
|
+
messageCountAtStart: Number.isInteger(messageCountAtStart) ? messageCountAtStart : null,
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function _sessionDir(session = _session) { return path.join(rootDir, session); }
|
|
188
|
+
|
|
189
|
+
// Read the on-disk prior state of one target (pre-mutation) honoring the size
|
|
190
|
+
// cap. A missing file is a normal, rewindable state (rewind = delete). A
|
|
191
|
+
// directory or an oversize file is recorded but flagged not-rewindable. A
|
|
192
|
+
// genuine read error (EACCES etc.) throws so the caller can fail safe.
|
|
193
|
+
function _readPrior(p, maxBytes) {
|
|
194
|
+
let st;
|
|
195
|
+
try {
|
|
196
|
+
st = fs.statSync(p);
|
|
197
|
+
} catch (err) {
|
|
198
|
+
if (err && err.code === 'ENOENT') {
|
|
199
|
+
return { path: p, existedBefore: false, isDir: false, oversize: false, rewindable: true, priorContentB64: null, priorMode: null, priorSize: 0 };
|
|
200
|
+
}
|
|
201
|
+
throw err;
|
|
202
|
+
}
|
|
203
|
+
if (st.isDirectory()) {
|
|
204
|
+
return { path: p, existedBefore: true, isDir: true, oversize: false, rewindable: false, priorContentB64: null, priorMode: st.mode, priorSize: st.size };
|
|
205
|
+
}
|
|
206
|
+
if (st.size > maxBytes) {
|
|
207
|
+
return { path: p, existedBefore: true, isDir: false, oversize: true, rewindable: false, priorContentB64: null, priorMode: st.mode, priorSize: st.size };
|
|
208
|
+
}
|
|
209
|
+
const buf = fs.readFileSync(p);
|
|
210
|
+
return { path: p, existedBefore: true, isDir: false, oversize: false, rewindable: true, priorContentB64: buf.toString('base64'), priorMode: st.mode, priorSize: st.size };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Read the current state of a target for the after-snapshot / external-mod
|
|
214
|
+
// check: { exists, hash } (hash null if absent or unreadable).
|
|
215
|
+
function _readCurrent(p) {
|
|
216
|
+
try {
|
|
217
|
+
const st = fs.statSync(p);
|
|
218
|
+
if (st.isDirectory()) return { exists: true, hash: null, isDir: true };
|
|
219
|
+
const buf = fs.readFileSync(p);
|
|
220
|
+
return { exists: true, hash: _sha256(buf), isDir: false };
|
|
221
|
+
} catch {
|
|
222
|
+
return { exists: false, hash: null, isDir: false };
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Phase 1 (pre-mutation): read prior state into memory. Returns a handle with
|
|
227
|
+
// commit()/discard(). Returns null when checkpointing is off, the action is
|
|
228
|
+
// not checkpointable, or snapshotting fails (fail-safe — the mutation still
|
|
229
|
+
// proceeds). Nothing is written to disk until commit().
|
|
230
|
+
async function beginCapture(action, args) {
|
|
231
|
+
if (!enabled()) return null;
|
|
232
|
+
if (!CHECKPOINTABLE_ACTIONS.has(action)) return null;
|
|
233
|
+
const cfg = _cfg();
|
|
234
|
+
const targets = _targetsFor(action, args);
|
|
235
|
+
if (!targets.length) return null;
|
|
236
|
+
|
|
237
|
+
let priors;
|
|
238
|
+
try {
|
|
239
|
+
priors = targets.map((t) => Object.assign(_readPrior(t.path, cfg.max_file_bytes), { role: t.role }));
|
|
240
|
+
} catch (err) {
|
|
241
|
+
warn(`checkpoint: could not snapshot prior state for ${action} (${err.message}); rewind will be unavailable for this change`);
|
|
242
|
+
return null;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
return {
|
|
246
|
+
action,
|
|
247
|
+
targets: priors,
|
|
248
|
+
commit: () => _commit(action, priors),
|
|
249
|
+
discard: () => {},
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function _nextSeq(dir) {
|
|
254
|
+
let max = 0;
|
|
255
|
+
try {
|
|
256
|
+
for (const name of fs.readdirSync(dir)) {
|
|
257
|
+
const m = /^(\d+)\.json$/.exec(name);
|
|
258
|
+
if (m) { const n = parseInt(m[1], 10); if (n > max) max = n; }
|
|
259
|
+
}
|
|
260
|
+
} catch { /* dir may not exist yet */ }
|
|
261
|
+
return max + 1;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// Phase 2 (post-mutation, on success): record the after-state, persist the
|
|
265
|
+
// checkpoint record, audit it, and prune. Fail-safe — a write failure here is
|
|
266
|
+
// warned and swallowed (the mutation already happened).
|
|
267
|
+
function _commit(action, priors) {
|
|
268
|
+
const cfg = _cfg();
|
|
269
|
+
try {
|
|
270
|
+
const dir = _sessionDir();
|
|
271
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
272
|
+
const seq = _nextSeq(dir);
|
|
273
|
+
|
|
274
|
+
const targets = priors.map((p) => {
|
|
275
|
+
const after = _readCurrent(p.path);
|
|
276
|
+
return {
|
|
277
|
+
path: p.path,
|
|
278
|
+
role: p.role,
|
|
279
|
+
existedBefore: p.existedBefore,
|
|
280
|
+
isDir: p.isDir,
|
|
281
|
+
oversize: p.oversize,
|
|
282
|
+
rewindable: p.rewindable,
|
|
283
|
+
priorContentB64: p.priorContentB64,
|
|
284
|
+
priorMode: p.priorMode,
|
|
285
|
+
afterExists: after.exists,
|
|
286
|
+
afterHash: after.hash,
|
|
287
|
+
};
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
const record = {
|
|
291
|
+
version: 1,
|
|
292
|
+
seq,
|
|
293
|
+
session: _session,
|
|
294
|
+
ts: now(),
|
|
295
|
+
action,
|
|
296
|
+
// Conversation linkage for Task 4.3b — DO NOT remove.
|
|
297
|
+
turn: { ..._turn },
|
|
298
|
+
targets,
|
|
299
|
+
// Whole-checkpoint convenience flag: true only if every target can be
|
|
300
|
+
// restored. A checkpoint with an oversize/dir target is still recorded
|
|
301
|
+
// (so the user sees it) but flagged so rewind reports what it can't do.
|
|
302
|
+
rewindable: targets.every((t) => t.rewindable),
|
|
303
|
+
};
|
|
304
|
+
|
|
305
|
+
const file = path.join(dir, `${seq}.json`);
|
|
306
|
+
fs.writeFileSync(file, JSON.stringify(record, null, 2));
|
|
307
|
+
|
|
308
|
+
const paths = targets.map((t) => t.path).join(', ');
|
|
309
|
+
try { logCheckpoint(seq, `${action} ${paths}`); } catch { /* audit never throws */ }
|
|
310
|
+
warn(`checkpoint:${seq} ${action} ${paths}`);
|
|
311
|
+
_prune(dir, cfg.max_per_session);
|
|
312
|
+
return { seq, action, targets, rewindable: record.rewindable };
|
|
313
|
+
} catch (err) {
|
|
314
|
+
warn(`checkpoint: failed to record snapshot for ${action} (${err.message}); rewind will be unavailable for this change`);
|
|
315
|
+
return null;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function _prune(dir, maxPerSession) {
|
|
320
|
+
try {
|
|
321
|
+
const seqs = [];
|
|
322
|
+
for (const name of fs.readdirSync(dir)) {
|
|
323
|
+
const m = /^(\d+)\.json$/.exec(name);
|
|
324
|
+
if (m) seqs.push(parseInt(m[1], 10));
|
|
325
|
+
}
|
|
326
|
+
if (seqs.length <= maxPerSession) return;
|
|
327
|
+
seqs.sort((a, b) => a - b);
|
|
328
|
+
const drop = seqs.slice(0, seqs.length - maxPerSession);
|
|
329
|
+
for (const s of drop) {
|
|
330
|
+
try { fs.unlinkSync(path.join(dir, `${s}.json`)); } catch { /* best effort */ }
|
|
331
|
+
}
|
|
332
|
+
} catch { /* best effort */ }
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function _loadRecord(seq, session = _session) {
|
|
336
|
+
try {
|
|
337
|
+
const file = path.join(_sessionDir(session), `${seq}.json`);
|
|
338
|
+
return JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
339
|
+
} catch { return null; }
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// All full records for a session, sorted by seq ascending.
|
|
343
|
+
function _allRecords(session = _session) {
|
|
344
|
+
const dir = _sessionDir(session);
|
|
345
|
+
const recs = [];
|
|
346
|
+
let names = [];
|
|
347
|
+
try { names = fs.readdirSync(dir); } catch { return recs; }
|
|
348
|
+
for (const name of names) {
|
|
349
|
+
const m = /^(\d+)\.json$/.exec(name);
|
|
350
|
+
if (!m) continue;
|
|
351
|
+
const rec = _loadRecord(parseInt(m[1], 10), session);
|
|
352
|
+
if (rec) recs.push(rec);
|
|
353
|
+
}
|
|
354
|
+
recs.sort((a, b) => a.seq - b.seq);
|
|
355
|
+
return recs;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// The after-state the agent LAST left at `p` is the after-state of the
|
|
359
|
+
// highest-seq checkpoint touching that path. The external-modification check
|
|
360
|
+
// compares the current file against THIS — not against the checkpoint being
|
|
361
|
+
// rewound to — so rewinding to an earlier point (with the agent's own later
|
|
362
|
+
// writes in between) is not mistaken for an out-of-band edit.
|
|
363
|
+
function _latestAfterFor(p, records) {
|
|
364
|
+
for (let i = records.length - 1; i >= 0; i--) {
|
|
365
|
+
const t = (records[i].targets || []).find((x) => x.path === p);
|
|
366
|
+
if (t) return t;
|
|
367
|
+
}
|
|
368
|
+
return null;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// List checkpoints for the (given) session, newest last, with a short summary
|
|
372
|
+
// of what each would undo.
|
|
373
|
+
function list(session = _session) {
|
|
374
|
+
const dir = _sessionDir(session);
|
|
375
|
+
const out = [];
|
|
376
|
+
let names = [];
|
|
377
|
+
try { names = fs.readdirSync(dir); } catch { return out; }
|
|
378
|
+
for (const name of names) {
|
|
379
|
+
const m = /^(\d+)\.json$/.exec(name);
|
|
380
|
+
if (!m) continue;
|
|
381
|
+
const rec = _loadRecord(parseInt(m[1], 10), session);
|
|
382
|
+
if (rec) out.push(rec);
|
|
383
|
+
}
|
|
384
|
+
out.sort((a, b) => a.seq - b.seq);
|
|
385
|
+
return out.map((rec) => ({
|
|
386
|
+
seq: rec.seq,
|
|
387
|
+
ts: rec.ts,
|
|
388
|
+
action: rec.action,
|
|
389
|
+
turn: rec.turn || null,
|
|
390
|
+
rewindable: rec.rewindable,
|
|
391
|
+
summary: _summarize(rec),
|
|
392
|
+
paths: (rec.targets || []).map((t) => t.path),
|
|
393
|
+
}));
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
function _summarize(rec) {
|
|
397
|
+
const t = (rec.targets || []);
|
|
398
|
+
if (rec.action === 'move_file' && t.length === 2) {
|
|
399
|
+
return `move ${t[0].path} → ${t[1].path}`;
|
|
400
|
+
}
|
|
401
|
+
if (rec.action === 'copy_file') return `copy → ${t[0] ? t[0].path : '?'}`;
|
|
402
|
+
if (rec.action === 'delete_file') return `delete ${t[0] ? t[0].path : '?'}`;
|
|
403
|
+
return `${rec.action} ${t[0] ? t[0].path : '?'}`;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// Restore one target to its prior state. existedBefore → write the prior
|
|
407
|
+
// bytes back; !existedBefore → remove the file if it now exists.
|
|
408
|
+
function _restoreTarget(t) {
|
|
409
|
+
if (t.existedBefore) {
|
|
410
|
+
const dir = path.dirname(t.path);
|
|
411
|
+
if (dir && dir !== '.') fs.mkdirSync(dir, { recursive: true });
|
|
412
|
+
const buf = Buffer.from(t.priorContentB64 || '', 'base64');
|
|
413
|
+
fs.writeFileSync(t.path, buf);
|
|
414
|
+
if (Number.isInteger(t.priorMode)) {
|
|
415
|
+
try { fs.chmodSync(t.path, t.priorMode); } catch { /* best effort */ }
|
|
416
|
+
}
|
|
417
|
+
} else {
|
|
418
|
+
try { fs.unlinkSync(t.path); } catch (err) { if (err && err.code !== 'ENOENT') throw err; }
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
// Has a target been changed out-of-band since the agent last left it? Compares
|
|
423
|
+
// the current file against the LATEST agent-left after-state for that path
|
|
424
|
+
// (across the whole session), so the agent's own later writes are never
|
|
425
|
+
// mistaken for an external edit. `ref` falls back to the target itself.
|
|
426
|
+
function _externallyModified(t, ref) {
|
|
427
|
+
const expect = ref || t;
|
|
428
|
+
const cur = _readCurrent(t.path);
|
|
429
|
+
if (cur.exists !== expect.afterExists) return true;
|
|
430
|
+
if (cur.exists && expect.afterExists && cur.hash !== expect.afterHash) return true;
|
|
431
|
+
return false;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// Restore a checkpoint. `seq` may be a number or 'last'.
|
|
435
|
+
//
|
|
436
|
+
// `mode` (Task 4.3b) selects WHAT is restored:
|
|
437
|
+
// 'code' — files only (the original 4.3 behavior).
|
|
438
|
+
// 'conversation' — session history only (truncate back to the turn that
|
|
439
|
+
// produced this checkpoint); files untouched.
|
|
440
|
+
// 'both' — files + history together (default), the coherent linked
|
|
441
|
+
// state: the code as it was AND the conversation back to the
|
|
442
|
+
// point that produced it (code-without-conversation leaves
|
|
443
|
+
// the agent amnesiac; conversation-without-code leaves it
|
|
444
|
+
// reasoning over stale files).
|
|
445
|
+
//
|
|
446
|
+
// For the file half: by default an out-of-band modification BLOCKS the restore
|
|
447
|
+
// (nothing is touched) and is reported; pass { force: true } to overwrite
|
|
448
|
+
// anyway. Non-rewindable targets (oversize/dir) are skipped and reported. Each
|
|
449
|
+
// target is additionally re-validated against the current guards (Part 1) — a
|
|
450
|
+
// path the guards now forbid is REFUSED (skipped) regardless of `force`.
|
|
451
|
+
//
|
|
452
|
+
// For the conversation half: pass the live `messages` array; the (truncated)
|
|
453
|
+
// history is returned as `conversation.messages` for the caller to apply (the
|
|
454
|
+
// store never owns the conversation). Cutting is always on a turn boundary, so
|
|
455
|
+
// no orphaned tool_call/tool-result pair is ever left behind.
|
|
456
|
+
function rewind(seq, { force = false, session = _session, mode = 'both', messages = null } = {}) {
|
|
457
|
+
const wantCode = mode === 'code' || mode === 'both';
|
|
458
|
+
const wantConversation = mode === 'conversation' || mode === 'both';
|
|
459
|
+
|
|
460
|
+
let targetSeq = seq;
|
|
461
|
+
if (seq === 'last' || seq == null) {
|
|
462
|
+
const items = list(session);
|
|
463
|
+
if (!items.length) return { ok: false, error: 'No checkpoints to rewind.' };
|
|
464
|
+
targetSeq = items[items.length - 1].seq;
|
|
465
|
+
}
|
|
466
|
+
targetSeq = parseInt(targetSeq, 10);
|
|
467
|
+
const rec = _loadRecord(targetSeq, session);
|
|
468
|
+
if (!rec) return { ok: false, error: `Checkpoint ${seq} not found.` };
|
|
469
|
+
|
|
470
|
+
const out = { ok: true, seq: targetSeq, action: rec.action, mode };
|
|
471
|
+
|
|
472
|
+
// ---- Code (file) restore ------------------------------------------------
|
|
473
|
+
if (wantCode) {
|
|
474
|
+
const allRecords = _allRecords(session);
|
|
475
|
+
const targets = rec.targets || [];
|
|
476
|
+
const restorable = targets.filter((t) => t.rewindable);
|
|
477
|
+
const unrewindable = targets.filter((t) => !t.rewindable);
|
|
478
|
+
const externallyModified = restorable.filter((t) => _externallyModified(t, _latestAfterFor(t.path, allRecords)));
|
|
479
|
+
|
|
480
|
+
if (externallyModified.length && !force) {
|
|
481
|
+
return {
|
|
482
|
+
ok: false,
|
|
483
|
+
blocked: true,
|
|
484
|
+
seq: targetSeq,
|
|
485
|
+
mode,
|
|
486
|
+
externallyModified: externallyModified.map((t) => t.path),
|
|
487
|
+
unrewindable: unrewindable.map((t) => t.path),
|
|
488
|
+
message: `Rewind blocked: ${externallyModified.length} file(s) changed since the agent last wrote them — restoring would discard those out-of-band edits. Re-run with force to overwrite.`,
|
|
489
|
+
};
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
const restored = [];
|
|
493
|
+
const failed = [];
|
|
494
|
+
const refused = [];
|
|
495
|
+
for (const t of restorable) {
|
|
496
|
+
// Part 1: re-validate against the CURRENT guards before writing/deleting.
|
|
497
|
+
// `force` overrides the external-mod check above, NOT these guards.
|
|
498
|
+
let verdict;
|
|
499
|
+
try { verdict = _restoreGuard(t.path, { willDelete: !t.existedBefore, role: t.role }); }
|
|
500
|
+
catch (err) { verdict = { ok: false, reason: `guard error: ${err.message}` }; }
|
|
501
|
+
if (verdict && verdict.ok === false) {
|
|
502
|
+
refused.push({ path: t.path, reason: verdict.reason || 'refused by current guards' });
|
|
503
|
+
continue;
|
|
504
|
+
}
|
|
505
|
+
try { _restoreTarget(t); restored.push(t.path); }
|
|
506
|
+
catch (err) { failed.push({ path: t.path, error: err.message }); }
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
try { logCheckpoint(targetSeq, `rewind restored ${restored.length} file(s)${force && externallyModified.length ? ` (forced over ${externallyModified.length} external edit(s))` : ''}${refused.length ? ` (refused ${refused.length} by guards)` : ''}`); } catch { /* never throws */ }
|
|
510
|
+
|
|
511
|
+
out.restored = restored;
|
|
512
|
+
out.failed = failed;
|
|
513
|
+
out.refused = refused;
|
|
514
|
+
out.forcedOverExternal = force ? externallyModified.map((t) => t.path) : [];
|
|
515
|
+
out.unrewindable = unrewindable.map((t) => t.path);
|
|
516
|
+
if (failed.length) out.ok = false;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
// ---- Conversation (history) restore -------------------------------------
|
|
520
|
+
if (wantConversation) {
|
|
521
|
+
if (!Array.isArray(messages)) {
|
|
522
|
+
out.conversation = { ok: false, reason: 'no conversation available to rewind' };
|
|
523
|
+
if (mode === 'conversation') out.ok = false;
|
|
524
|
+
} else {
|
|
525
|
+
const plan = planConversationRewind(messages, rec.turn);
|
|
526
|
+
if (plan.ok) {
|
|
527
|
+
// Post-rewind message policy: DISCARD. The messages after the rewind
|
|
528
|
+
// point are removed from active history (returned as `removed` for the
|
|
529
|
+
// caller to optionally archive — the store does not retain them).
|
|
530
|
+
out.conversation = {
|
|
531
|
+
ok: true,
|
|
532
|
+
cutIndex: plan.cutIndex,
|
|
533
|
+
removedCount: plan.removed.length,
|
|
534
|
+
messages: plan.kept,
|
|
535
|
+
removed: plan.removed,
|
|
536
|
+
turnId: rec.turn && rec.turn.turnId ? rec.turn.turnId : null,
|
|
537
|
+
};
|
|
538
|
+
try { logCheckpoint(targetSeq, `rewind truncated conversation to ${rec.turn && rec.turn.turnId ? rec.turn.turnId : `seq ${targetSeq}`} (${plan.removed.length} message(s) discarded)`); } catch { /* never throws */ }
|
|
539
|
+
} else {
|
|
540
|
+
out.conversation = { ok: false, reason: plan.reason };
|
|
541
|
+
out.ok = false;
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
return out;
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
return {
|
|
550
|
+
enabled,
|
|
551
|
+
getSession,
|
|
552
|
+
setSession,
|
|
553
|
+
setTurnContext,
|
|
554
|
+
beginCapture,
|
|
555
|
+
list,
|
|
556
|
+
rewind,
|
|
557
|
+
// test/diagnostic seams
|
|
558
|
+
_sessionDir,
|
|
559
|
+
_loadRecord,
|
|
560
|
+
};
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
// ---------------------------------------------------------------------------
|
|
564
|
+
// Conversation-rewind helpers (Task 4.3b, Part 2) — pure, unit-testable.
|
|
565
|
+
// ---------------------------------------------------------------------------
|
|
566
|
+
|
|
567
|
+
// Native function-calling requires a consistent tool_calls ↔ tool-result map: a
|
|
568
|
+
// tool message must answer a preceding assistant tool_call, and an assistant
|
|
569
|
+
// tool_call must be answered (the 4.0c invariant — an orphan breaks the next
|
|
570
|
+
// turn). Returns the list of orphaned tool_call ids ([] = consistent). Used to
|
|
571
|
+
// PROVE a conversation cut never left a dangling pair.
|
|
572
|
+
function findOrphanedToolCalls(messages) {
|
|
573
|
+
const declared = new Set();
|
|
574
|
+
const answered = new Set();
|
|
575
|
+
for (const m of (Array.isArray(messages) ? messages : [])) {
|
|
576
|
+
if (m && m.role === 'assistant' && Array.isArray(m.tool_calls)) {
|
|
577
|
+
for (const tc of m.tool_calls) { if (tc && tc.id) declared.add(tc.id); }
|
|
578
|
+
}
|
|
579
|
+
if (m && m.role === 'tool' && m.tool_call_id) answered.add(m.tool_call_id);
|
|
580
|
+
}
|
|
581
|
+
const orphans = [];
|
|
582
|
+
for (const id of declared) { if (!answered.has(id)) orphans.push(id); }
|
|
583
|
+
for (const id of answered) { if (!declared.has(id)) orphans.push(id); }
|
|
584
|
+
return orphans;
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
// Snap an arbitrary message index back to the start of its turn — the nearest
|
|
588
|
+
// `user` message at or before `idx`. A turn always begins with a user message
|
|
589
|
+
// and all of its assistant/tool messages follow, so cutting at a user boundary
|
|
590
|
+
// can never split a tool_call/tool-result pair. Returns 0 if no user message is
|
|
591
|
+
// at/before idx (cut everything).
|
|
592
|
+
function snapToTurnBoundary(messages, idx) {
|
|
593
|
+
const n = Array.isArray(messages) ? messages.length : 0;
|
|
594
|
+
let i = Math.min(typeof idx === 'number' ? idx : n, n - 1);
|
|
595
|
+
for (; i >= 0; i--) {
|
|
596
|
+
if (messages[i] && messages[i].role === 'user') return i;
|
|
597
|
+
}
|
|
598
|
+
return 0;
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
// Locate, in the CURRENT messages array, the index of the user message that
|
|
602
|
+
// began the turn this checkpoint belongs to. Robust to index shifts (e.g.
|
|
603
|
+
// compaction since capture): prefer matching the recorded promptId (a hash of
|
|
604
|
+
// the turn's user prompt) against user messages; fall back to promptIndex /
|
|
605
|
+
// messageCountAtStart only when they still point at a user message. Returns -1
|
|
606
|
+
// when the turn can no longer be located (it was compacted/cleared away).
|
|
607
|
+
function locateTurnStart(messages, turn) {
|
|
608
|
+
if (!Array.isArray(messages) || !turn) return -1;
|
|
609
|
+
if (turn.promptId) {
|
|
610
|
+
for (let i = 0; i < messages.length; i++) {
|
|
611
|
+
const m = messages[i];
|
|
612
|
+
if (m && m.role === 'user' && typeof m.content === 'string'
|
|
613
|
+
&& _sha256(Buffer.from(m.content)).slice(0, 12) === turn.promptId) {
|
|
614
|
+
return i;
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
const candidates = [];
|
|
619
|
+
if (Number.isInteger(turn.promptIndex)) candidates.push(turn.promptIndex);
|
|
620
|
+
if (Number.isInteger(turn.messageCountAtStart)) candidates.push(turn.messageCountAtStart - 1);
|
|
621
|
+
// Prefer a candidate that is exactly a user message (a turn boundary).
|
|
622
|
+
for (const c of candidates) {
|
|
623
|
+
if (c >= 0 && c < messages.length && messages[c] && messages[c].role === 'user') return c;
|
|
624
|
+
}
|
|
625
|
+
// Otherwise fall back to the first in-range candidate — snapToTurnBoundary will
|
|
626
|
+
// walk it back to the nearest user boundary, so a stale index that lands
|
|
627
|
+
// mid-turn never cuts a tool_call/tool-result pair.
|
|
628
|
+
for (const c of candidates) {
|
|
629
|
+
if (c >= 0 && c < messages.length) return c;
|
|
630
|
+
}
|
|
631
|
+
return -1;
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
// Plan a conversation rewind: given the live messages and a checkpoint's turn
|
|
635
|
+
// linkage, return where to cut on a turn boundary. { ok, cutIndex, kept, removed }
|
|
636
|
+
// or { ok:false, reason }. The cut removes the turn's user prompt and everything
|
|
637
|
+
// after it (discard policy) — the conversation returns to the coherent state
|
|
638
|
+
// before that prompt was issued.
|
|
639
|
+
function planConversationRewind(messages, turn) {
|
|
640
|
+
if (!Array.isArray(messages)) return { ok: false, reason: 'no conversation available to rewind' };
|
|
641
|
+
if (!turn) return { ok: false, reason: 'checkpoint has no turn linkage' };
|
|
642
|
+
let idx = locateTurnStart(messages, turn);
|
|
643
|
+
if (idx < 0) return { ok: false, reason: 'could not locate this turn in the current conversation (it may have been compacted or cleared)' };
|
|
644
|
+
idx = snapToTurnBoundary(messages, idx);
|
|
645
|
+
return { ok: true, cutIndex: idx, kept: messages.slice(0, idx), removed: messages.slice(idx) };
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
// Resolve the most-recently-active session directory under rootDir (by the
|
|
649
|
+
// newest checkpoint file mtime). Used by the standalone `semalt-code rewind`
|
|
650
|
+
// command, which runs in a fresh process with no in-memory session.
|
|
651
|
+
function latestSession(rootDir = DEFAULT_ROOT, fs = realFs) {
|
|
652
|
+
let entries = [];
|
|
653
|
+
try { entries = fs.readdirSync(rootDir); } catch { return null; }
|
|
654
|
+
let best = null;
|
|
655
|
+
let bestMtime = -1;
|
|
656
|
+
for (const name of entries) {
|
|
657
|
+
const dir = path.join(rootDir, name);
|
|
658
|
+
let files = [];
|
|
659
|
+
try {
|
|
660
|
+
const st = fs.statSync(dir);
|
|
661
|
+
if (!st.isDirectory()) continue;
|
|
662
|
+
files = fs.readdirSync(dir).filter((f) => /^\d+\.json$/.test(f));
|
|
663
|
+
} catch { continue; }
|
|
664
|
+
if (!files.length) continue;
|
|
665
|
+
for (const f of files) {
|
|
666
|
+
try {
|
|
667
|
+
const m = fs.statSync(path.join(dir, f)).mtimeMs;
|
|
668
|
+
if (m > bestMtime) { bestMtime = m; best = name; }
|
|
669
|
+
} catch { /* skip */ }
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
return best;
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
// The three restore modes (Task 4.3b). `both` is the default — the coherent
|
|
676
|
+
// linked state (files + the conversation that produced them).
|
|
677
|
+
const REWIND_MODES = ['code', 'conversation', 'both'];
|
|
678
|
+
|
|
679
|
+
// Parse a user-supplied mode token, defaulting to 'both'. Unknown tokens return
|
|
680
|
+
// null so the caller can reject them rather than silently rewinding the wrong
|
|
681
|
+
// surface.
|
|
682
|
+
function normalizeRewindMode(token) {
|
|
683
|
+
if (token == null || token === '') return 'both';
|
|
684
|
+
const t = String(token).toLowerCase();
|
|
685
|
+
return REWIND_MODES.includes(t) ? t : null;
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
// Surfaced wherever rewind is shown so the user never mistakes it for full undo.
|
|
689
|
+
const SCOPE_NOTICE =
|
|
690
|
+
'Note: rewind restores file-tool changes only (write/edit/delete/move/copy/upload). '
|
|
691
|
+
+ 'Shell side effects (commands that created files, touched a DB, or hit the network) are NOT reversible.';
|
|
692
|
+
|
|
693
|
+
// Render the checkpoint list as plain text (no ANSI) for both the CLI command
|
|
694
|
+
// and the in-chat /rewind view.
|
|
695
|
+
function formatCheckpointList(items, { session } = {}) {
|
|
696
|
+
const lines = [];
|
|
697
|
+
lines.push(`Checkpoints${session ? ` (session ${session})` : ''}:`);
|
|
698
|
+
if (!items.length) {
|
|
699
|
+
lines.push(' (none — nothing to rewind yet)');
|
|
700
|
+
} else {
|
|
701
|
+
for (const it of items) {
|
|
702
|
+
const flag = it.rewindable ? '' : ' [partial: some files too large/dir — rewind unavailable]';
|
|
703
|
+
const turn = it.turn && it.turn.turnId ? ` · ${it.turn.turnId}` : '';
|
|
704
|
+
lines.push(` [${it.seq}] ${it.summary}${turn}${flag}`);
|
|
705
|
+
}
|
|
706
|
+
lines.push('');
|
|
707
|
+
lines.push('Rewind with: /rewind <seq> or /rewind last (add force to override out-of-band edits)');
|
|
708
|
+
}
|
|
709
|
+
lines.push(SCOPE_NOTICE);
|
|
710
|
+
return lines.join('\n');
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
// Render a rewind() result as plain text.
|
|
714
|
+
function formatRewindResult(res) {
|
|
715
|
+
if (!res) return 'Rewind failed: no result.';
|
|
716
|
+
if (res.error) return `Rewind failed: ${res.error}`;
|
|
717
|
+
if (res.blocked) {
|
|
718
|
+
const lines = [res.message];
|
|
719
|
+
for (const p of res.externallyModified) lines.push(` changed: ${p}`);
|
|
720
|
+
lines.push(`Re-run: /rewind ${res.seq} force`);
|
|
721
|
+
return lines.join('\n');
|
|
722
|
+
}
|
|
723
|
+
const modeLabel = res.mode ? ` [${res.mode}]` : '';
|
|
724
|
+
const lines = [`Rewound checkpoint [${res.seq}]${modeLabel} (${res.action}).`];
|
|
725
|
+
for (const p of res.restored || []) lines.push(` restored: ${p}`);
|
|
726
|
+
for (const p of res.forcedOverExternal || []) lines.push(` overwrote out-of-band edit: ${p}`);
|
|
727
|
+
for (const r of res.refused || []) lines.push(` refused (current guards): ${r.path} — ${r.reason}`);
|
|
728
|
+
for (const p of res.unrewindable || []) lines.push(` skipped (too large / directory — no snapshot): ${p}`);
|
|
729
|
+
for (const f of res.failed || []) lines.push(` FAILED: ${f.path} — ${f.error}`);
|
|
730
|
+
if (res.conversation) {
|
|
731
|
+
if (res.conversation.ok) {
|
|
732
|
+
lines.push(` conversation: rewound to ${res.conversation.turnId || 'the matching turn'} — ${res.conversation.removedCount} message(s) discarded`);
|
|
733
|
+
} else {
|
|
734
|
+
lines.push(` conversation: not rewound — ${res.conversation.reason}`);
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
lines.push(SCOPE_NOTICE);
|
|
738
|
+
return lines.join('\n');
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
module.exports = {
|
|
742
|
+
CHECKPOINTABLE_ACTIONS,
|
|
743
|
+
DEFAULT_ROOT,
|
|
744
|
+
SCOPE_NOTICE,
|
|
745
|
+
REWIND_MODES,
|
|
746
|
+
normalizeRewindMode,
|
|
747
|
+
normalizeCheckpoints,
|
|
748
|
+
createCheckpointStore,
|
|
749
|
+
latestSession,
|
|
750
|
+
formatCheckpointList,
|
|
751
|
+
formatRewindResult,
|
|
752
|
+
// Conversation-rewind helpers (Task 4.3b) — pure, exported for reuse + tests.
|
|
753
|
+
findOrphanedToolCalls,
|
|
754
|
+
snapToTurnBoundary,
|
|
755
|
+
locateTurnStart,
|
|
756
|
+
planConversationRewind,
|
|
757
|
+
};
|