@cspeach/cli 1.1.14 → 1.1.16
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/README.md +63 -0
- package/dist/agent/loop.js +32 -7
- package/dist/agent/tool-dispatch.js +13 -9
- package/dist/cli.js +81 -27
- package/dist/commands/compact.js +6 -2
- package/dist/commands/project-context-impact.js +2 -2
- package/dist/commands/project.js +301 -0
- package/dist/commands/team.js +1166 -0
- package/dist/one-shot.js +2 -0
- package/dist/projects/image-attachments.js +155 -0
- package/dist/projects/save-command.js +20 -0
- package/dist/register/atc-file.js +802 -0
- package/dist/register/baseline.js +106 -0
- package/dist/register/measure.js +380 -0
- package/dist/register/records.js +159 -0
- package/dist/register/store.js +227 -0
- package/dist/repl/post-turn-status.js +2 -1
- package/dist/repl.js +16 -2
- package/dist/rewind/candidates.js +14 -1
- package/dist/rewind/restore.js +53 -19
- package/dist/router/classifier.js +24 -4
- package/dist/session/recap.js +8 -5
- package/dist/session/resume.js +37 -17
- package/dist/session/store.js +5 -3
- package/dist/session/user-prompt.js +23 -0
- package/dist/skill-catalog.js +33 -0
- package/dist/skills/bundled-skills.js +1 -1
- package/dist/tools/filesystem/extract-document.js +6 -0
- package/dist/tools/filesystem/extract-xlsx.js +139 -0
- package/dist/tools/filesystem/file-write.js +20 -0
- package/dist/tools/filesystem/read-document.js +15 -6
- package/dist/tools/include-snapshot.js +24 -0
- package/dist/tools/sap-write.js +19 -9
- package/dist/tools/snapshot.js +27 -0
- package/dist/ui/clipboard-image.js +125 -0
- package/dist/ui/footer.js +11 -1
- package/dist/ui/text-input.js +30 -2
- package/package.json +7 -4
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Project Register on disk (spec §3, §3a).
|
|
3
|
+
*
|
|
4
|
+
* Two places, both chosen by a person, never by the model:
|
|
5
|
+
* - the TEAM PROJECT FOLDER the lead creates: cspeach-team.json, baseline/,
|
|
6
|
+
* lanes.json, rulings/. Collected records sit under writers/.
|
|
7
|
+
* - this laptop's OUTBOX: where this machine's records are written. It mirrors
|
|
8
|
+
* the project-folder layout (writers/<id>/records/…) so that handing records
|
|
9
|
+
* in is a plain folder copy. By default it lives under ~/.cspeach; a developer
|
|
10
|
+
* may point it inside their own OneDrive-synced folder instead.
|
|
11
|
+
*
|
|
12
|
+
* CSPeach writes only to paths on this list. It never writes to a network
|
|
13
|
+
* location on its own initiative.
|
|
14
|
+
*/
|
|
15
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
16
|
+
import fs from 'node:fs';
|
|
17
|
+
import os from 'node:os';
|
|
18
|
+
import path from 'node:path';
|
|
19
|
+
import { cspeachRoot } from '../config/paths.js';
|
|
20
|
+
export const TEAM_FILE = 'cspeach-team.json';
|
|
21
|
+
export const BASELINE_FILE = path.join('baseline', 'objects.json');
|
|
22
|
+
export const LANES_FILE = 'lanes.json';
|
|
23
|
+
export class RegisterError extends Error {
|
|
24
|
+
}
|
|
25
|
+
export const defaultRand = (bytes) => randomBytes(bytes).toString('hex');
|
|
26
|
+
const stateFile = () => path.join(cspeachRoot(), 'register.json');
|
|
27
|
+
export function loadState() {
|
|
28
|
+
try {
|
|
29
|
+
const s = JSON.parse(stripBom(fs.readFileSync(stateFile(), 'utf8')));
|
|
30
|
+
if (s && typeof s === 'object' && s.projects)
|
|
31
|
+
return { active: s.active ?? null, projects: s.projects };
|
|
32
|
+
}
|
|
33
|
+
catch { /* first use, or unreadable: start empty */ }
|
|
34
|
+
return { active: null, projects: {} };
|
|
35
|
+
}
|
|
36
|
+
export function saveState(state) {
|
|
37
|
+
fs.mkdirSync(cspeachRoot(), { recursive: true });
|
|
38
|
+
fs.writeFileSync(stateFile(), `${JSON.stringify(state, null, 2)}\n`);
|
|
39
|
+
}
|
|
40
|
+
/** The team project this laptop is recording against, or null. Cheap: one small file read. */
|
|
41
|
+
export function activeProject() {
|
|
42
|
+
const s = loadState();
|
|
43
|
+
if (!s.active || !s.projects[s.active])
|
|
44
|
+
return null;
|
|
45
|
+
return { projectId: s.active, ...s.projects[s.active] };
|
|
46
|
+
}
|
|
47
|
+
export function defaultOutboxDir(projectId) {
|
|
48
|
+
return path.join(cspeachRoot(), 'register', projectId, 'outbox');
|
|
49
|
+
}
|
|
50
|
+
/** `mike-khan-7f3a`: the person plus this machine, so one file never has two writers. */
|
|
51
|
+
export function writerIdFor(name, machine = os.hostname()) {
|
|
52
|
+
const slug = name.trim().toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || 'writer';
|
|
53
|
+
return `${slug}-${createHash('sha256').update(machine.toLowerCase()).digest('hex').slice(0, 4)}`;
|
|
54
|
+
}
|
|
55
|
+
export function projectIdFor(name, rand = defaultRand) {
|
|
56
|
+
const slug = name.trim().toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 40) || 'project';
|
|
57
|
+
return `${slug}-${rand(2)}`;
|
|
58
|
+
}
|
|
59
|
+
export const sha256 = (text) => createHash('sha256').update(text).digest('hex');
|
|
60
|
+
/**
|
|
61
|
+
* A byte-order mark, taken off by NUMBER. Notepad, Excel and a few sync tools
|
|
62
|
+
* put one in front of a file they save as UTF-8; `JSON.parse` then refuses the
|
|
63
|
+
* whole file. Written as a character in a regex it is invisible in the source —
|
|
64
|
+
* which is how two of these were copied around and a third was forgotten — so
|
|
65
|
+
* this is the one place that knows about it, and it names the code point.
|
|
66
|
+
*/
|
|
67
|
+
export const stripBom = (text) => (text.charCodeAt(0) === 0xfeff ? text.slice(1) : text);
|
|
68
|
+
function readJson(file) {
|
|
69
|
+
let text;
|
|
70
|
+
try {
|
|
71
|
+
text = fs.readFileSync(file, 'utf8');
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
throw new RegisterError(`Cannot read ${file}`);
|
|
75
|
+
}
|
|
76
|
+
try {
|
|
77
|
+
return JSON.parse(stripBom(text));
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
throw new RegisterError(`${file} is not valid JSON`);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
export function readProjectFile(projectDir) {
|
|
84
|
+
const p = readJson(path.join(projectDir, TEAM_FILE));
|
|
85
|
+
if (p?.kind !== 'team-project' || typeof p.projectId !== 'string' || typeof p.lead?.name !== 'string') {
|
|
86
|
+
throw new RegisterError(`${path.join(projectDir, TEAM_FILE)} is not a CSPeach team project file`);
|
|
87
|
+
}
|
|
88
|
+
return p;
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* What SAP accepts as a check variant name. Checked by every caller BEFORE a
|
|
92
|
+
* run, not only at the moment of writing: a name that cannot be written must
|
|
93
|
+
* not be measured with either.
|
|
94
|
+
*/
|
|
95
|
+
export const VARIANT_NAME = /^[A-Z0-9_/]{1,40}$/;
|
|
96
|
+
/**
|
|
97
|
+
* The one field of the team project file that is set after `init`: which ATC
|
|
98
|
+
* check variant this team project measures with. Written whole, then renamed,
|
|
99
|
+
* like a record — a sync tool must never pick up half a team project file.
|
|
100
|
+
*/
|
|
101
|
+
export function writeProjectVariant(projectDir, variant) {
|
|
102
|
+
if (!VARIANT_NAME.test(variant))
|
|
103
|
+
throw new RegisterError(`"${variant}" is not an ATC check variant name.`);
|
|
104
|
+
const file = path.join(projectDir, TEAM_FILE);
|
|
105
|
+
const next = { ...readProjectFile(projectDir), atcVariant: variant };
|
|
106
|
+
fs.writeFileSync(`${file}.tmp`, `${JSON.stringify(next, null, 2)}\n`);
|
|
107
|
+
fs.renameSync(`${file}.tmp`, file);
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* One read, one sentence. `existsSync` then `readFileSync` answers only the
|
|
111
|
+
* easy failure: a folder halfway through a sync also gives EISDIR (a folder
|
|
112
|
+
* where the file goes), EBUSY and EPERM (a file the sync tool still holds, or
|
|
113
|
+
* one OneDrive has not fetched). None of those may reach a person as a stack.
|
|
114
|
+
*/
|
|
115
|
+
function readTextOrSay(file, missing) {
|
|
116
|
+
try {
|
|
117
|
+
return fs.readFileSync(file, 'utf8');
|
|
118
|
+
}
|
|
119
|
+
catch (e) {
|
|
120
|
+
if (e?.code === 'ENOENT')
|
|
121
|
+
throw new RegisterError(missing);
|
|
122
|
+
throw new RegisterError(`${file} could not be read. It may still be syncing.`);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
export function readBaseline(projectDir) {
|
|
126
|
+
const project = readProjectFile(projectDir);
|
|
127
|
+
const file = path.join(projectDir, project.baseline?.file ?? BASELINE_FILE);
|
|
128
|
+
const text = readTextOrSay(file, `The baseline file is missing: ${file}`);
|
|
129
|
+
if (project.baseline?.sha256 && sha256(text) !== project.baseline.sha256) {
|
|
130
|
+
throw new RegisterError(`${file} does not match the checksum in the team project file. It was changed or only partly copied.`);
|
|
131
|
+
}
|
|
132
|
+
// A folder halfway through a sync is the everyday reason this file is not
|
|
133
|
+
// whole JSON. A person reads a sentence about their own folder here, never a
|
|
134
|
+
// parser's stack trace about a position in a byte stream.
|
|
135
|
+
let b;
|
|
136
|
+
try {
|
|
137
|
+
b = JSON.parse(stripBom(text));
|
|
138
|
+
}
|
|
139
|
+
catch {
|
|
140
|
+
throw new RegisterError(`${file} could not be read. It may still be syncing.`);
|
|
141
|
+
}
|
|
142
|
+
if (b?.kind !== 'baseline' || !Array.isArray(b.objects))
|
|
143
|
+
throw new RegisterError(`${file} is not a baseline file`);
|
|
144
|
+
// Every caller reads `o.type` and `o.name` straight away. One hand-edited or
|
|
145
|
+
// half-copied entry that is not an object would come out as a TypeError with
|
|
146
|
+
// a stack, from whichever command happened to touch it first.
|
|
147
|
+
if (b.objects.some((o) => !o || typeof o.type !== 'string' || typeof o.name !== 'string')) {
|
|
148
|
+
throw new RegisterError(`${file} could not be read. It may still be syncing.`);
|
|
149
|
+
}
|
|
150
|
+
return b.objects;
|
|
151
|
+
}
|
|
152
|
+
export function readLanes(projectDir) {
|
|
153
|
+
const file = path.join(projectDir, LANES_FILE);
|
|
154
|
+
if (!fs.existsSync(file))
|
|
155
|
+
return [];
|
|
156
|
+
const l = readJson(file);
|
|
157
|
+
return Array.isArray(l?.lanes) ? l.lanes : [];
|
|
158
|
+
}
|
|
159
|
+
export function writeLanes(projectDir, projectId, lanes) {
|
|
160
|
+
fs.writeFileSync(path.join(projectDir, LANES_FILE), `${JSON.stringify({ kind: 'lanes', formatVersion: 1, projectId, lanes }, null, 2)}\n`);
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* A record is a short list of object names. Anything this big is a dump, a log
|
|
164
|
+
* or something that is not a record at all, and reading it would cost a
|
|
165
|
+
* gigabyte of memory before `JSON.parse` could say so.
|
|
166
|
+
*/
|
|
167
|
+
const MAX_RECORD_BYTES = 64 * 1024 * 1024;
|
|
168
|
+
/** One record file, or nothing. Counted as unreadable rather than thrown, whatever is wrong with it. */
|
|
169
|
+
function readRecord(file) {
|
|
170
|
+
try {
|
|
171
|
+
if (fs.statSync(file).size > MAX_RECORD_BYTES)
|
|
172
|
+
return { ok: false };
|
|
173
|
+
return { ok: true, value: JSON.parse(stripBom(fs.readFileSync(file, 'utf8'))) };
|
|
174
|
+
}
|
|
175
|
+
catch {
|
|
176
|
+
// A file can be listed before the sync tool has finished writing it, or be a
|
|
177
|
+
// folder, or be held open: skip and count, never fail.
|
|
178
|
+
return { ok: false };
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
/** Every record-shaped JSON under a folder: writers/<id>/records, rulings, baseline/measurement-*.json. */
|
|
182
|
+
export function collectRecords(root) {
|
|
183
|
+
const records = [];
|
|
184
|
+
let unreadable = 0;
|
|
185
|
+
let files = 0;
|
|
186
|
+
const take = (file) => {
|
|
187
|
+
files += 1;
|
|
188
|
+
const read = readRecord(file);
|
|
189
|
+
if (read.ok)
|
|
190
|
+
records.push(read.value);
|
|
191
|
+
else
|
|
192
|
+
unreadable += 1;
|
|
193
|
+
};
|
|
194
|
+
const readDir = (dir) => {
|
|
195
|
+
let names = [];
|
|
196
|
+
try {
|
|
197
|
+
names = fs.readdirSync(dir);
|
|
198
|
+
}
|
|
199
|
+
catch {
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
for (const name of names) {
|
|
203
|
+
if (!name.toLowerCase().endsWith('.json'))
|
|
204
|
+
continue;
|
|
205
|
+
take(path.join(dir, name));
|
|
206
|
+
}
|
|
207
|
+
};
|
|
208
|
+
let writers = [];
|
|
209
|
+
try {
|
|
210
|
+
writers = fs.readdirSync(path.join(root, 'writers'));
|
|
211
|
+
}
|
|
212
|
+
catch { /* none handed in yet */ }
|
|
213
|
+
for (const w of writers)
|
|
214
|
+
readDir(path.join(root, 'writers', w, 'records'));
|
|
215
|
+
readDir(path.join(root, 'rulings'));
|
|
216
|
+
try {
|
|
217
|
+
for (const name of fs.readdirSync(path.join(root, 'baseline'))) {
|
|
218
|
+
if (!/^measurement-.*\.json$/i.test(name))
|
|
219
|
+
continue;
|
|
220
|
+
// Measurement zero lands here, written by the lead and then synced: the
|
|
221
|
+
// same hazards as any other record, read the same way.
|
|
222
|
+
take(path.join(root, 'baseline', name));
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
catch { /* no baseline folder here (an outbox has none) */ }
|
|
226
|
+
return { records, unreadable, files };
|
|
227
|
+
}
|
|
@@ -23,6 +23,7 @@ import { globalStore } from '../ui/sap-state-store.js';
|
|
|
23
23
|
import { globalCredits } from '../cost/credits.js';
|
|
24
24
|
import { printStatusFooter, formatStatusFooter } from '../renderer/status-footer.js';
|
|
25
25
|
import { shouldUseInk } from '../renderer/tty.js';
|
|
26
|
+
import { isUserPrompt } from '../session/user-prompt.js';
|
|
26
27
|
export function snapshotTurnStart(session) {
|
|
27
28
|
return {
|
|
28
29
|
input: session.usage.input_tokens,
|
|
@@ -34,7 +35,7 @@ export function snapshotTurnStart(session) {
|
|
|
34
35
|
}
|
|
35
36
|
export function pushPostTurnStatus(params) {
|
|
36
37
|
const { session, selectedAlias, snapshot, chunkEmitter, turnModel } = params;
|
|
37
|
-
const userTurns = session.messages.filter(
|
|
38
|
+
const userTurns = session.messages.filter(isUserPrompt).length;
|
|
38
39
|
const lifetimeTokensAfter = session.usage.input_tokens + session.usage.output_tokens;
|
|
39
40
|
const lifetimeTokensBefore = snapshot.input + snapshot.output;
|
|
40
41
|
const turnTokens = Math.max(0, lifetimeTokensAfter - lifetimeTokensBefore);
|
package/dist/repl.js
CHANGED
|
@@ -1014,6 +1014,10 @@ export async function runRepl(opts) {
|
|
|
1014
1014
|
// from regex, not the LLM, and surface the real cause.
|
|
1015
1015
|
chunkEmitter.emit('chunk', chalk.dim(`↪ [offline classifier — proxy unreachable: ${c.transport_error ?? 'unknown'}]\n`));
|
|
1016
1016
|
}
|
|
1017
|
+
if (c.screenshot) {
|
|
1018
|
+
chunkEmitter.emit('chunk', chalk.dim(`┄ Screenshot: ${c.screenshot}
|
|
1019
|
+
`));
|
|
1020
|
+
}
|
|
1017
1021
|
const decision = decideRouting(c);
|
|
1018
1022
|
// 2026-05-17 — pass the just-used skill so the picker can
|
|
1019
1023
|
// default cursor to it (one-Enter "continue" UX for follow-up
|
|
@@ -2482,8 +2486,16 @@ export async function runRepl(opts) {
|
|
|
2482
2486
|
// user re-submits the empty pre-fill, just silently re-fill
|
|
2483
2487
|
// without re-spamming the hint line. Tracked via lastEmptyPrefill.
|
|
2484
2488
|
if (lastEmptyPrefill !== head) {
|
|
2485
|
-
|
|
2486
|
-
|
|
2489
|
+
// 2026-09-23 (Glenn's first run): "type your prompt" never said WHAT
|
|
2490
|
+
// to type, and our own skills suggest a bare next command
|
|
2491
|
+
// ("/abap-estimate next"), so the user is told to run something and
|
|
2492
|
+
// then asked an unanswerable question. Every skill now carries an
|
|
2493
|
+
// `asks` line with an example (skill-catalog.ts).
|
|
2494
|
+
const asks = SKILL_CATALOG.find((e) => `/${e.name}` === head)?.asks;
|
|
2495
|
+
console.log(chalk.dim(asks
|
|
2496
|
+
? `↪ ${head} — ${asks}`
|
|
2497
|
+
: `${'↪'} ${head} — type your prompt and press Enter`));
|
|
2498
|
+
console.log(chalk.dim(' Enter to send · /cancel to abandon'));
|
|
2487
2499
|
lastEmptyPrefill = head;
|
|
2488
2500
|
}
|
|
2489
2501
|
// 2026-05-02: bake the pre-fill into the next prompt string
|
|
@@ -2569,6 +2581,8 @@ export async function runRepl(opts) {
|
|
|
2569
2581
|
if (c.offline) {
|
|
2570
2582
|
console.log(chalk.dim(`↪ [offline classifier — proxy unreachable: ${c.transport_error ?? 'unknown'}]`));
|
|
2571
2583
|
}
|
|
2584
|
+
if (c.screenshot)
|
|
2585
|
+
console.log(chalk.dim(`┄ Screenshot: ${c.screenshot}`));
|
|
2572
2586
|
const decision = decideRouting(c);
|
|
2573
2587
|
// 2026-05-17 — pass the just-used skill so the picker can
|
|
2574
2588
|
// default cursor to it. session.skill = immediately-previous
|
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
* - sap_delete_object ⇒ restorable when a pre-delete snapshot exists (the
|
|
25
25
|
* snapshot IS the prior source; the executor recreates it).
|
|
26
26
|
*/
|
|
27
|
+
import { includeSnapshotType } from '../tools/include-snapshot.js';
|
|
27
28
|
/** The five write tools whose effects can (potentially) be rewound. */
|
|
28
29
|
const WRITE_TOOLS = new Set([
|
|
29
30
|
'sap_set_source',
|
|
@@ -116,7 +117,19 @@ function extractIdentity(tool, rawArgs) {
|
|
|
116
117
|
const args = (rawArgs ?? {});
|
|
117
118
|
if (IMPLICIT_CLASS_TOOLS.has(tool)) {
|
|
118
119
|
const object = pickString(args, 'className') ?? pickString(args, 'class_name');
|
|
119
|
-
|
|
120
|
+
if (!object)
|
|
121
|
+
return null;
|
|
122
|
+
// An include write is rewound from the INCLUDE's own snapshot
|
|
123
|
+
// (type CLAS.<INCLUDE>), never from the class main source — restoring the
|
|
124
|
+
// main source would report success while the include change stayed.
|
|
125
|
+
// No includeType recorded ⇒ unknown target ⇒ not rewindable.
|
|
126
|
+
if (tool === 'sap_set_class_include') {
|
|
127
|
+
const includeType = pickString(args, 'includeType');
|
|
128
|
+
return includeType
|
|
129
|
+
? { object: object.toUpperCase(), objType: includeSnapshotType(includeType) }
|
|
130
|
+
: null;
|
|
131
|
+
}
|
|
132
|
+
return { object: object.toUpperCase(), objType: 'CLAS' };
|
|
120
133
|
}
|
|
121
134
|
const object = pickString(args, 'name') ?? pickString(args, 'object_name');
|
|
122
135
|
if (!object)
|
package/dist/rewind/restore.js
CHANGED
|
@@ -27,6 +27,7 @@ import { getEffectiveWriteMode } from '../repl/mode-cycle.js';
|
|
|
27
27
|
import { effectiveRisk } from '../approvals/risk-floor.js';
|
|
28
28
|
import { verifySyntax, verifyActive } from '../tools/verify.js';
|
|
29
29
|
import { renderDiff } from '../repl/diff-display.js';
|
|
30
|
+
import { parseIncludeSnapshotType } from '../tools/include-snapshot.js';
|
|
30
31
|
/** Compact single-line failure text for receipts / error envelopes. */
|
|
31
32
|
function shortError(err) {
|
|
32
33
|
const msg = err instanceof Error ? err.message : String(err);
|
|
@@ -55,6 +56,10 @@ export async function restoreSnapshot(c, deps) {
|
|
|
55
56
|
if (c.snapshotId === null) {
|
|
56
57
|
return { ok: false, steps, error: 'no snapshot to restore from' };
|
|
57
58
|
}
|
|
59
|
+
// A class-include candidate (objType CLAS.<INCLUDE>) restores that include;
|
|
60
|
+
// syntax check / activation / verify then run on the CLASS.
|
|
61
|
+
const include = parseIncludeSnapshotType(c.objType);
|
|
62
|
+
const repoType = include ? 'CLAS' : c.objType;
|
|
58
63
|
// ── Step 2: Read the snapshot source (the content we will restore) ────────
|
|
59
64
|
let snapshotSource;
|
|
60
65
|
try {
|
|
@@ -67,12 +72,23 @@ export async function restoreSnapshot(c, deps) {
|
|
|
67
72
|
// A 404 means the object was deleted this session; current = null and we
|
|
68
73
|
// will recreate it. Any OTHER read failure is NOT proof of absence — stop
|
|
69
74
|
// rather than blindly treat it as a create.
|
|
75
|
+
// Class include candidates (objType CLAS.<INCLUDE>) read and write the
|
|
76
|
+
// include itself; there is no delete-restore for an include, so a 404 stops.
|
|
70
77
|
let currentSource;
|
|
71
78
|
try {
|
|
72
|
-
|
|
79
|
+
if (include) {
|
|
80
|
+
const inc = await deps.adt.classIncludes(c.object, include);
|
|
81
|
+
if (typeof inc !== 'string') {
|
|
82
|
+
return { ok: false, steps, error: 'could not read current include source' };
|
|
83
|
+
}
|
|
84
|
+
currentSource = inc;
|
|
85
|
+
}
|
|
86
|
+
else {
|
|
87
|
+
currentSource = await deps.adt.getSource(c.objType, c.object);
|
|
88
|
+
}
|
|
73
89
|
}
|
|
74
90
|
catch (err) {
|
|
75
|
-
if (err instanceof SapError && err.httpStatus === 404) {
|
|
91
|
+
if (err instanceof SapError && err.httpStatus === 404 && !include) {
|
|
76
92
|
currentSource = null;
|
|
77
93
|
}
|
|
78
94
|
else {
|
|
@@ -87,7 +103,7 @@ export async function restoreSnapshot(c, deps) {
|
|
|
87
103
|
const change = {
|
|
88
104
|
op,
|
|
89
105
|
object: c.object,
|
|
90
|
-
type:
|
|
106
|
+
type: repoType,
|
|
91
107
|
diff: renderDiff(currentSource ?? '', snapshotSource, {
|
|
92
108
|
label: `${c.objType}/${c.object}`,
|
|
93
109
|
syntaxHighlight: false,
|
|
@@ -143,20 +159,38 @@ export async function restoreSnapshot(c, deps) {
|
|
|
143
159
|
return { ok: false, steps, error: `recreate failed: ${created.error ?? `HTTP ${created.httpStatus}`}` };
|
|
144
160
|
}
|
|
145
161
|
}
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
162
|
+
if (include) {
|
|
163
|
+
// Include restore: write the include back. setClassInclude reads its own
|
|
164
|
+
// write back and returns written:false on a discarded or refused PUT.
|
|
165
|
+
let incResult;
|
|
166
|
+
try {
|
|
167
|
+
incResult = await deps.adt.setClassInclude(c.object, include, snapshotSource, deps.transport);
|
|
168
|
+
}
|
|
169
|
+
catch (err) {
|
|
170
|
+
record('② written ✗');
|
|
171
|
+
return { ok: false, steps, error: `write failed: ${shortError(err)}` };
|
|
172
|
+
}
|
|
173
|
+
if (!incResult.written) {
|
|
174
|
+
record('② written ✗');
|
|
175
|
+
return { ok: false, steps, error: `write failed: ${incResult.error ?? `HTTP ${incResult.httpStatus}`}` };
|
|
176
|
+
}
|
|
153
177
|
}
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
178
|
+
else {
|
|
179
|
+
let writeResult;
|
|
180
|
+
try {
|
|
181
|
+
writeResult = await deps.adt.setSource(c.objType, c.object, snapshotSource, deps.transport);
|
|
182
|
+
}
|
|
183
|
+
catch (err) {
|
|
184
|
+
record('② written ✗');
|
|
185
|
+
return { ok: false, steps, error: `write failed: ${shortError(err)}` };
|
|
186
|
+
}
|
|
187
|
+
// setSource RETURNS (does not throw) on HTTP 4xx — check the return value,
|
|
188
|
+
// exactly as sap_set_source does.
|
|
189
|
+
if (writeResult.error || (writeResult.writeStatus !== undefined && writeResult.writeStatus >= 400)) {
|
|
190
|
+
const detail = writeResult.error ?? `HTTP ${writeResult.writeStatus}`;
|
|
191
|
+
record('② written ✗');
|
|
192
|
+
return { ok: false, steps, error: `write failed: ${detail}` };
|
|
193
|
+
}
|
|
160
194
|
}
|
|
161
195
|
record('② written ✓');
|
|
162
196
|
// ── Step 7: Syntax check (Rule 10) — errors ⇒ STOP, do NOT activate ───────
|
|
@@ -167,7 +201,7 @@ export async function restoreSnapshot(c, deps) {
|
|
|
167
201
|
// WRITE HAPPENED; only the verification/activation call failed to run.
|
|
168
202
|
let syntax;
|
|
169
203
|
try {
|
|
170
|
-
syntax = await verifySyntax(deps.adt, c.object,
|
|
204
|
+
syntax = await verifySyntax(deps.adt, c.object, repoType);
|
|
171
205
|
}
|
|
172
206
|
catch (err) {
|
|
173
207
|
record(`③ syntax — check failed to run: ${shortError(err)}`);
|
|
@@ -191,7 +225,7 @@ export async function restoreSnapshot(c, deps) {
|
|
|
191
225
|
// ── Step 8: Activate + verify (Rule 10) ───────────────────────────────────
|
|
192
226
|
let activation;
|
|
193
227
|
try {
|
|
194
|
-
activation = await deps.adt.activate(
|
|
228
|
+
activation = await deps.adt.activate(repoType, c.object);
|
|
195
229
|
}
|
|
196
230
|
catch (err) {
|
|
197
231
|
record(`④ activate — call failed: ${shortError(err)}`);
|
|
@@ -217,7 +251,7 @@ export async function restoreSnapshot(c, deps) {
|
|
|
217
251
|
// past the receipt.
|
|
218
252
|
let active;
|
|
219
253
|
try {
|
|
220
|
-
active = await verifyActive(deps.adt, c.object,
|
|
254
|
+
active = await verifyActive(deps.adt, c.object, repoType);
|
|
221
255
|
}
|
|
222
256
|
catch (err) {
|
|
223
257
|
active = { active: false, verifyError: shortError(err) };
|
|
@@ -3,6 +3,8 @@ import { getStore } from '../auth/api-key.js';
|
|
|
3
3
|
import { classify as classifyLocal } from '../classifier/client.js';
|
|
4
4
|
import { SKILL_CATALOG, SKILL_ALIASES, resolveSkillAlias } from '../skill-catalog.js';
|
|
5
5
|
import { isBuiltinCommandHead } from '../repl/builtin-commands.js';
|
|
6
|
+
import { expandImageAttachments } from '../projects/image-attachments.js';
|
|
7
|
+
import { expandImageChips } from '../ui/clipboard-image.js';
|
|
6
8
|
// 8s — sized to cover the observed Fly cold-start (~3.2s on the 2026-05-20
|
|
7
9
|
// probe) plus headroom for a slow network. Tighter values risk timing out
|
|
8
10
|
// legitimate cold-start hits and burning the retry budget on the same.
|
|
@@ -25,7 +27,7 @@ function extractCause(err) {
|
|
|
25
27
|
}
|
|
26
28
|
return e.message;
|
|
27
29
|
}
|
|
28
|
-
async function tryFetchOnce(proxyUrl, key, prompt, context) {
|
|
30
|
+
async function tryFetchOnce(proxyUrl, key, prompt, context, images = []) {
|
|
29
31
|
const ctrl = new AbortController();
|
|
30
32
|
const timer = setTimeout(() => ctrl.abort(), FETCH_TIMEOUT_MS);
|
|
31
33
|
try {
|
|
@@ -35,7 +37,7 @@ async function tryFetchOnce(proxyUrl, key, prompt, context) {
|
|
|
35
37
|
'Authorization': `Bearer ${key}`,
|
|
36
38
|
'Content-Type': 'application/json',
|
|
37
39
|
},
|
|
38
|
-
body: JSON.stringify({ prompt, context }),
|
|
40
|
+
body: JSON.stringify(images.length > 0 ? { prompt, context, images } : { prompt, context }),
|
|
39
41
|
signal: ctrl.signal,
|
|
40
42
|
});
|
|
41
43
|
}
|
|
@@ -43,7 +45,24 @@ async function tryFetchOnce(proxyUrl, key, prompt, context) {
|
|
|
43
45
|
clearTimeout(timer);
|
|
44
46
|
}
|
|
45
47
|
}
|
|
46
|
-
|
|
48
|
+
/**
|
|
49
|
+
* Screenshot-aware routing (2026-09-23): images in the prompt (pasted
|
|
50
|
+
* [Image #N] chips or image paths) go to the router so it reads the picture
|
|
51
|
+
* with the text. Any problem (too large, too many, unreadable) → classify on
|
|
52
|
+
* text; the turn itself reports the error when it runs.
|
|
53
|
+
*/
|
|
54
|
+
async function promptImages(prompt, cwd) {
|
|
55
|
+
try {
|
|
56
|
+
const r = await expandImageAttachments(expandImageChips(prompt), cwd);
|
|
57
|
+
if (r.error)
|
|
58
|
+
return [];
|
|
59
|
+
return r.images.map((im) => ({ media_type: im.source.media_type, data: im.source.data }));
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
return [];
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
export async function classifyPrompt(prompt, context, opts = {}) {
|
|
47
66
|
// D22/D35 structural invariant (2026-06-11) — an explicit slash command
|
|
48
67
|
// must NEVER reach the classifier. The S3 battery proved the failure mode:
|
|
49
68
|
// `/abap-plan <multiline>` failed the old parseShortcut regex, the whole
|
|
@@ -61,6 +80,7 @@ export async function classifyPrompt(prompt, context) {
|
|
|
61
80
|
const key = await store.get();
|
|
62
81
|
if (!key)
|
|
63
82
|
throw new Error('no_api_key');
|
|
83
|
+
const images = await promptImages(prompt, opts.cwd ?? process.cwd());
|
|
64
84
|
let r;
|
|
65
85
|
let lastErr;
|
|
66
86
|
// Two attempts total: the second masks the common case of a stale
|
|
@@ -71,7 +91,7 @@ export async function classifyPrompt(prompt, context) {
|
|
|
71
91
|
// they bubble through as before.
|
|
72
92
|
for (let attempt = 0; attempt < 2; attempt++) {
|
|
73
93
|
try {
|
|
74
|
-
r = await tryFetchOnce(cfg.proxy_url, key, prompt, context);
|
|
94
|
+
r = await tryFetchOnce(cfg.proxy_url, key, prompt, context, images);
|
|
75
95
|
break;
|
|
76
96
|
}
|
|
77
97
|
catch (err) {
|
package/dist/session/recap.js
CHANGED
|
@@ -26,6 +26,7 @@ import { readProjectFile } from '../projects/status.js';
|
|
|
26
26
|
import { parsePlanContent } from '../projects/plan-schema.js';
|
|
27
27
|
import { statusesFromItems, isPhaseSatisfied } from '../projects/plan-run.js';
|
|
28
28
|
import { defaultWorkspaceFor } from '../projects/workspace.js';
|
|
29
|
+
import { userPromptText } from './user-prompt.js';
|
|
29
30
|
/** Wave-1 truncation rule (see /reroute): ≤60 chars total, '...' suffix. */
|
|
30
31
|
function truncatePrompt(s) {
|
|
31
32
|
return s.length > 60 ? s.slice(0, 57) + '...' : s;
|
|
@@ -37,10 +38,12 @@ function truncatePrompt(s) {
|
|
|
37
38
|
export function buildRecap(session, cwd, opts) {
|
|
38
39
|
const lines = [];
|
|
39
40
|
// ── line 1: session orientation ─────────────────────────────────────────
|
|
40
|
-
// "User turns" =
|
|
41
|
-
//
|
|
42
|
-
const
|
|
43
|
-
|
|
41
|
+
// "User turns" = typed prompts, not tool_result rounds — same counting
|
|
42
|
+
// rule as the resume picker (session/store.ts); see session/user-prompt.ts.
|
|
43
|
+
const userTexts = session.messages
|
|
44
|
+
.map(userPromptText)
|
|
45
|
+
.filter((t) => t !== null);
|
|
46
|
+
const turns = userTexts.length;
|
|
44
47
|
if (turns === 0)
|
|
45
48
|
return [];
|
|
46
49
|
const skillLabel = session.skill ? `/${session.skill}` : 'no skill';
|
|
@@ -50,7 +53,7 @@ export function buildRecap(session, cwd, opts) {
|
|
|
50
53
|
// rule as the picker's promptSnippet).
|
|
51
54
|
let prompt = session.lastUserPrompt;
|
|
52
55
|
if (!prompt) {
|
|
53
|
-
const last =
|
|
56
|
+
const last = userTexts[userTexts.length - 1];
|
|
54
57
|
prompt = last.replace(/<session_context>[\s\S]*?<\/session_context>\s*/g, '').trim() || null;
|
|
55
58
|
}
|
|
56
59
|
const quoted = prompt ? `"${truncatePrompt(prompt)}"` : '(no prompt)';
|
package/dist/session/resume.js
CHANGED
|
@@ -19,23 +19,15 @@ export async function runResume(id) {
|
|
|
19
19
|
return b.lastTurnAt.localeCompare(a.lastTurnAt);
|
|
20
20
|
});
|
|
21
21
|
targetId = await select({
|
|
22
|
-
message:
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
return {
|
|
32
|
-
value: s.id,
|
|
33
|
-
name: `${flag}${idCol} ${timeCol} ${sapCol} ${skillCol} ${turns} ${snippet}`,
|
|
34
|
-
description: s.interrupted
|
|
35
|
-
? `Interrupted (${s.interruptedReason ?? 'unknown'}) — type "continue" after resume`
|
|
36
|
-
: `${s.userTurnCount} prior turn(s) — pick up from where you left off`,
|
|
37
|
-
};
|
|
38
|
-
}),
|
|
22
|
+
message: `Pick a session to resume:
|
|
23
|
+
${formatSessionHeader()}`,
|
|
24
|
+
choices: sorted.map((s) => ({
|
|
25
|
+
value: s.id,
|
|
26
|
+
name: formatSessionRow(s),
|
|
27
|
+
description: s.interrupted
|
|
28
|
+
? `Interrupted (${s.interruptedReason ?? 'unknown'}) — type "continue" after resume`
|
|
29
|
+
: `${s.userTurnCount} prior turn(s) — pick up from where you left off`,
|
|
30
|
+
})),
|
|
39
31
|
});
|
|
40
32
|
}
|
|
41
33
|
let session;
|
|
@@ -75,3 +67,31 @@ export async function runResume(id) {
|
|
|
75
67
|
// to the persistence work in agent/loop.ts.
|
|
76
68
|
return runRepl({ resumedSession: session });
|
|
77
69
|
}
|
|
70
|
+
// Column widths shared by the rows and the header so the labels line up.
|
|
71
|
+
const W = { id: 8, time: 10, sap: 8, skill: 22, turns: 5 };
|
|
72
|
+
export function formatSessionRow(s) {
|
|
73
|
+
const flag = s.interrupted ? chalk.yellow('⚠ ') : ' ';
|
|
74
|
+
const idCol = chalk.cyan(s.id.padEnd(W.id));
|
|
75
|
+
const timeCol = chalk.gray(formatTimeAgo(s.lastTurnAt).padEnd(W.time));
|
|
76
|
+
const sapCol = chalk.gray((s.sapSystem ?? '-').padEnd(W.sap));
|
|
77
|
+
const skillCol = chalk.gray(`/${s.skill ?? '-'}`.padEnd(W.skill));
|
|
78
|
+
const turns = chalk.gray(`${s.userTurnCount}t`.padStart(W.turns));
|
|
79
|
+
const snippet = s.promptSnippet ?? chalk.dim('(no prompt)');
|
|
80
|
+
return `${flag}${idCol} ${timeCol} ${sapCol} ${skillCol} ${turns} ${snippet}`;
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* One dim line naming the row columns (2026-09-23 — rows had no labels). The
|
|
84
|
+
* picker draws a 2-char cursor gutter before each choice but not before the
|
|
85
|
+
* message, so the header adds it: gutter + flag = 4 leading spaces.
|
|
86
|
+
*/
|
|
87
|
+
export function formatSessionHeader() {
|
|
88
|
+
return chalk.dim(' ' +
|
|
89
|
+
[
|
|
90
|
+
'ID'.padEnd(W.id),
|
|
91
|
+
'LAST USED'.padEnd(W.time),
|
|
92
|
+
'SYSTEM'.padEnd(W.sap),
|
|
93
|
+
'SKILL'.padEnd(W.skill),
|
|
94
|
+
'TURNS'.padStart(W.turns),
|
|
95
|
+
].join(' ') +
|
|
96
|
+
' LAST PROMPT');
|
|
97
|
+
}
|
package/dist/session/store.js
CHANGED
|
@@ -3,6 +3,7 @@ import path from 'node:path';
|
|
|
3
3
|
import crypto from 'node:crypto';
|
|
4
4
|
import { sessionsDir, pinnedSessionsDir } from '../config/paths.js';
|
|
5
5
|
import { SCHEMA_VERSION } from './schema.js';
|
|
6
|
+
import { userPromptText, isUserPrompt } from './user-prompt.js';
|
|
6
7
|
export function makeSessionId() {
|
|
7
8
|
return crypto.randomBytes(4).toString('hex');
|
|
8
9
|
}
|
|
@@ -107,16 +108,17 @@ export async function listSessions(limit = 10) {
|
|
|
107
108
|
let promptSnippet = null;
|
|
108
109
|
for (let i = messages.length - 1; i >= 0; i--) {
|
|
109
110
|
const m = messages[i];
|
|
110
|
-
|
|
111
|
+
const text = m ? userPromptText(m) : null;
|
|
112
|
+
if (text !== null) {
|
|
111
113
|
// Strip <session_context>...</session_context> wrapper that the
|
|
112
114
|
// first turn injects, leaving only the user-typed prompt visible
|
|
113
115
|
// in the picker.
|
|
114
|
-
const stripped =
|
|
116
|
+
const stripped = text.replace(/<session_context>[\s\S]*?<\/session_context>\s*/g, '').trim();
|
|
115
117
|
promptSnippet = (stripped || '<empty>').slice(0, 80);
|
|
116
118
|
break;
|
|
117
119
|
}
|
|
118
120
|
}
|
|
119
|
-
const userTurnCount = messages.filter((m) => m
|
|
121
|
+
const userTurnCount = messages.filter((m) => m && isUserPrompt(m)).length;
|
|
120
122
|
results.push({
|
|
121
123
|
id: parsed.id,
|
|
122
124
|
startedAt: parsed.started_at,
|