@pikiloom/kernel 0.3.18 → 0.3.20

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.
@@ -0,0 +1,36 @@
1
+ /** Mime type when the file is an inlineable image, else null. */
2
+ export declare function imageMimeForFile(filePath: string): string | null;
3
+ /** The text note substituted for a non-image attachment. */
4
+ export declare function attachedFileNote(filePath: string): string;
5
+ /**
6
+ * Anthropic scales any image whose long edge exceeds 1568px before the model sees it, and —
7
+ * once a single request carries MORE THAN 20 images — outright REJECTS any image over 2000px
8
+ * on a side ("dimensions exceed max allowed size for many-image requests"). A long session
9
+ * full of screenshots easily crosses 20 images, at which point one freshly attached (or
10
+ * mid-turn steered) full-resolution screenshot gets the whole image stripped from the turn.
11
+ * Capping the long edge at Anthropic's own downscale threshold loses no detail the model
12
+ * would have kept and makes the many-image rejection unreachable. Other providers only gain
13
+ * from smaller uploads.
14
+ */
15
+ export declare const MAX_IMAGE_EDGE = 1568;
16
+ /**
17
+ * Pixel dimensions parsed from an image buffer's header (PNG / JPEG / GIF / WebP),
18
+ * or null when the bytes aren't one of those formats. Header-only: never decodes.
19
+ */
20
+ export declare function imageDimensions(buf: Buffer): {
21
+ width: number;
22
+ height: number;
23
+ } | null;
24
+ type Resizer = 'sips' | 'magick' | 'convert' | 'none';
25
+ /** Test seam: force re-probing (and optionally stub) the resize engine. */
26
+ export declare function resetResizerForTest(value?: Resizer | null): void;
27
+ /**
28
+ * Swap oversized raster images (long edge > {@link MAX_IMAGE_EDGE}) for downscaled temp
29
+ * copies, leaving everything else — small images, non-images, unreadable paths, animated
30
+ * gifs — untouched. Best-effort by design: any probe/resize failure keeps the original, so
31
+ * an attachment is never dropped; the worst case is the pre-existing behaviour. Called once
32
+ * per dispatch at the Hub chokepoints (turn start + mid-turn steer), so every driver —
33
+ * built-in or host-plugged — sends model-safe images.
34
+ */
35
+ export declare function normalizeImageAttachments(attachments: string[] | undefined, log?: (msg: string) => void): Promise<string[] | undefined>;
36
+ export {};
@@ -0,0 +1,162 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { randomUUID } from 'node:crypto';
3
+ import fs from 'node:fs';
4
+ import os from 'node:os';
5
+ import path, { extname } from 'node:path';
6
+ // Attachment vocabulary + normalization, shared by the runtime (Hub) and every driver.
7
+ // NOT part of the public API — nothing here is re-exported by any barrel.
8
+ // Every driver inlines the same image formats (the Anthropic vision set, which the
9
+ // others accept too) and notes non-image files the same way.
10
+ const IMAGE_MIME_BY_EXT = {
11
+ '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
12
+ '.gif': 'image/gif', '.webp': 'image/webp',
13
+ };
14
+ /** Mime type when the file is an inlineable image, else null. */
15
+ export function imageMimeForFile(filePath) {
16
+ return IMAGE_MIME_BY_EXT[extname(filePath).toLowerCase()] ?? null;
17
+ }
18
+ /** The text note substituted for a non-image attachment. */
19
+ export function attachedFileNote(filePath) {
20
+ return `[Attached file: ${filePath}]`;
21
+ }
22
+ /**
23
+ * Anthropic scales any image whose long edge exceeds 1568px before the model sees it, and —
24
+ * once a single request carries MORE THAN 20 images — outright REJECTS any image over 2000px
25
+ * on a side ("dimensions exceed max allowed size for many-image requests"). A long session
26
+ * full of screenshots easily crosses 20 images, at which point one freshly attached (or
27
+ * mid-turn steered) full-resolution screenshot gets the whole image stripped from the turn.
28
+ * Capping the long edge at Anthropic's own downscale threshold loses no detail the model
29
+ * would have kept and makes the many-image rejection unreachable. Other providers only gain
30
+ * from smaller uploads.
31
+ */
32
+ export const MAX_IMAGE_EDGE = 1568;
33
+ /**
34
+ * Pixel dimensions parsed from an image buffer's header (PNG / JPEG / GIF / WebP),
35
+ * or null when the bytes aren't one of those formats. Header-only: never decodes.
36
+ */
37
+ export function imageDimensions(buf) {
38
+ if (buf.length >= 24 && buf.subarray(0, 8).equals(Buffer.from('89504e470d0a1a0a', 'hex'))) {
39
+ return { width: buf.readUInt32BE(16), height: buf.readUInt32BE(20) };
40
+ }
41
+ if (buf.length >= 10 && (buf.subarray(0, 6).toString('latin1') === 'GIF87a' || buf.subarray(0, 6).toString('latin1') === 'GIF89a')) {
42
+ return { width: buf.readUInt16LE(6), height: buf.readUInt16LE(8) };
43
+ }
44
+ if (buf.length >= 4 && buf[0] === 0xff && buf[1] === 0xd8) {
45
+ // JPEG: walk segments to the first SOF (skips arbitrarily large EXIF/APPn blocks).
46
+ let i = 2;
47
+ while (i + 9 < buf.length) {
48
+ if (buf[i] !== 0xff) {
49
+ i++;
50
+ continue;
51
+ }
52
+ const marker = buf[i + 1];
53
+ if (marker === 0xff) {
54
+ i++;
55
+ continue;
56
+ } // fill byte
57
+ if (marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc) {
58
+ return { width: buf.readUInt16BE(i + 7), height: buf.readUInt16BE(i + 5) };
59
+ }
60
+ if (marker === 0xd8 || (marker >= 0xd0 && marker <= 0xd9)) {
61
+ i += 2;
62
+ continue;
63
+ } // standalone
64
+ i += 2 + buf.readUInt16BE(i + 2);
65
+ }
66
+ return null;
67
+ }
68
+ if (buf.length >= 30 && buf.subarray(0, 4).toString('latin1') === 'RIFF' && buf.subarray(8, 12).toString('latin1') === 'WEBP') {
69
+ const chunk = buf.subarray(12, 16).toString('latin1');
70
+ if (chunk === 'VP8X')
71
+ return { width: 1 + buf.readUIntLE(24, 3), height: 1 + buf.readUIntLE(27, 3) };
72
+ if (chunk === 'VP8L')
73
+ return { width: 1 + (buf.readUInt32LE(21) & 0x3fff), height: 1 + ((buf.readUInt32LE(21) >> 14) & 0x3fff) };
74
+ if (chunk === 'VP8 ')
75
+ return { width: buf.readUInt16LE(26) & 0x3fff, height: buf.readUInt16LE(28) & 0x3fff };
76
+ }
77
+ return null;
78
+ }
79
+ let resizerCache = null;
80
+ function run(cmd, args) {
81
+ return new Promise((resolve, reject) => {
82
+ execFile(cmd, args, { timeout: 10_000 }, (err) => (err ? reject(err) : resolve()));
83
+ });
84
+ }
85
+ function detectResizer() {
86
+ if (resizerCache)
87
+ return resizerCache;
88
+ if (process.platform === 'darwin')
89
+ return (resizerCache = 'sips');
90
+ resizerCache = (async () => {
91
+ for (const cmd of ['magick', 'convert']) {
92
+ try {
93
+ await run(cmd, ['-version']);
94
+ return (resizerCache = cmd);
95
+ }
96
+ catch { /* try next */ }
97
+ }
98
+ return (resizerCache = 'none');
99
+ })();
100
+ return resizerCache;
101
+ }
102
+ /** Test seam: force re-probing (and optionally stub) the resize engine. */
103
+ export function resetResizerForTest(value = null) {
104
+ resizerCache = value;
105
+ }
106
+ // Downscale `src` so its long edge is `maxEdge`, into a fresh temp file; null = keep the
107
+ // original (no engine, or the engine failed/produced nothing). WebP output support varies
108
+ // by sips version, so webp is re-encoded as PNG — the extension switch keeps the drivers'
109
+ // mime mapping truthful.
110
+ async function resizeToTemp(src, maxEdge) {
111
+ const resizer = await detectResizer();
112
+ if (resizer === 'none')
113
+ return null;
114
+ const srcExt = extname(src).toLowerCase();
115
+ const outExt = srcExt === '.webp' ? '.png' : srcExt;
116
+ const out = path.join(os.tmpdir(), `pikiloom-att-${randomUUID().slice(0, 8)}${outExt}`);
117
+ try {
118
+ if (resizer === 'sips') {
119
+ const format = srcExt === '.webp' ? ['-s', 'format', 'png'] : [];
120
+ await run('sips', [...format, '-Z', String(maxEdge), src, '--out', out]);
121
+ }
122
+ else {
123
+ await run(resizer, [src, '-resize', `${maxEdge}x${maxEdge}>`, out]);
124
+ }
125
+ return fs.statSync(out).size > 0 ? out : null;
126
+ }
127
+ catch {
128
+ fs.rmSync(out, { force: true });
129
+ return null;
130
+ }
131
+ }
132
+ /**
133
+ * Swap oversized raster images (long edge > {@link MAX_IMAGE_EDGE}) for downscaled temp
134
+ * copies, leaving everything else — small images, non-images, unreadable paths, animated
135
+ * gifs — untouched. Best-effort by design: any probe/resize failure keeps the original, so
136
+ * an attachment is never dropped; the worst case is the pre-existing behaviour. Called once
137
+ * per dispatch at the Hub chokepoints (turn start + mid-turn steer), so every driver —
138
+ * built-in or host-plugged — sends model-safe images.
139
+ */
140
+ export async function normalizeImageAttachments(attachments, log) {
141
+ if (!attachments?.length)
142
+ return attachments;
143
+ const out = [...attachments];
144
+ for (let i = 0; i < out.length; i++) {
145
+ const file = out[i];
146
+ const mime = imageMimeForFile(file);
147
+ if (!mime || mime === 'image/gif')
148
+ continue; // gif: resizing would mangle animation
149
+ try {
150
+ const dims = imageDimensions(fs.readFileSync(file));
151
+ if (!dims || Math.max(dims.width, dims.height) <= MAX_IMAGE_EDGE)
152
+ continue;
153
+ const resized = await resizeToTemp(file, MAX_IMAGE_EDGE);
154
+ if (resized) {
155
+ out[i] = resized;
156
+ log?.(`[attachments] downscaled ${path.basename(file)} ${dims.width}x${dims.height} -> long edge ${MAX_IMAGE_EDGE}`);
157
+ }
158
+ }
159
+ catch { /* unreadable — the drivers' own fallback (file note) handles it */ }
160
+ }
161
+ return out;
162
+ }
@@ -10,10 +10,7 @@ export declare function parseJsonLine(line: string): any | undefined;
10
10
  export declare function wireAbort(signal: AbortSignal, fn: () => void): () => void;
