gentle-pi 4.3.0 → 4.4.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/README.md +8 -5
- package/assets/agents/gentle-ai-worker.md +1 -1
- package/assets/design-canvas/canvas.css +648 -0
- package/assets/design-canvas/canvas.js +1497 -0
- package/assets/design-canvas/index.html +88 -0
- package/assets/design-canvas/inspector.js +290 -0
- package/assets/orchestrator-delegation-reference.md +2 -2
- package/assets/orchestrator-verification.md +1 -1
- package/assets/orchestrator.md +2 -2
- package/bin/engram-guard +1 -1
- package/bin/gentle-shell.mjs +101 -151
- package/docs/bundled-install.md +47 -42
- package/docs/delegated-verification.md +6 -0
- package/docs/gentle-agents-activity.md +5 -1
- package/docs/gentle-music.md +61 -0
- package/docs/gentle-shell.md +77 -7
- package/docs/install-wizard.md +30 -6
- package/docs/permission-modes.md +7 -2
- package/docs/readme-reference.md +19 -15
- package/docs/session-profile-format.md +26 -0
- package/docs/voice.md +259 -0
- package/docs/yolo-mode.md +17 -2
- package/extensions/ask-user-choice.ts +4 -1
- package/extensions/gentle-agents.ts +173 -38
- package/extensions/gentle-ai.ts +214 -69
- package/extensions/gentle-design.ts +158 -0
- package/extensions/gentle-music.ts +289 -0
- package/extensions/gentle-shell.ts +332 -73
- package/extensions/gentle-voice.ts +11 -0
- package/extensions/session-scratch.ts +41 -22
- package/extensions/skill-registry.ts +63 -8
- package/lib/agent-profiles.ts +26 -0
- package/lib/agents-config.ts +35 -0
- package/lib/agents-protocol.ts +57 -1
- package/lib/agents-runner.ts +52 -3
- package/lib/agents-session-transport.ts +101 -6
- package/lib/agents-widget.ts +14 -1
- package/lib/auto-permission-memory.ts +48 -18
- package/lib/auto-permission-runtime.ts +21 -6
- package/lib/clipboard-image.ts +147 -0
- package/lib/command-palette-catalog.ts +2 -0
- package/lib/commit-slice-signal.ts +1 -1
- package/lib/context-backstop.ts +36 -0
- package/lib/design-canvas-server.ts +947 -0
- package/lib/design-comments.ts +437 -0
- package/lib/design-handoffs.ts +313 -0
- package/lib/design-open-browser.ts +58 -0
- package/lib/design-sources.ts +120 -0
- package/lib/external-editor.ts +31 -0
- package/lib/gentle-design.ts +513 -0
- package/lib/gentle-shell-bundled.ts +123 -49
- package/lib/gentle-shell-launcher.ts +9 -4
- package/lib/gentle-shell-startup-update.ts +61 -9
- package/lib/gentle-shell-uninstall.ts +33 -1
- package/lib/image-attachments.ts +150 -0
- package/lib/inprocess-reviewer.ts +33 -2
- package/lib/nan-provider.ts +3 -0
- package/lib/native-review-cli.ts +11 -4
- package/lib/notification-audio-native.ts +149 -13
- package/lib/notification-audio.ts +4 -0
- package/lib/notification-pulse-worker.ts +82 -18
- package/lib/notification-service.ts +5 -1
- package/lib/orchestrator-presence.ts +78 -8
- package/lib/orchestrator-scope.ts +10 -0
- package/lib/orchestrator-state.ts +11 -3
- package/lib/permission-mode-policy.ts +2 -2
- package/lib/review-host-relay.ts +11 -2
- package/lib/review-reminder-receipt.ts +24 -1
- package/lib/session-profile-append-controller.ts +713 -0
- package/lib/session-profile-disk-reader.ts +182 -0
- package/lib/session-profile-persistence.ts +15 -7
- package/lib/session-scratch.ts +52 -3
- package/lib/session-worktree-registry.ts +7 -0
- package/lib/shell-bar.ts +4 -3
- package/lib/shell-changes.ts +81 -5
- package/lib/shell-prompt.ts +4 -4
- package/lib/shell-sidebar-layout.ts +26 -6
- package/lib/shell-todo.ts +1 -1
- package/lib/verifier-admission.ts +5 -4
- package/lib/vim-editor-adapter.ts +11 -8
- package/lib/voice-artifacts.ts +188 -0
- package/lib/voice-command.ts +576 -0
- package/lib/voice-install.ts +455 -0
- package/lib/voice-platform.ts +99 -0
- package/lib/voice-push-to-talk.ts +262 -0
- package/lib/voice-session.ts +460 -0
- package/lib/voice-settings.ts +50 -0
- package/lib/voice-ui.ts +158 -0
- package/lib/yolo-session-policy.ts +58 -22
- package/package.json +1 -1
- package/runtime/gentle-shell-bundled.mjs +122 -48
- package/runtime/gentle-shell-launcher.mjs +9 -4
- package/runtime/gentle-shell-startup-update.mjs +61 -9
- package/runtime/gentle-shell-uninstall.mjs +33 -1
- package/runtime/native-review-cli.mjs +11 -4
- package/scripts/bundled-engram-migration.mjs +0 -6
- package/scripts/gentle-ai-installer.mjs +10 -10
- package/scripts/installer-downloads.mjs +7 -7
- package/scripts/installer-preflight.mjs +5 -3
- package/scripts/installer-windows.mjs +37 -7
- package/scripts/main-channel.mjs +91 -17
- package/scripts/pi-update.mjs +34 -6
- package/scripts/verify-package-files.mjs +26 -2
- package/scripts/voice-worker.cjs +476 -0
- package/skills/design/SKILL.md +110 -0
- package/tests/agent-profiles.test.ts +24 -0
- package/tests/agents-config.test.ts +61 -0
- package/tests/agents-fake-child.ts +8 -1
- package/tests/agents-protocol.test.ts +32 -0
- package/tests/agents-runner.test.ts +226 -5
- package/tests/agents-session-transport.test.ts +53 -3
- package/tests/ask-user-choice.test.ts +30 -0
- package/tests/auto-mode.test.ts +17 -0
- package/tests/auto-permission-memory-diagnostics.test.ts +56 -0
- package/tests/auto-permission-runtime.test.ts +85 -1
- package/tests/autonomous-guard.test.ts +20 -0
- package/tests/bundled-install.test.ts +3 -3
- package/tests/clipboard-image.test.ts +114 -0
- package/tests/command-palette.test.ts +1 -1
- package/tests/context-backstop.test.ts +70 -4
- package/tests/delegate-for-reason-contract.test.ts +2 -1
- package/tests/design-canvas-assets.test.ts +710 -0
- package/tests/design-canvas-server.test.ts +1096 -0
- package/tests/design-comments.test.ts +201 -0
- package/tests/design-handoffs.test.ts +282 -0
- package/tests/design-open-browser.test.ts +70 -0
- package/tests/design-sources.test.ts +132 -0
- package/tests/engram-safety.test.ts +132 -57
- package/tests/external-editor.test.ts +84 -0
- package/tests/fixtures/primary-session-prompt/gentleman.golden.md +3 -3
- package/tests/gentle-agents.test.ts +365 -20
- package/tests/gentle-ai-binary.test.ts +1 -1
- package/tests/gentle-ai-installer.test.ts +51 -51
- package/tests/gentle-ai.test.ts +292 -13
- package/tests/gentle-design.test.ts +1313 -0
- package/tests/gentle-music.test.ts +335 -0
- package/tests/gentle-shell-bundled.test.ts +301 -248
- package/tests/gentle-shell-launcher.test.ts +28 -2
- package/tests/gentle-shell-startup-update.test.ts +77 -1
- package/tests/gentle-shell-uninstall.test.ts +59 -0
- package/tests/gentle-shell.test.ts +183 -8
- package/tests/gentle-todo.test.ts +11 -0
- package/tests/image-attachments.test.ts +119 -0
- package/tests/image-paste-chips.test.ts +236 -0
- package/tests/inprocess-reviewer.test.ts +55 -2
- package/tests/installer-bundles.test.ts +12 -6
- package/tests/installer-posix-bootstrap.test.ts +8 -8
- package/tests/installer-preflight.test.ts +16 -0
- package/tests/installer-server.test.ts +2 -2
- package/tests/installer-windows-bootstrap.test.ts +61 -3
- package/tests/installer-windows-diagnostics.test.ts +111 -0
- package/tests/main-channel-upgrade.test.ts +232 -2
- package/tests/main-channel.test.ts +140 -21
- package/tests/nan-provider.test.ts +36 -0
- package/tests/native-review-capability-contract.test.ts +11 -4
- package/tests/native-review-parity.test.ts +1 -1
- package/tests/notification-audio-native.test.ts +128 -9
- package/tests/notification-customize.test.ts +17 -5
- package/tests/odd-routing-canonical-ratchet.test.ts +2 -1
- package/tests/orchestrator-budget.test.ts +5 -2
- package/tests/orchestrator-presence.test.ts +100 -2
- package/tests/orchestrator-rdd-ownership.test.ts +2 -2
- package/tests/orchestrator-scope.test.ts +14 -0
- package/tests/orchestrator-state.test.ts +29 -0
- package/tests/package-files.test.ts +34 -0
- package/tests/package-manifest.test.ts +6 -6
- package/tests/permission-mode-policy.test.ts +10 -1
- package/tests/pi-update.test.ts +99 -4
- package/tests/rdd-aware-verification-contract.test.ts +1 -1
- package/tests/review-host-relay-routing.test.ts +88 -1
- package/tests/review-host-relay.test.ts +9 -2
- package/tests/review-json-arguments.test.ts +20 -3
- package/tests/review-loadout-rdd-off-sdk.test.ts +23 -2
- package/tests/review-relay-transport-agent.test.ts +26 -0
- package/tests/review-reminder-receipt.test.ts +31 -1
- package/tests/session-changes.test.ts +2 -1
- package/tests/session-profile-append-controller-fork.test.ts +458 -0
- package/tests/session-profile-append-controller-pi.test.ts +290 -0
- package/tests/session-profile-append-controller.test.ts +731 -0
- package/tests/session-profile-disk-reader-pi.test.ts +183 -0
- package/tests/session-profile-disk-reader.test.ts +311 -0
- package/tests/session-scratch.test.ts +68 -2
- package/tests/session-worktree-registry.test.ts +13 -1
- package/tests/shell-bar.test.ts +14 -2
- package/tests/shell-changes.test.ts +148 -1
- package/tests/shell-prompt.test.ts +3 -3
- package/tests/shell-render-cache.test.ts +134 -1
- package/tests/shell-sidebar-layout.test.ts +50 -0
- package/tests/short-check-routing-contract.test.ts +1 -1
- package/tests/skill-collision-prefixes.test.ts +3 -1
- package/tests/skill-registry-watcher.test.ts +17 -5
- package/tests/skill-registry.test.ts +47 -3
- package/tests/task-size-routing-contract.test.ts +1 -1
- package/tests/test-strength-contract.test.ts +1 -1
- package/tests/verifier-admission.test.ts +18 -1
- package/tests/voice-command.test.ts +773 -0
- package/tests/voice-install.test.ts +323 -0
- package/tests/voice-platform.test.ts +176 -0
- package/tests/voice-push-to-talk.test.ts +444 -0
- package/tests/voice-session.test.ts +434 -0
- package/tests/voice-settings.test.ts +49 -0
- package/tests/voice-ui.test.ts +97 -0
- package/tests/voice-worker.test.ts +445 -0
- package/tests/weak-model-economy-prompt.test.ts +32 -0
- package/tests/windows-hidden-processes.test.ts +25 -2
- package/tests/yolo-customize.test.ts +5 -1
- package/tests/yolo-mode.test.ts +1 -1
- package/tests/yolo-session-policy-capture.test.ts +104 -0
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { lstatSync, readFileSync, readlinkSync, realpathSync } from "node:fs";
|
|
2
2
|
import { request as httpRequest } from "node:http";
|
|
3
3
|
import { createConnection } from "node:net";
|
|
4
|
-
import { dirname, posix, win32 } from "node:path";
|
|
4
|
+
import { dirname, isAbsolute, posix, win32 } from "node:path";
|
|
5
5
|
import { fileURLToPath } from "node:url";
|
|
6
6
|
|
|
7
7
|
// The launcher in a bundled install (S5, S8): pure decisions over an injected
|
|
@@ -27,6 +27,46 @@ export interface BundledInstall {
|
|
|
27
27
|
|
|
28
28
|
type Env = Record<string, string | undefined>;
|
|
29
29
|
|
|
30
|
+
/**
|
|
31
|
+
* Builds the detached shared server's environment without retaining session scratch.
|
|
32
|
+
* Windows Node can reintroduce an omitted TEMP from its parent, so use the
|
|
33
|
+
* original stable ancestor there; POSIX omits scratch temp settings instead.
|
|
34
|
+
*/
|
|
35
|
+
export function sharedEngramEnv(base: Env, platform: string = process.platform): Env {
|
|
36
|
+
const path = pathFor(platform);
|
|
37
|
+
const env: Env = {};
|
|
38
|
+
const scratch = (value: string) => /^(gp|ga)[A-Za-z0-9]{6}$/.test(path.basename(path.normalize(value)));
|
|
39
|
+
let stableTemp: string | undefined;
|
|
40
|
+
let tempKey: string | undefined;
|
|
41
|
+
for (const [key, value] of Object.entries(base)) {
|
|
42
|
+
const name = platform === "win32" ? key.toUpperCase() : key;
|
|
43
|
+
if (name.startsWith("ENGRAM_") || name === "GH_TOKEN" || name === "GITHUB_TOKEN") continue;
|
|
44
|
+
if (["TMPDIR", "TMP", "TEMP"].includes(name) && value !== undefined && scratch(value)) {
|
|
45
|
+
if (platform !== "win32") continue;
|
|
46
|
+
// A drive-relative or root-relative path depends on ambient state.
|
|
47
|
+
const root = win32.parse(value).root;
|
|
48
|
+
if (!/^(?:[A-Za-z]:[\\/]|[\\/]{2}[^\\/]+[\\/][^\\/]+[\\/]?)$/.test(root) || value.split(/[\\/]/).includes("..")) {
|
|
49
|
+
throw new Error("An absolute Windows scratch temp path is required for shared Engram.");
|
|
50
|
+
}
|
|
51
|
+
let stable = win32.normalize(value);
|
|
52
|
+
while (scratch(stable)) {
|
|
53
|
+
const parent = win32.dirname(stable);
|
|
54
|
+
if (parent === stable) throw new Error("An absolute Windows scratch temp path is required for shared Engram.");
|
|
55
|
+
stable = parent;
|
|
56
|
+
}
|
|
57
|
+
env[key] = stable;
|
|
58
|
+
stableTemp ??= stable;
|
|
59
|
+
} else {
|
|
60
|
+
env[key] = value;
|
|
61
|
+
}
|
|
62
|
+
if (name === "TEMP") tempKey = key;
|
|
63
|
+
}
|
|
64
|
+
// Keep an existing custom TEMP, even if TMP/TMPDIR chose another root.
|
|
65
|
+
if (stableTemp !== undefined && (tempKey === undefined || env[tempKey] === undefined)) env[tempKey ?? "TEMP"] = stableTemp;
|
|
66
|
+
env.ENGRAM_NO_UPDATE_CHECK = "1";
|
|
67
|
+
return env;
|
|
68
|
+
}
|
|
69
|
+
|
|
30
70
|
// The same rules scripts/bundled-install.mjs writes versions and markers with.
|
|
31
71
|
const VERSION_ID = /^[0-9A-Za-z][0-9A-Za-z.-]*$/;
|
|
32
72
|
const VERSION_MARKER = ".gentle-shell-version";
|
|
@@ -232,15 +272,14 @@ export function bundledPi(install: BundledInstall, input: { platform: string; fs
|
|
|
232
272
|
// Gentle AI's variables, never set here because the agent's shell would pass
|
|
233
273
|
// them to a gentle-ai the user runs), and the published Engram as
|
|
234
274
|
// gentle-engram's ENGRAM_BIN unless the user chose one. With a memory decision
|
|
235
|
-
// (S2, S6): the startup policy, and ENGRAM_BIN is the
|
|
236
|
-
//
|
|
237
|
-
//
|
|
275
|
+
// (S2, S6): the startup policy, and ENGRAM_BIN is the Shell-owned guard, so no
|
|
276
|
+
// provider, whatever loaded it, runs an Engram binary against a store; allowed
|
|
277
|
+
// memory reaches the shared server through ENGRAM_URL only.
|
|
238
278
|
export const ENGRAM_COMPANION_SOURCE = "npm:gentle-engram@0.4.2";
|
|
239
|
-
export const
|
|
240
|
-
export const
|
|
241
|
-
export const
|
|
279
|
+
export const ENGRAM_RECOVERY_NOTICE = "Engram memory disabled: Shell could not verify safe server reuse. Matching versions or updating alone do not prove compatibility. Reuse requires a server on 127.0.0.1:7437 with verified /compatibility evidence; Shell never stops or replaces a server already listening there. Back up existing Engram data before manual changes; do not delete it or bypass the safety check. Shell rechecks on every launch and remains usable without memory.";
|
|
280
|
+
export const ENGRAM_START_FAILED_NOTICE = "Engram memory disabled for this launch: Shell could not start the shared Engram server, or it did not report ready in time. Restart Shell to retry; if another program holds 127.0.0.1:7437, stop it first. Shell remains usable without memory.";
|
|
281
|
+
export const ENGRAM_NEWER_SCHEMA_NOTICE = "Engram memory disabled: ~/.engram was migrated by a newer Engram than Shell's bundled core, so Shell will not start a server on it. Start that newer Engram's server yourself (engram serve); Shell reuses a running server with verified /compatibility evidence. Back up the store; do not delete or downgrade it. Shell remains usable without memory.";
|
|
242
282
|
// `gentle-shell setup` publishes the verified core (ensureEngram) on every run.
|
|
243
|
-
export const ENGRAM_PROVISION_FAILED_NOTICE = "Engram memory disabled for this launch: Shell could not provision a new Engram store identity, so it cannot vouch for that store. Restart Shell to retry. If Shell keeps reporting unknown compatibility and ~/.engram holds only .instance-id files, back it up and remove it, then restart Shell. Shell remains usable without memory.";
|
|
244
283
|
export const ENGRAM_CORE_MISSING_NOTICE = "Engram memory disabled: the bundled Engram core is not installed yet. Run 'gentle-shell setup' to install the verified core, then restart Shell. Shell remains usable without memory.";
|
|
245
284
|
// The first Engram release whose server answers GET /compatibility (PR #1792).
|
|
246
285
|
export const ENGRAM_COMPATIBILITY_MIN_VERSION = "3.4.0";
|
|
@@ -261,13 +300,14 @@ function outdatedServerVersion(version: string | undefined): string | undefined
|
|
|
261
300
|
// The default endpoint gentle-engram uses without ENGRAM_URL or ENGRAM_PORT.
|
|
262
301
|
export const ENGRAM_DEFAULT_PORT = 7437;
|
|
263
302
|
export const ENGRAM_DEFAULT_URL = `http://127.0.0.1:${ENGRAM_DEFAULT_PORT}`;
|
|
264
|
-
const ENGRAM_SELECTIONS = ["ENGRAM_BIN", "ENGRAM_URL", "ENGRAM_DATA_DIR", "ENGRAM_PORT", "ENGRAM_SOCKET", "ENGRAM_DATABASE_URL"];
|
|
303
|
+
const ENGRAM_SELECTIONS = ["ENGRAM_BIN", "ENGRAM_URL", "ENGRAM_DATA_DIR", "ENGRAM_PORT", "ENGRAM_SOCKET", "ENGRAM_DATABASE_URL", "ENGRAM_SERVER_MODE"];
|
|
265
304
|
const HEX32 = /^[a-f0-9]{32}$/;
|
|
266
305
|
|
|
267
306
|
export type EngramProbe = { kind: "refused" } | { kind: "unknown" } | { kind: "listening"; compatible: boolean; healthInstanceId?: string; healthVersion?: string };
|
|
268
|
-
// `
|
|
269
|
-
// `
|
|
270
|
-
|
|
307
|
+
// `url`: the shared server, reached explicitly once /compatibility verified it.
|
|
308
|
+
// `spawn`: nothing listens yet; Shell starts the bundled core's server and must
|
|
309
|
+
// see it verified before Pi starts, or memory is disabled for that launch.
|
|
310
|
+
export type EngramStartup = { policy: "allow" | "disabled"; notice?: string; url?: string; spawn?: true };
|
|
271
311
|
|
|
272
312
|
// Without an invalid policy or an explicit Engram selection, the default
|
|
273
313
|
// endpoint may be probed; an explicit selection is never contacted.
|
|
@@ -277,26 +317,24 @@ export function engramProbeAllowed(env: Env): boolean {
|
|
|
277
317
|
}
|
|
278
318
|
|
|
279
319
|
// Evidence only: never run an executable, open SQLite or infer compatibility
|
|
280
|
-
// from versions.
|
|
281
|
-
//
|
|
282
|
-
//
|
|
283
|
-
|
|
284
|
-
|
|
320
|
+
// from versions. Like other agents' hooks, Shell shares the global server: a
|
|
321
|
+
// verified listener is reused, and with nothing listening the verified bundled
|
|
322
|
+
// core starts one on Engram's defaults, unless the default store's header
|
|
323
|
+
// (`userVersion`) shows a newer schema than that core's. A listener that does
|
|
324
|
+
// not verify is never replaced. A server version only selects the wording.
|
|
325
|
+
export function classifyEngramStartup(input: { env: Env; endpoint: EngramProbe; core: boolean; coreVersion: string; userVersion?: number }): EngramStartup {
|
|
285
326
|
if (!engramProbeAllowed(input.env)) return { policy: "disabled", notice: ENGRAM_RECOVERY_NOTICE };
|
|
286
327
|
const endpoint = input.endpoint;
|
|
287
|
-
const outdated = endpoint.kind === "listening" && !endpoint.compatible ? outdatedServerVersion(endpoint.healthVersion) : undefined;
|
|
288
|
-
const unknown: EngramStartup = { policy: "disabled", notice: outdated === undefined ? ENGRAM_RECOVERY_NOTICE : engramOutdatedServerNotice(outdated) };
|
|
289
328
|
if (endpoint.kind === "listening" && endpoint.compatible) return { policy: "allow", url: ENGRAM_DEFAULT_URL };
|
|
290
|
-
if (endpoint.kind === "refused"
|
|
291
|
-
|
|
329
|
+
if (endpoint.kind === "refused") {
|
|
330
|
+
if (!input.core) return { policy: "disabled", notice: ENGRAM_CORE_MISSING_NOTICE };
|
|
331
|
+
if (input.userVersion !== undefined && !engramHeaderAllows(input.userVersion, bundledEngramSchema(input.coreVersion))) {
|
|
332
|
+
return { policy: "disabled", notice: ENGRAM_NEWER_SCHEMA_NOTICE };
|
|
333
|
+
}
|
|
334
|
+
return { policy: "allow", url: ENGRAM_DEFAULT_URL, spawn: true };
|
|
292
335
|
}
|
|
293
|
-
const
|
|
294
|
-
|
|
295
|
-
// Shell's own store, created by this exact core: offline, the provider starts
|
|
296
|
-
// that core; a running server must be that store and that exact core.
|
|
297
|
-
if (endpoint.kind === "refused") return { policy: "allow" };
|
|
298
|
-
if (endpoint.kind === "listening" && endpoint.healthInstanceId === own.instanceId && endpoint.healthVersion === input.coreVersion) return { policy: "allow", url: ENGRAM_DEFAULT_URL };
|
|
299
|
-
return unknown;
|
|
336
|
+
const outdated = endpoint.kind === "listening" ? outdatedServerVersion(endpoint.healthVersion) : undefined;
|
|
337
|
+
return { policy: "disabled", notice: outdated === undefined ? ENGRAM_RECOVERY_NOTICE : engramOutdatedServerNotice(outdated) };
|
|
300
338
|
}
|
|
301
339
|
|
|
302
340
|
// The exact contract a startup-verified store reports (Engram GET /compatibility).
|
|
@@ -356,8 +394,8 @@ export async function probeEngramEndpoint(input: { port?: number; timeoutMs?: nu
|
|
|
356
394
|
return { kind: "listening", compatible: false, healthInstanceId: identified ? body.instance_id : undefined, healthVersion: identified ? body.version : undefined };
|
|
357
395
|
}
|
|
358
396
|
|
|
359
|
-
// Shell's
|
|
360
|
-
//
|
|
397
|
+
// Shell's record of a store an earlier Shell provisioned with its bundled core,
|
|
398
|
+
// read only by the explicit `gentle-shell memory migrate` recovery.
|
|
361
399
|
export type EngramProvenance = { schema: 1; dataDir: string; instanceId: string; coreSha256: string };
|
|
362
400
|
|
|
363
401
|
export function engramProvenancePath(prefix: string, platform: string = process.platform): string {
|
|
@@ -371,14 +409,10 @@ export function parseEngramProvenance(text: string | undefined): EngramProvenanc
|
|
|
371
409
|
return { schema: 1, dataDir: record.dataDir, instanceId: record.instanceId, coreSha256: record.coreSha256 };
|
|
372
410
|
}
|
|
373
411
|
|
|
374
|
-
export function engramProvenanceMatches(record: EngramProvenance | undefined, observed: { dataDir: string; instanceId?: string; coreSha256?: string }): boolean {
|
|
375
|
-
return record !== undefined && record.dataDir === observed.dataDir && record.instanceId === observed.instanceId && record.coreSha256 === observed.coreSha256;
|
|
376
|
-
}
|
|
377
|
-
|
|
378
412
|
// The bundled core's own schema (SQLite user_version), verified from the
|
|
379
|
-
// tagged sources v3.3.2 and v3.
|
|
413
|
+
// tagged sources v3.3.2, v3.4.0 and v3.5.2: internal/store/store.go `const schemaVersion = 1`.
|
|
380
414
|
// A pin without a verified entry leaves the header check unavailable.
|
|
381
|
-
const BUNDLED_ENGRAM_SCHEMA: Record<string, number> = { "3.3.2": 1, "3.4.0": 1 };
|
|
415
|
+
const BUNDLED_ENGRAM_SCHEMA: Record<string, number> = { "3.3.2": 1, "3.4.0": 1, "3.5.2": 1 };
|
|
382
416
|
|
|
383
417
|
export function bundledEngramSchema(coreVersion: string): number | undefined {
|
|
384
418
|
return Object.hasOwn(BUNDLED_ENGRAM_SCHEMA, coreVersion) ? BUNDLED_ENGRAM_SCHEMA[coreVersion] : undefined;
|
|
@@ -426,11 +460,6 @@ export function supportsEngramStartupPolicy(metadata: string | undefined): boole
|
|
|
426
460
|
return pkg?.name === "gentle-engram" && pkg.engram?.startupPolicy === 1;
|
|
427
461
|
}
|
|
428
462
|
|
|
429
|
-
export function supportsEngramSessionOwned(metadata: string | undefined): boolean {
|
|
430
|
-
const pkg = parseJson(metadata);
|
|
431
|
-
return supportsEngramStartupPolicy(metadata) && pkg?.engram?.sessionOwned === 1;
|
|
432
|
-
}
|
|
433
|
-
|
|
434
463
|
export function bundledChildEnv(base: Env, input: { node: string; cli: string; prefix: string; engram?: string; guard?: string; memory?: EngramStartup }): Env {
|
|
435
464
|
const env: Env = { ...base, PI_SKIP_VERSION_CHECK: "1", [GENTLE_SHELL_PREFIX_ENV]: input.prefix };
|
|
436
465
|
delete env.GENTLE_SHELL_PI;
|
|
@@ -453,14 +482,7 @@ export function bundledChildEnv(base: Env, input: { node: string; cli: string; p
|
|
|
453
482
|
return env;
|
|
454
483
|
}
|
|
455
484
|
if (memory.url !== undefined) env.ENGRAM_URL = memory.url;
|
|
456
|
-
|
|
457
|
-
env.ENGRAM_BIN = local ?? input.guard;
|
|
458
|
-
if (local && memory.owned) {
|
|
459
|
-
env.ENGRAM_SERVER_MODE = "session-owned";
|
|
460
|
-
delete env.ENGRAM_URL;
|
|
461
|
-
delete env.ENGRAM_PORT;
|
|
462
|
-
delete env.ENGRAM_SOCKET;
|
|
463
|
-
}
|
|
485
|
+
env.ENGRAM_BIN = input.guard;
|
|
464
486
|
return env;
|
|
465
487
|
}
|
|
466
488
|
|
|
@@ -491,6 +513,58 @@ export function withBundledNpmCommand(settingsText: string | undefined, command:
|
|
|
491
513
|
return { status: "write", text: settingsText === undefined || settingsText.endsWith("\n") ? `${serialized}\n` : serialized };
|
|
492
514
|
}
|
|
493
515
|
|
|
516
|
+
export type BundledNpmCommandRemoval = { status: "unchanged" } | { status: "malformed" } | { status: "removed"; text: string };
|
|
517
|
+
|
|
518
|
+
// settings.json without `npmCommand`: every other key kept in order and the
|
|
519
|
+
// file's indentation and final newline kept.
|
|
520
|
+
function settingsWithoutNpmCommand(settingsText: string, settings: Record<string, unknown>): string {
|
|
521
|
+
const indent = settingsText.match(/\{\r?\n([ \t]+)/)?.[1] ?? " ";
|
|
522
|
+
const { npmCommand: _removed, ...rest } = settings;
|
|
523
|
+
const serialized = JSON.stringify(rest, null, indent);
|
|
524
|
+
return settingsText.endsWith("\n") ? `${serialized}\n` : serialized;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
// The agent home's settings.json without a `npmCommand` naming the prefix a
|
|
528
|
+
// bundled self-uninstall removed (gentle-shell-uninstall.ts): one absolute
|
|
529
|
+
// entry inside `withinRemoved` drops the whole key, every other key kept in
|
|
530
|
+
// order and the file's indentation and final newline kept. A file that is not
|
|
531
|
+
// a JSON object is never rewritten; a command naming nothing inside it stays.
|
|
532
|
+
export function withoutBundledNpmCommand(settingsText: string | undefined, withinRemoved: (path: string) => boolean): BundledNpmCommandRemoval {
|
|
533
|
+
if (settingsText === undefined) return { status: "unchanged" };
|
|
534
|
+
const settings = parseJson(settingsText);
|
|
535
|
+
if (typeof settings !== "object" || settings === null || Array.isArray(settings)) return { status: "malformed" };
|
|
536
|
+
const current = settings.npmCommand;
|
|
537
|
+
if (!Array.isArray(current) || !current.some((part) => typeof part === "string" && isAbsolute(part) && withinRemoved(part))) return { status: "unchanged" };
|
|
538
|
+
return { status: "removed", text: settingsWithoutNpmCommand(settingsText, settings) };
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
// An `npmCommand` a bundled install wrote (scripts/bundled-install.mjs
|
|
542
|
+
// bundledNpmCommand, with npm-cli.js and --prefix in earlier releases): our
|
|
543
|
+
// node in runtime/node-<v>, then npm's settings and cache flags, and
|
|
544
|
+
// --update-notifier=false last. A command the user chose never matches, even
|
|
545
|
+
// when its program is missing.
|
|
546
|
+
export function isBundledNpmCommand(command: readonly unknown[]): boolean {
|
|
547
|
+
const [node] = command;
|
|
548
|
+
return typeof node === "string" && /[\\/]runtime[\\/]node-[^\\/]+[\\/](node\.exe|bin[\\/]node)$/.test(node)
|
|
549
|
+
&& ["--userconfig=", "--globalconfig=", "--cache="].every((flag) => command.some((part) => typeof part === "string" && part.startsWith(flag)))
|
|
550
|
+
&& command[command.length - 1] === "--update-notifier=false";
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
// The agent home's settings.json without an `npmCommand` a bundled install
|
|
554
|
+
// wrote whose executable (its first entry) no longer exists, as after a
|
|
555
|
+
// bundled self-uninstall that kept the home (#2469): Pi runs that command for
|
|
556
|
+
// every package install, so a key naming a deleted node.exe fails them all
|
|
557
|
+
// with ENOENT. A command whose executable exists, that names no absolute
|
|
558
|
+
// path, or that the user set themselves is never touched.
|
|
559
|
+
export function withoutMissingBundledNpmCommand(settingsText: string | undefined, exists: (path: string) => boolean): BundledNpmCommandRemoval {
|
|
560
|
+
if (settingsText === undefined) return { status: "unchanged" };
|
|
561
|
+
const settings = parseJson(settingsText);
|
|
562
|
+
if (typeof settings !== "object" || settings === null || Array.isArray(settings)) return { status: "malformed" };
|
|
563
|
+
const executable = Array.isArray(settings.npmCommand) ? settings.npmCommand[0] : undefined;
|
|
564
|
+
if (typeof executable !== "string" || !isAbsolute(executable) || exists(executable) || !isBundledNpmCommand(settings.npmCommand as unknown[])) return { status: "unchanged" };
|
|
565
|
+
return { status: "removed", text: settingsWithoutNpmCommand(settingsText, settings) };
|
|
566
|
+
}
|
|
567
|
+
|
|
494
568
|
export interface BundledUpdatePlan {
|
|
495
569
|
// Pi's own update was asked for: redirect it to `gentle-shell upgrade`.
|
|
496
570
|
self: boolean;
|
|
@@ -1118,12 +1118,17 @@ const CMD_EXE_SPECIAL_CHARS = /[\s"&|<>^%()]/;
|
|
|
1118
1118
|
|
|
1119
1119
|
// cmd.exe quoting is deliberately simple, not a full cmd.exe parser: wrap a
|
|
1120
1120
|
// token in double quotes when it is empty or contains whitespace or any of
|
|
1121
|
-
//
|
|
1122
|
-
//
|
|
1123
|
-
//
|
|
1121
|
+
// `&|<>^%()`. A token with an inner `"` cannot be represented: cmd.exe has no
|
|
1122
|
+
// backslash escape, so `\"` still ends the quoted section and the rest of the
|
|
1123
|
+
// token is parsed as command syntax (`x" & whoami & rem "` would run
|
|
1124
|
+
// `whoami`). Doubling the quote is not reliable either, so such a token is
|
|
1125
|
+
// refused instead of producing a command line it cannot protect.
|
|
1124
1126
|
export function quoteForCmdExe(token: string): string {
|
|
1127
|
+
if (token.includes('"')) {
|
|
1128
|
+
throw new Error("cannot pass an argument containing a double quote through cmd.exe: it would end the quoted argument and let the rest run as a command");
|
|
1129
|
+
}
|
|
1125
1130
|
if (token.length > 0 && !CMD_EXE_SPECIAL_CHARS.test(token)) return token;
|
|
1126
|
-
return `"${token
|
|
1131
|
+
return `"${token}"`;
|
|
1127
1132
|
}
|
|
1128
1133
|
|
|
1129
1134
|
const POSIX_SHELL_SPECIAL_CHARS = /[\s"'`\\$&|;<>(){}*?[\]!#~]/;
|
|
@@ -19,8 +19,32 @@ export interface StartupUpdateLookupOptions {
|
|
|
19
19
|
skipped?: string;
|
|
20
20
|
lookup: (signal: AbortSignal) => Promise<string | undefined>;
|
|
21
21
|
timeoutMs?: number;
|
|
22
|
+
/** A recorded previous lookup. Fresh enough, it answers without the network. */
|
|
23
|
+
cache?: StartupReleaseCache;
|
|
24
|
+
/** Injected clock and budget for tests; defaults to now and one day. */
|
|
25
|
+
nowMs?: number;
|
|
26
|
+
ttlMs?: number;
|
|
27
|
+
/** Called once with a live lookup's version; never for a fresh-cache answer or an unanswered lookup. */
|
|
28
|
+
cacheWrite?: (latest: string) => void;
|
|
22
29
|
}
|
|
23
30
|
|
|
31
|
+
/** The launcher config records the last live lookup so the everyday launch stays off the network. */
|
|
32
|
+
export interface StartupReleaseCache {
|
|
33
|
+
checkedAt: number;
|
|
34
|
+
latest: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Reads the config's `startupReleaseCheck` key; anything malformed is no cache. */
|
|
38
|
+
export function parseStartupReleaseCache(value: unknown): StartupReleaseCache | undefined {
|
|
39
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) return undefined;
|
|
40
|
+
const { checkedAt, latest } = value as { checkedAt?: unknown; latest?: unknown };
|
|
41
|
+
const ms = typeof checkedAt === "number" ? checkedAt : typeof checkedAt === "string" ? Date.parse(checkedAt) : Number.NaN;
|
|
42
|
+
if (!Number.isFinite(ms) || typeof latest !== "string" || latest === "") return undefined;
|
|
43
|
+
return { checkedAt: ms, latest };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export const STARTUP_RELEASE_TTL_MS = 24 * 60 * 60 * 1000;
|
|
47
|
+
|
|
24
48
|
export function shouldCheckStartupUpdate({ args, env, bundled, inputTTY, outputTTY }: StartupUpdateCheckOptions): boolean {
|
|
25
49
|
if (!bundled || !inputTTY || !outputTTY || args.command || args.piSubcommand || args.error || args.help || args.version || args.packageRoot !== undefined) {
|
|
26
50
|
return false;
|
|
@@ -61,9 +85,30 @@ function isNewer(latest: string[], current: string[]): boolean {
|
|
|
61
85
|
return false;
|
|
62
86
|
}
|
|
63
87
|
|
|
64
|
-
|
|
88
|
+
function offerFor(current: string, latest: string | undefined, skipped: string | undefined): StartupUpdateOffer | undefined {
|
|
65
89
|
const currentParts = stableParts(current);
|
|
66
90
|
if (!currentParts) return undefined;
|
|
91
|
+
if (latest === undefined || latest === skipped) return undefined;
|
|
92
|
+
const latestParts = stableParts(latest);
|
|
93
|
+
if (!latestParts || !isNewer(latestParts, currentParts)) return undefined;
|
|
94
|
+
return {
|
|
95
|
+
current,
|
|
96
|
+
latest,
|
|
97
|
+
notesUrl: `https://github.com/Gentleman-Programming/gentle-shell/releases/tag/v${latest}`,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// A malformed cached latest is never trusted — it must not suppress the
|
|
102
|
+
// network check for a whole TTL window — so it counts as no cache at all.
|
|
103
|
+
function usableCache(cache: StartupReleaseCache | undefined, ttlMs: number, nowMs: number): StartupReleaseCache | undefined {
|
|
104
|
+
if (cache === undefined) return undefined;
|
|
105
|
+
if (!Number.isFinite(cache.checkedAt) || typeof cache.latest !== "string" || cache.latest === "" || stableParts(cache.latest) === undefined) return undefined;
|
|
106
|
+
return nowMs - cache.checkedAt < ttlMs ? cache : undefined;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export async function findStartupUpdate({ current, skipped, lookup, timeoutMs = 1500, cache, nowMs = Date.now(), ttlMs = STARTUP_RELEASE_TTL_MS, cacheWrite }: StartupUpdateLookupOptions): Promise<StartupUpdateOffer | undefined> {
|
|
110
|
+
const cached = usableCache(cache, ttlMs, nowMs);
|
|
111
|
+
if (cached !== undefined) return offerFor(current, cached.latest, skipped);
|
|
67
112
|
const controller = new AbortController();
|
|
68
113
|
const deadline = Number.isFinite(timeoutMs) && timeoutMs >= 0 && timeoutMs <= 2_147_483_647 ? timeoutMs : 1500;
|
|
69
114
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
@@ -76,17 +121,24 @@ export async function findStartupUpdate({ current, skipped, lookup, timeoutMs =
|
|
|
76
121
|
});
|
|
77
122
|
// The race bounds startup even if the injected lookup ignores cancellation.
|
|
78
123
|
const latest = await Promise.race([lookup(controller.signal), timeout]);
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
if (
|
|
82
|
-
return
|
|
83
|
-
current,
|
|
84
|
-
latest,
|
|
85
|
-
notesUrl: `https://github.com/Gentleman-Programming/gentle-shell/releases/tag/v${latest}`,
|
|
86
|
-
};
|
|
124
|
+
// Only a real answer is worth a day's trust: a timeout or rejection must
|
|
125
|
+
// not suppress tomorrow's check.
|
|
126
|
+
if (typeof latest === "string") cacheWrite?.(latest);
|
|
127
|
+
return offerFor(current, latest, skipped);
|
|
87
128
|
} catch {
|
|
88
129
|
return undefined;
|
|
89
130
|
} finally {
|
|
90
131
|
clearTimeout(timer);
|
|
91
132
|
}
|
|
92
133
|
}
|
|
134
|
+
|
|
135
|
+
// Mirrors omitStartupVersion: preserves every other config key, fails loudly on
|
|
136
|
+
// an invalid config, and records the lookup's answer with an injected clock.
|
|
137
|
+
export function writeStartupReleaseCache(read: () => string | undefined, write: (text: string) => void, version: string, nowMs = Date.now()): void {
|
|
138
|
+
const text = read();
|
|
139
|
+
const config = text === undefined ? {} : JSON.parse(text);
|
|
140
|
+
if (config === null || typeof config !== "object" || Array.isArray(config)) throw new Error("Invalid launcher config");
|
|
141
|
+
const cache: StartupReleaseCache = { checkedAt: nowMs, latest: version };
|
|
142
|
+
const serialized = JSON.stringify({ ...config, startupReleaseCheck: { checkedAt: new Date(cache.checkedAt).toISOString(), latest: cache.latest } }, null, 2);
|
|
143
|
+
write(`${serialized}\n`);
|
|
144
|
+
}
|
|
@@ -1,6 +1,8 @@
|
|
|
1
|
-
import { lstatSync, readdirSync, readFileSync, readlinkSync, realpathSync, rmdirSync, rmSync } from "node:fs";
|
|
1
|
+
import { lstatSync, readdirSync, readFileSync, readlinkSync, realpathSync, renameSync, rmdirSync, rmSync, unlinkSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
2
3
|
import { createInterface } from "node:readline";
|
|
3
4
|
import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
5
|
+
import { withoutBundledNpmCommand } from "./gentle-shell-bundled.ts";
|
|
4
6
|
import { isolatedDir, launcherConfigPath, parseRawLauncherConfig, provisionedEntry, userPiHome } from "./gentle-shell-launcher.ts";
|
|
5
7
|
|
|
6
8
|
// `gentle-shell self-uninstall`: removes what Gentle Shell itself created, then
|
|
@@ -778,6 +780,18 @@ function printBundledPlan(plan: BundledPlan, input: BundledSelfUninstallInput, o
|
|
|
778
780
|
}
|
|
779
781
|
}
|
|
780
782
|
|
|
783
|
+
/** Replaces `path`'s text through a same-directory temporary file, so a crash never leaves it half-written. */
|
|
784
|
+
function writeTextAtomically(path: string, text: string): void {
|
|
785
|
+
const temporary = join(dirname(path), `.${basename(path)}.${randomUUID()}.tmp`);
|
|
786
|
+
try {
|
|
787
|
+
writeFileSync(temporary, text);
|
|
788
|
+
renameSync(temporary, path);
|
|
789
|
+
} catch (error) {
|
|
790
|
+
try { unlinkSync(temporary); } catch { /* The temporary file is best effort. */ }
|
|
791
|
+
throw error;
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
|
|
781
795
|
/**
|
|
782
796
|
* Runs `gentle-shell self-uninstall` for the bundled install at `root`. Exit
|
|
783
797
|
* codes as runSelfUninstall: 0 done or dry run, 1 refused, cancelled or failed,
|
|
@@ -837,6 +851,24 @@ export async function runBundledSelfUninstall(input: BundledSelfUninstallInput):
|
|
|
837
851
|
} catch {
|
|
838
852
|
// A leftover empty folder is harmless.
|
|
839
853
|
}
|
|
854
|
+
// A home that stays must not keep Pi pointed at the npm this uninstall removed
|
|
855
|
+
// (#2469): an `npmCommand` naming the prefix fails every later `pi install`
|
|
856
|
+
// with ENOENT naming the deleted node.exe.
|
|
857
|
+
if (!confirmation.includeShared) {
|
|
858
|
+
const settingsPath = join(plan.shellHome, "agent", "settings.json");
|
|
859
|
+
let settingsText: string | undefined;
|
|
860
|
+
try { settingsText = readFileSync(settingsPath, "utf8"); } catch { settingsText = undefined; }
|
|
861
|
+
const removal = withoutBundledNpmCommand(settingsText, (path) => within(root, path, platform));
|
|
862
|
+
if (removal.status === "removed") {
|
|
863
|
+
try {
|
|
864
|
+
writeTextAtomically(settingsPath, removal.text);
|
|
865
|
+
out(`Removed npmCommand from ${settingsPath}: it named the removed install.`);
|
|
866
|
+
} catch (error) {
|
|
867
|
+
err(`gentle-shell self-uninstall: could not remove npmCommand from ${settingsPath}: ${error instanceof Error ? error.message : String(error)}`);
|
|
868
|
+
err(`Pi package installs keep failing until the npmCommand naming ${root} is deleted from it.`);
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
}
|
|
840
872
|
if (plan.held.length > 0) {
|
|
841
873
|
try {
|
|
842
874
|
if (!input.deferRemoval) throw new Error("no deferred removal on this system");
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import type { ImageContent } from "@earendil-works/pi-ai";
|
|
2
|
+
import { getImageDimensions, type ImageDimensions } from "@earendil-works/pi-tui";
|
|
3
|
+
import type { ClipboardImage } from "./clipboard-image.ts";
|
|
4
|
+
|
|
5
|
+
// Pasted images live in the draft as atomic `[Image#N]` chips (gentle-shell#2464).
|
|
6
|
+
// The bytes stay in memory: a chip deleted from the draft is never sent, and
|
|
7
|
+
// the chips still in the text become real image attachments on submit.
|
|
8
|
+
//
|
|
9
|
+
// The token has no whitespace on purpose: Pi's word motions, word deletion and
|
|
10
|
+
// word wrap treat any segment containing a space as whitespace unless it is
|
|
11
|
+
// one of Pi's own paste markers, so `[Image #1]` would be skipped and eaten
|
|
12
|
+
// together with the word before it.
|
|
13
|
+
|
|
14
|
+
export const IMAGE_TOKEN_PATTERN = /\[Image#(\d+)\]/g;
|
|
15
|
+
|
|
16
|
+
export interface ImageAttachment {
|
|
17
|
+
id: number;
|
|
18
|
+
token: string;
|
|
19
|
+
mimeType: string;
|
|
20
|
+
byteLength: number;
|
|
21
|
+
base64: string;
|
|
22
|
+
dimensions?: ImageDimensions;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface SubmittedImages {
|
|
26
|
+
text: string;
|
|
27
|
+
images: ImageContent[];
|
|
28
|
+
/** Chips the active model cannot receive; their tokens become a plain note. */
|
|
29
|
+
omitted: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface TextSpan { start: number; end: number }
|
|
33
|
+
|
|
34
|
+
interface Segment { segment: string; index: number; input: string }
|
|
35
|
+
|
|
36
|
+
export function imageToken(id: number): string {
|
|
37
|
+
return `[Image#${id}]`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export class ImageAttachments {
|
|
41
|
+
private readonly entries = new Map<number, ImageAttachment>();
|
|
42
|
+
// Never reset: Pi's prompt history keeps the raw submitted text, so a
|
|
43
|
+
// reused id would let a recalled `[Image#1]` bind to a different image.
|
|
44
|
+
private counter = 0;
|
|
45
|
+
|
|
46
|
+
get size(): number { return this.entries.size; }
|
|
47
|
+
|
|
48
|
+
has(id: number): boolean { return this.entries.has(id); }
|
|
49
|
+
|
|
50
|
+
add(image: ClipboardImage): ImageAttachment {
|
|
51
|
+
const id = ++this.counter;
|
|
52
|
+
const base64 = Buffer.from(image.bytes).toString("base64");
|
|
53
|
+
const attachment: ImageAttachment = {
|
|
54
|
+
id,
|
|
55
|
+
token: imageToken(id),
|
|
56
|
+
mimeType: image.mimeType,
|
|
57
|
+
byteLength: image.bytes.length,
|
|
58
|
+
base64,
|
|
59
|
+
dimensions: getImageDimensions(base64, image.mimeType) ?? undefined,
|
|
60
|
+
};
|
|
61
|
+
this.entries.set(id, attachment);
|
|
62
|
+
return attachment;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Registered chips in the order they appear in `text`, each once. */
|
|
66
|
+
referenced(text: string): ImageAttachment[] {
|
|
67
|
+
const seen = new Set<number>();
|
|
68
|
+
const result: ImageAttachment[] = [];
|
|
69
|
+
for (const match of text.matchAll(IMAGE_TOKEN_PATTERN)) {
|
|
70
|
+
const id = Number(match[1]);
|
|
71
|
+
const attachment = this.entries.get(id);
|
|
72
|
+
if (!attachment || seen.has(id)) continue;
|
|
73
|
+
seen.add(id);
|
|
74
|
+
result.push(attachment);
|
|
75
|
+
}
|
|
76
|
+
return result;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Resolve a submitted draft. Returns undefined when it holds no registered
|
|
81
|
+
* chip, so ordinary prompts pass through untouched.
|
|
82
|
+
*
|
|
83
|
+
* Pi can run input handlers after the editor already holds a new draft
|
|
84
|
+
* (a prompt queued during compaction), so only chips the current draft
|
|
85
|
+
* still shows survive; everything else, sent or deleted, is released.
|
|
86
|
+
*/
|
|
87
|
+
submit(text: string, modelAcceptsImages: boolean, currentDraft = ""): SubmittedImages | undefined {
|
|
88
|
+
const attachments = this.referenced(text);
|
|
89
|
+
const keep = new Set(this.referenced(currentDraft).map((attachment) => attachment.id));
|
|
90
|
+
for (const id of [...this.entries.keys()]) if (!keep.has(id)) this.entries.delete(id);
|
|
91
|
+
if (attachments.length === 0) return undefined;
|
|
92
|
+
if (modelAcceptsImages) {
|
|
93
|
+
return { text, images: attachments.map((attachment) => ({ type: "image", data: attachment.base64, mimeType: attachment.mimeType })), omitted: 0 };
|
|
94
|
+
}
|
|
95
|
+
const ids = new Set(attachments.map((attachment) => attachment.id));
|
|
96
|
+
const noted = text.replace(IMAGE_TOKEN_PATTERN, (token, id: string) => ids.has(Number(id)) ? `${token.slice(0, -1)} omitted: the current model does not accept images]` : token);
|
|
97
|
+
return { text: noted, images: [], omitted: attachments.length };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Drop every pending image; numbering keeps counting up. */
|
|
101
|
+
clear(): void {
|
|
102
|
+
this.entries.clear();
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Character spans of the registered chips in `text`. */
|
|
106
|
+
spans(text: string): TextSpan[] {
|
|
107
|
+
if (this.entries.size === 0 || !text.includes("[Image#")) return [];
|
|
108
|
+
const spans: TextSpan[] = [];
|
|
109
|
+
for (const match of text.matchAll(IMAGE_TOKEN_PATTERN)) {
|
|
110
|
+
if (this.entries.has(Number(match[1]))) spans.push({ start: match.index, end: match.index + match[0].length });
|
|
111
|
+
}
|
|
112
|
+
return spans;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Merge every registered chip into one segment, so cursor movement, word
|
|
117
|
+
* wrap and deletion treat it as a single unit (one Backspace removes it).
|
|
118
|
+
* Segments that already span several graphemes, such as Pi's own paste
|
|
119
|
+
* markers, pass through unchanged.
|
|
120
|
+
*/
|
|
121
|
+
segment(text: string, segments: Iterable<Segment>): Iterable<Segment> {
|
|
122
|
+
const spans = this.spans(text);
|
|
123
|
+
if (spans.length === 0) return segments;
|
|
124
|
+
const merged: Segment[] = [];
|
|
125
|
+
let span = 0;
|
|
126
|
+
for (const segment of segments) {
|
|
127
|
+
while (span < spans.length && spans[span]!.end <= segment.index) span++;
|
|
128
|
+
const current = spans[span];
|
|
129
|
+
if (current && segment.index >= current.start && segment.index < current.end) {
|
|
130
|
+
if (segment.index === current.start) merged.push({ segment: text.slice(current.start, current.end), index: current.start, input: text });
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
merged.push(segment);
|
|
134
|
+
}
|
|
135
|
+
return merged;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function formatBytes(bytes: number): string {
|
|
140
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
141
|
+
if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`;
|
|
142
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** `png · 1280×720 · 84 KB`: the chip's description when no preview is drawn. */
|
|
146
|
+
export function describeAttachment(attachment: Pick<ImageAttachment, "mimeType" | "byteLength" | "dimensions">): string {
|
|
147
|
+
const format = attachment.mimeType.replace(/^image\//, "").replace("jpeg", "jpg");
|
|
148
|
+
const size = attachment.dimensions ? `${attachment.dimensions.widthPx}×${attachment.dimensions.heightPx}` : undefined;
|
|
149
|
+
return [format, size, formatBytes(attachment.byteLength)].filter(Boolean).join(" · ");
|
|
150
|
+
}
|
|
@@ -153,6 +153,36 @@ function isTextContent(part: { type?: unknown }): part is TextContent {
|
|
|
153
153
|
return part.type === "text";
|
|
154
154
|
}
|
|
155
155
|
|
|
156
|
+
/** Completion diagnostics are facts only; never copy provider text, errors, or payloads. */
|
|
157
|
+
function emptyOutputEvidence(model: Model<Api>, assistant: AssistantMessage): Record<string, unknown> {
|
|
158
|
+
const evidence: Record<string, unknown> = { reviewerModel: `${model.provider}/${model.id}`.slice(0, 512) };
|
|
159
|
+
if (["pending", "stop", "length", "toolUse", "error", "aborted", "deferred"].includes(assistant.stopReason)) {
|
|
160
|
+
evidence.stopReason = assistant.stopReason;
|
|
161
|
+
}
|
|
162
|
+
const addCounter = (target: Record<string, number>, key: string, value: unknown) => {
|
|
163
|
+
if (typeof value === "number" && Number.isSafeInteger(value) && value >= 0) target[key] = value;
|
|
164
|
+
};
|
|
165
|
+
const usage: Record<string, number> = {};
|
|
166
|
+
for (const key of ["input", "output", "reasoning", "cacheRead", "cacheWrite", "totalTokens"] as const) {
|
|
167
|
+
addCounter(usage, key, assistant.usage?.[key]);
|
|
168
|
+
}
|
|
169
|
+
if (Object.keys(usage).length > 0) evidence.usage = usage;
|
|
170
|
+
const text = assistant.content.filter(isTextContent);
|
|
171
|
+
const thinking = assistant.content.filter((part) => part.type === "thinking");
|
|
172
|
+
const content: Record<string, number> = {};
|
|
173
|
+
addCounter(content, "blocks", assistant.content.length);
|
|
174
|
+
addCounter(content, "textBlocks", text.length);
|
|
175
|
+
addCounter(content, "thinkingBlocks", thinking.length);
|
|
176
|
+
if (text.every((part) => typeof part.text === "string")) {
|
|
177
|
+
addCounter(content, "textChars", text.reduce((sum, part) => sum + part.text.length, 0));
|
|
178
|
+
}
|
|
179
|
+
if (thinking.every((part) => typeof part.thinking === "string")) {
|
|
180
|
+
addCounter(content, "thinkingChars", thinking.reduce((sum, part) => sum + part.thinking.length, 0));
|
|
181
|
+
}
|
|
182
|
+
evidence.content = content;
|
|
183
|
+
return evidence;
|
|
184
|
+
}
|
|
185
|
+
|
|
156
186
|
/**
|
|
157
187
|
* Mirrors pi's main-loop OpenCode attribution condition exactly
|
|
158
188
|
* (core/provider-attribution.js#getSessionHeaders): the model's provider is
|
|
@@ -329,10 +359,11 @@ export async function runInProcessReviewer(request: InProcessReviewerRequest, de
|
|
|
329
359
|
|
|
330
360
|
const text = assistant.content.filter(isTextContent).map((part) => part.text).join("");
|
|
331
361
|
if (text.length === 0) {
|
|
362
|
+
const evidence = emptyOutputEvidence(model, assistant);
|
|
332
363
|
return refuse(
|
|
333
364
|
INPROCESS_REVIEWER_FAILURE.EMPTY_OUTPUT,
|
|
334
|
-
`Reviewer produced no text for ${request.routingKey} (stopReason: ${
|
|
335
|
-
|
|
365
|
+
`Reviewer produced no text for ${request.routingKey} (stopReason: ${evidence.stopReason ?? "unknown"}).`,
|
|
366
|
+
evidence,
|
|
336
367
|
);
|
|
337
368
|
}
|
|
338
369
|
|