@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
package/dist/one-shot.js
CHANGED
|
@@ -170,6 +170,8 @@ export async function runOneShot(prompt, opts) {
|
|
|
170
170
|
console.error(chalk.dim(`[offline classifier — proxy unreachable: ${c.transport_error ?? 'unknown'}]`));
|
|
171
171
|
}
|
|
172
172
|
const pct = Math.round(c.confidence * 100);
|
|
173
|
+
if (c.screenshot)
|
|
174
|
+
console.log(chalk.dim(`┄ Screenshot: ${c.screenshot}`));
|
|
173
175
|
if (c.confidence < 0.6) {
|
|
174
176
|
const alternates = [c.skill, ...c.alternates].slice(0, 3).map((s) => `/${s}`).join(', ');
|
|
175
177
|
console.error(chalk.yellow(`[classifier] Low confidence (${pct}%). Using /${c.skill}. ` +
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { promises as fs } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
/**
|
|
4
|
+
* Tier 1 screenshots (2026-09-23, spec 2026-09-16-image-attachments-1.1.5.md).
|
|
5
|
+
*
|
|
6
|
+
* An image path in the prompt — typed, `@`-referenced, or dragged in (a
|
|
7
|
+
* terminal pastes the path as text, double-quoted when it has spaces) — is
|
|
8
|
+
* read and sent as an image content block. The path in the text becomes
|
|
9
|
+
* `[attached <name>]`. In standalone mode a screenshot of an ST22 dump or an
|
|
10
|
+
* ATC list is how users show us what they see.
|
|
11
|
+
*/
|
|
12
|
+
const MEDIA_TYPES = {
|
|
13
|
+
'.png': 'image/png',
|
|
14
|
+
'.jpg': 'image/jpeg',
|
|
15
|
+
'.jpeg': 'image/jpeg',
|
|
16
|
+
'.gif': 'image/gif',
|
|
17
|
+
'.webp': 'image/webp',
|
|
18
|
+
};
|
|
19
|
+
/** Raw-file cap. The API limit is 5 MB per image as base64 (4/3 inflation). */
|
|
20
|
+
export const MAX_IMAGE_BYTES = Math.floor((5 * 1024 * 1024 * 3) / 4);
|
|
21
|
+
export const MAX_IMAGES_PER_TURN = 5;
|
|
22
|
+
/** Appended to the text block whenever an image is attached. */
|
|
23
|
+
export const IMAGE_PROMPT_NOTE = '(Anything taken from an attached image must be stated as "from the screenshot", ' +
|
|
24
|
+
'and object names read from it confirmed with the user before use.)';
|
|
25
|
+
function mediaTypeOf(p) {
|
|
26
|
+
return MEDIA_TYPES[path.extname(p).toLowerCase()] ?? null;
|
|
27
|
+
}
|
|
28
|
+
function formatBytes(n) {
|
|
29
|
+
if (n < 1024 * 1024)
|
|
30
|
+
return `${Math.max(1, Math.round(n / 1024))} KB`;
|
|
31
|
+
return `${(n / (1024 * 1024)).toFixed(1)} MB`;
|
|
32
|
+
}
|
|
33
|
+
export async function expandImageAttachments(message, cwd, log) {
|
|
34
|
+
// One token per match: optional @, then a "double" or 'single' quoted run,
|
|
35
|
+
// or a bare word.
|
|
36
|
+
const tokenRe = /@?(?:"([^"\r\n]+)"|'([^'\r\n]+)'|(\S+))/g;
|
|
37
|
+
const found = [];
|
|
38
|
+
let m;
|
|
39
|
+
while ((m = tokenRe.exec(message)) !== null) {
|
|
40
|
+
const quoted = m[1] ?? m[2];
|
|
41
|
+
if (quoted !== undefined) {
|
|
42
|
+
if (mediaTypeOf(quoted)) {
|
|
43
|
+
found.push({ index: m.index, length: m[0].length, token: quoted });
|
|
44
|
+
}
|
|
45
|
+
else {
|
|
46
|
+
// A quoted run that is not an image path ("some text with x.png", or
|
|
47
|
+
// an apostrophe pair) — rescan its words instead of skipping them.
|
|
48
|
+
const openQuote = m.index + (m[0].startsWith('@') ? 1 : 0);
|
|
49
|
+
tokenRe.lastIndex = openQuote + 1;
|
|
50
|
+
}
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
let token = m[3];
|
|
54
|
+
let length = m[0].length;
|
|
55
|
+
const trail = /[,;:.!?)\]]+$/.exec(token);
|
|
56
|
+
if (trail && trail[0].length < token.length) {
|
|
57
|
+
token = token.slice(0, -trail[0].length);
|
|
58
|
+
length -= trail[0].length;
|
|
59
|
+
}
|
|
60
|
+
if (token.includes('://') || !mediaTypeOf(token))
|
|
61
|
+
continue;
|
|
62
|
+
found.push({ index: m.index, length, token });
|
|
63
|
+
}
|
|
64
|
+
if (found.length === 0)
|
|
65
|
+
return { text: message, images: [] };
|
|
66
|
+
// Resolve against cwd; keep only files that exist. One block per file even
|
|
67
|
+
// if it is named twice.
|
|
68
|
+
const byPath = new Map();
|
|
69
|
+
const substitutions = [];
|
|
70
|
+
for (const f of found) {
|
|
71
|
+
const abs = path.resolve(cwd, f.token);
|
|
72
|
+
let size;
|
|
73
|
+
try {
|
|
74
|
+
const st = await fs.stat(abs);
|
|
75
|
+
if (!st.isFile())
|
|
76
|
+
throw new Error('not a file');
|
|
77
|
+
size = st.size;
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
if (log)
|
|
81
|
+
log(`(image ${f.token} not found — looked in ${cwd})`);
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
const name = path.basename(abs);
|
|
85
|
+
if (!byPath.has(abs))
|
|
86
|
+
byPath.set(abs, { name, size });
|
|
87
|
+
substitutions.push({ index: f.index, length: f.length, replacement: `[attached ${name}]` });
|
|
88
|
+
}
|
|
89
|
+
if (byPath.size === 0)
|
|
90
|
+
return { text: message, images: [] };
|
|
91
|
+
if (byPath.size > MAX_IMAGES_PER_TURN) {
|
|
92
|
+
return {
|
|
93
|
+
text: message,
|
|
94
|
+
images: [],
|
|
95
|
+
error: `${byPath.size} images in one message — the limit is ${MAX_IMAGES_PER_TURN}. Nothing was sent.`,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
for (const { name, size } of byPath.values()) {
|
|
99
|
+
if (size > MAX_IMAGE_BYTES) {
|
|
100
|
+
return {
|
|
101
|
+
text: message,
|
|
102
|
+
images: [],
|
|
103
|
+
error: `${name} is ${formatBytes(size)} — the limit is ${formatBytes(MAX_IMAGE_BYTES)} per image. ` +
|
|
104
|
+
'Crop it or save it as JPEG and try again. Nothing was sent.',
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
const images = [];
|
|
109
|
+
for (const [abs, { name, size }] of byPath) {
|
|
110
|
+
const data = (await fs.readFile(abs)).toString('base64');
|
|
111
|
+
images.push({ type: 'image', source: { type: 'base64', media_type: mediaTypeOf(abs), data } });
|
|
112
|
+
if (log)
|
|
113
|
+
log(`(attached ${name} — ${formatBytes(size)})`);
|
|
114
|
+
}
|
|
115
|
+
substitutions.sort((a, b) => b.index - a.index);
|
|
116
|
+
let text = message;
|
|
117
|
+
for (const s of substitutions) {
|
|
118
|
+
text = text.slice(0, s.index) + s.replacement + text.slice(s.index + s.length);
|
|
119
|
+
}
|
|
120
|
+
return { text: `${text}\n\n${IMAGE_PROMPT_NOTE}`, images };
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* The user message content sent to the model: the plain string when no image
|
|
124
|
+
* is attached (unchanged from before this feature), else images first, then
|
|
125
|
+
* the text — enrichment blocks stay in the text part.
|
|
126
|
+
*/
|
|
127
|
+
export function buildUserContent(text, images) {
|
|
128
|
+
if (images.length === 0)
|
|
129
|
+
return text;
|
|
130
|
+
return [...images, { type: 'text', text }];
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Images stay in the conversation and are re-sent every turn. Past this
|
|
134
|
+
* many, the oldest are swapped for a short text note so a long session never
|
|
135
|
+
* hits the 32 MB request cap (and stops paying for stale screenshots).
|
|
136
|
+
*/
|
|
137
|
+
export const MAX_HISTORY_IMAGES = 10;
|
|
138
|
+
/** Mutates `messages`; returns how many image blocks were removed. */
|
|
139
|
+
export function trimHistoryImages(messages, max = MAX_HISTORY_IMAGES) {
|
|
140
|
+
const slots = [];
|
|
141
|
+
for (const m of messages) {
|
|
142
|
+
if (m.role !== 'user' || !Array.isArray(m.content))
|
|
143
|
+
continue;
|
|
144
|
+
m.content.forEach((b, i) => {
|
|
145
|
+
if (b?.type === 'image')
|
|
146
|
+
slots.push({ blocks: m.content, i });
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
const excess = slots.length - max;
|
|
150
|
+
for (let k = 0; k < excess; k++) {
|
|
151
|
+
const { blocks, i } = slots[k];
|
|
152
|
+
blocks[i] = { type: 'text', text: '[image removed from history]' };
|
|
153
|
+
}
|
|
154
|
+
return Math.max(0, excess);
|
|
155
|
+
}
|
|
@@ -371,6 +371,26 @@ export async function runSaveCommand(args) {
|
|
|
371
371
|
if (spec.artefactType === 'plan')
|
|
372
372
|
writePlanHandover(path, args.log);
|
|
373
373
|
args.log(`Saved: ${path}`);
|
|
374
|
+
// Project Register (2026-09-18): when this laptop has joined a project, a
|
|
375
|
+
// decision / fix record is written to the LOCAL outbox as a by-product of the
|
|
376
|
+
// save. No project joined → `activeProject()` is one missing-file read and
|
|
377
|
+
// nothing else happens. A record failure never fails the save.
|
|
378
|
+
try {
|
|
379
|
+
const { activeProject } = await import('../register/store.js');
|
|
380
|
+
const joined = activeProject();
|
|
381
|
+
if (joined) {
|
|
382
|
+
const { recordFromEnvelope } = await import('../register/records.js');
|
|
383
|
+
const res = recordFromEnvelope(env, path, joined, { name: args.author.name }, args.cwd);
|
|
384
|
+
if (res?.file)
|
|
385
|
+
args.log(`Recorded for team project ${joined.projectId}: ${res.written} objects → ${res.file}`);
|
|
386
|
+
if (res?.refused.length) {
|
|
387
|
+
args.log(`Not recorded (${res.refused.length} not in the team project's object list): ${res.refused.slice(0, 5).join(', ')}${res.refused.length > 5 ? ', …' : ''}`);
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
catch (e) {
|
|
392
|
+
args.log(`(team project record not written: ${e instanceof Error ? e.message : String(e)})`);
|
|
393
|
+
}
|
|
374
394
|
if (planExtract && prior) {
|
|
375
395
|
args.log(`(revision v${env.version} of the existing plan — version chain preserved, no new id)`);
|
|
376
396
|
}
|