@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.
Files changed (38) hide show
  1. package/README.md +63 -0
  2. package/dist/agent/loop.js +32 -7
  3. package/dist/agent/tool-dispatch.js +13 -9
  4. package/dist/cli.js +81 -27
  5. package/dist/commands/compact.js +6 -2
  6. package/dist/commands/project-context-impact.js +2 -2
  7. package/dist/commands/project.js +301 -0
  8. package/dist/commands/team.js +1166 -0
  9. package/dist/one-shot.js +2 -0
  10. package/dist/projects/image-attachments.js +155 -0
  11. package/dist/projects/save-command.js +20 -0
  12. package/dist/register/atc-file.js +802 -0
  13. package/dist/register/baseline.js +106 -0
  14. package/dist/register/measure.js +380 -0
  15. package/dist/register/records.js +159 -0
  16. package/dist/register/store.js +227 -0
  17. package/dist/repl/post-turn-status.js +2 -1
  18. package/dist/repl.js +16 -2
  19. package/dist/rewind/candidates.js +14 -1
  20. package/dist/rewind/restore.js +53 -19
  21. package/dist/router/classifier.js +24 -4
  22. package/dist/session/recap.js +8 -5
  23. package/dist/session/resume.js +37 -17
  24. package/dist/session/store.js +5 -3
  25. package/dist/session/user-prompt.js +23 -0
  26. package/dist/skill-catalog.js +33 -0
  27. package/dist/skills/bundled-skills.js +1 -1
  28. package/dist/tools/filesystem/extract-document.js +6 -0
  29. package/dist/tools/filesystem/extract-xlsx.js +139 -0
  30. package/dist/tools/filesystem/file-write.js +20 -0
  31. package/dist/tools/filesystem/read-document.js +15 -6
  32. package/dist/tools/include-snapshot.js +24 -0
  33. package/dist/tools/sap-write.js +19 -9
  34. package/dist/tools/snapshot.js +27 -0
  35. package/dist/ui/clipboard-image.js +125 -0
  36. package/dist/ui/footer.js +11 -1
  37. package/dist/ui/text-input.js +30 -2
  38. 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
  }