11
11
  /** SIGTERM a child, swallowing the already-dead race. */
12
12
  export declare function sigterm(proc: ChildProcess | null | undefined): void;
13
- /** Mime type when the file is an inlineable image, else null. */
14
- export declare function imageMimeForFile(filePath: string): string | null;
15
- /** The text note substituted for a non-image attachment. */
16
- export declare function attachedFileNote(filePath: string): string;
13
+ export { imageMimeForFile, attachedFileNote } from '../attachments.js';
17
14
  /**
18
15
  * Context-window occupancy as a display percent (one decimal, capped at 99.9).
19
16
  * Pass `used` as null when the caller wants "no data" rather than 0%.
@@ -1,4 +1,3 @@
1
- import { extname } from 'node:path';
2
1
  // Driver-internal helpers shared by the concrete drivers (claude/codex/gemini/acp).
3
2
  // NOT part of the public API — nothing here is re-exported by any barrel. Each helper
4
3
  // exists because the same code appeared verbatim in 3+ drivers.
@@ -43,20 +42,9 @@ export function sigterm(proc) {
43
42
  }
44
43
  catch { /* ignore */ }
45
44
  }
46
- // Attachment vocabulary: every driver inlines the same image formats (the Anthropic
47
- // vision set, which the others accept too) and notes non-image files the same way.
48
- const IMAGE_MIME_BY_EXT = {
49
- '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
50
- '.gif': 'image/gif', '.webp': 'image/webp',
51
- };
52
- /** Mime type when the file is an inlineable image, else null. */
53
- export function imageMimeForFile(filePath) {
54
- return IMAGE_MIME_BY_EXT[extname(filePath).toLowerCase()] ?? null;
55
- }
56
- /** The text note substituted for a non-image attachment. */
57
- export function attachedFileNote(filePath) {
58
- return `[Attached file: ${filePath}]`;
59
- }
45
+ // Attachment vocabulary lives in ../attachments.ts (the Hub also normalizes oversized
46
+ // images there); re-exported so drivers keep one import site for driver-internal helpers.
47
+ export { imageMimeForFile, attachedFileNote } from '../attachments.js';
60
48
  /**
61
49
  * Context-window occupancy as a display percent (one decimal, capped at 99.9).
62
50
  * Pass `used` as null when the caller wants "no data" rather than 0%.
@@ -16,7 +16,7 @@ export function emptySnapshot() {
16
16
  return { phase: 'idle', updatedAt: 0 };
17
17
  }
18
18
  const APPEND_FIELDS = ['text', 'reasoning'];
19
- const STRUCT_FIELDS = ['plan', 'toolCalls', 'subAgents', 'usage', 'artifacts', 'interactions', 'queued'];
19
+ const STRUCT_FIELDS = ['plan', 'toolCalls', 'subAgents', 'usage', 'artifacts', 'interactions', 'queued', 'compaction'];
20
20
  const SCALAR_FIELDS = ['phase', 'taskId', 'sessionId', 'agent', 'model', 'effort', 'prompt', 'activity', 'error', 'incomplete', 'startedAt', 'updatedAt', 'anchor'];
21
21
  export function diffSnapshot(prev, next) {
22
22
  const patch = {};
@@ -2,6 +2,7 @@ import { randomUUID } from 'node:crypto';
2
2
  import { diffSnapshot, emptySnapshot, makeSessionKey, splitSessionKey, } from '../protocol/index.js';
3
3
  import { SessionRunner } from './session-runner.js';
4
4
  import { buildForkSeed } from './fork.js';
5
+ import { normalizeImageAttachments } from '../attachments.js';
5
6
  export class Hub {
6
7
  deps;
7
8
  sessions = new Map();
@@ -262,7 +263,9 @@ export class Hub {
262
263
  }
263
264
  const turnInput = {
264
265
  prompt: driverPrompt,
265
- attachments: input.attachments,
266
+ // Oversized images are downscaled here and in steer() — the two dispatch chokepoints —
267
+ // so every driver (built-in or host-plugged) sends model-safe attachments.
268
+ attachments: await normalizeImageAttachments(input.attachments, this.deps.log),
266
269
  sessionId: driverSessionId,
267
270
  fork: driverFork,
268
271
  rewind: driverRewind,
@@ -467,7 +470,7 @@ export class Hub {
467
470
  }
468
471
  async steer(taskId, prompt, attachments) {
469
472
  const r = this.runnersByTask.get(taskId);
470
- return r ? r.steer(prompt, attachments) : false;
473
+ return r ? r.steer(prompt, await normalizeImageAttachments(attachments, this.deps.log)) : false;
471
474
  }
472
475
  interact(promptId, action, value) {
473
476
  for (const r of this.runnersByTask.values()) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pikiloom/kernel",
3
- "version": "0.3.18",
3
+ "version": "0.3.20",
4
4
  "description": "Heterogeneous coding agents (Claude / Codex / Gemini / ACP) -> an interaction-friendly, accumulating session snapshot + control handle, over pluggable surfaces (IM, Web, tunnel, TUI). The reusable core pikiloom itself is built on.",
5
5
  "type": "module",
6
6
  "license": "MIT",