pi-daddy 0.48.3 → 0.49.1
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/CHANGELOG.md +11 -0
- package/README.md +55 -0
- package/dist/cli.d.ts +3 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +18 -0
- package/dist/cli.js.map +1 -1
- package/dist/governance/retention-contract.js +3 -3
- package/dist/governance/retention-contract.js.map +1 -1
- package/dist/governance/retention-validator.d.ts +7 -0
- package/dist/governance/retention-validator.d.ts.map +1 -0
- package/dist/governance/retention-validator.js +100 -0
- package/dist/governance/retention-validator.js.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/kernel/selected-definitions.d.ts.map +1 -1
- package/dist/kernel/selected-definitions.js +6 -3
- package/dist/kernel/selected-definitions.js.map +1 -1
- package/dist/products/dashboard-cli.d.ts.map +1 -1
- package/dist/products/dashboard-cli.js +46 -1
- package/dist/products/dashboard-cli.js.map +1 -1
- package/dist/products/dashboard-display-controls.d.ts +1 -1
- package/dist/products/dashboard-display-controls.d.ts.map +1 -1
- package/dist/products/dashboard-display-controls.js +11 -3
- package/dist/products/dashboard-display-controls.js.map +1 -1
- package/dist/products/dashboard-screen.d.ts +3 -1
- package/dist/products/dashboard-screen.d.ts.map +1 -1
- package/dist/products/dashboard-screen.js +21 -8
- package/dist/products/dashboard-screen.js.map +1 -1
- package/dist/products/dashboard-session-client.d.ts +9 -0
- package/dist/products/dashboard-session-client.d.ts.map +1 -1
- package/dist/products/dashboard-session-client.js.map +1 -1
- package/dist/products/dashboard-settings.d.ts +28 -0
- package/dist/products/dashboard-settings.d.ts.map +1 -0
- package/dist/products/dashboard-settings.js +42 -0
- package/dist/products/dashboard-settings.js.map +1 -0
- package/dist/products/diagnostic-export.d.ts +23 -0
- package/dist/products/diagnostic-export.d.ts.map +1 -0
- package/dist/products/diagnostic-export.js +264 -0
- package/dist/products/diagnostic-export.js.map +1 -0
- package/dist/products/diagnostic-filter.d.ts +7 -0
- package/dist/products/diagnostic-filter.d.ts.map +1 -0
- package/dist/products/diagnostic-filter.js +135 -0
- package/dist/products/diagnostic-filter.js.map +1 -0
- package/extensions/dashboard-session-server.ts +17 -2
- package/extensions/dashboard-settings.ts +149 -0
- package/extensions/delegation-operations.ts +81 -2
- package/extensions/execute-child.ts +9 -11
- package/extensions/execution-controls.ts +7 -2
- package/extensions/grants-command.ts +3 -1
- package/extensions/grants.ts +3 -0
- package/extensions/reload-environment.ts +3 -0
- package/extensions/run-delegation.ts +3 -1
- package/extensions/runtime-snapshot.ts +9 -1
- package/package.json +1 -1
- package/src/cli.ts +24 -1
- package/src/governance/retention-contract.ts +3 -3
- package/src/governance/retention-validator.ts +110 -0
- package/src/index.ts +2 -0
- package/src/kernel/selected-definitions.ts +5 -3
- package/src/products/dashboard-cli.ts +38 -1
- package/src/products/dashboard-display-controls.ts +10 -4
- package/src/products/dashboard-screen.ts +21 -10
- package/src/products/dashboard-session-client.ts +7 -1
- package/src/products/dashboard-settings.ts +72 -0
- package/src/products/diagnostic-export.ts +288 -0
- package/src/products/diagnostic-filter.ts +140 -0
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { DASHBOARD_SETTING_KEYS } from "./dashboard-settings.ts";
|
|
1
2
|
/** Local display controls; these never dispatch actions to an owner or change ledger state. */
|
|
2
3
|
import { dashboardScreenState } from "./dashboard-screen.ts";
|
|
3
4
|
import { ActivityTimelineAliases } from "./activity-timeline.ts";
|
|
@@ -16,16 +17,17 @@ export function createDashboardDisplayControls(details: boolean, ledger: boolean
|
|
|
16
17
|
return {
|
|
17
18
|
state,
|
|
18
19
|
screen,
|
|
19
|
-
key(key: string): "auto" | "quit" | "command" | undefined {
|
|
20
|
+
key(key: string): "auto" | "quit" | "command" | "setting" | undefined {
|
|
20
21
|
if (key === "a") return "auto";
|
|
21
22
|
if (key === "q" || key === "ctrl-c") return "quit";
|
|
22
23
|
if (key === ":") return "command";
|
|
24
|
+
if (screen.view === "settings" && (key === "return" || key === "d")) return "setting";
|
|
23
25
|
if (key === "escape") {
|
|
24
26
|
screen.view = screen.view === "version-details" ? "versions" : "main";
|
|
25
27
|
state.activityDetail = undefined;
|
|
26
28
|
screen.offset = 0;
|
|
27
|
-
} else if (key === "?" || key === "m" || key === "v") {
|
|
28
|
-
screen.view = key === "?" ? "help" : key === "v" ? "versions" : "models";
|
|
29
|
+
} else if (key === "?" || key === "m" || key === "v" || key === "s") {
|
|
30
|
+
screen.view = key === "?" ? "help" : key === "v" ? "versions" : key === "s" ? "settings" : "models";
|
|
29
31
|
state.activityDetail = undefined;
|
|
30
32
|
screen.offset = 0;
|
|
31
33
|
} else if (key === "return" || key === "d") {
|
|
@@ -33,7 +35,11 @@ export function createDashboardDisplayControls(details: boolean, ledger: boolean
|
|
|
33
35
|
screen.offset = 0;
|
|
34
36
|
} else if (["up", "down", "j", "k"].includes(key)) {
|
|
35
37
|
const delta = key === "up" || key === "k" ? -1 : 1;
|
|
36
|
-
if (screen.view === "
|
|
38
|
+
if (screen.view === "settings") {
|
|
39
|
+
const index = DASHBOARD_SETTING_KEYS.indexOf(screen.settingKey ?? "auto");
|
|
40
|
+
screen.settingKey =
|
|
41
|
+
DASHBOARD_SETTING_KEYS[Math.max(0, Math.min(DASHBOARD_SETTING_KEYS.length - 1, index + delta))];
|
|
42
|
+
} else if (screen.view === "versions") {
|
|
37
43
|
const rows = screen.versionRows ?? [],
|
|
38
44
|
index = rows.findIndex((row) => row.id === screen.versionId);
|
|
39
45
|
screen.versionId = rows[Math.max(0, Math.min(rows.length - 1, index + delta))]?.id;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { dashboardSettingsLines, type DashboardSettingKey } from "./dashboard-settings.ts";
|
|
1
2
|
/** Height-bounded, task-first view. Permission state is always supplied by the owner. */
|
|
2
3
|
import type { EcosystemVersionRow } from "./ecosystem-versions.ts";
|
|
3
4
|
import type { DashboardProjection } from "./dashboard-projection.ts";
|
|
@@ -19,7 +20,8 @@ export interface DashboardItem {
|
|
|
19
20
|
agent?: boolean;
|
|
20
21
|
}
|
|
21
22
|
export interface DashboardScreenState {
|
|
22
|
-
view: "main" | "details" | "models" | "help" | "versions" | "version-details";
|
|
23
|
+
view: "main" | "details" | "models" | "help" | "versions" | "version-details" | "settings";
|
|
24
|
+
settingKey?: DashboardSettingKey;
|
|
23
25
|
versionId?: EcosystemVersionRow["id"];
|
|
24
26
|
versionRows?: EcosystemVersionRow[];
|
|
25
27
|
selectedKey?: string;
|
|
@@ -204,12 +206,14 @@ export function renderDashboardScreen(options: DashboardScreenOptions): string {
|
|
|
204
206
|
const footer =
|
|
205
207
|
options.command !== undefined
|
|
206
208
|
? [`: ${clean(options.command)}`]
|
|
207
|
-
: screen.view === "
|
|
208
|
-
? ["↑↓ select · Enter
|
|
209
|
-
:
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
209
|
+
: screen.view === "settings"
|
|
210
|
+
? ["↑↓ select · Enter toggle/edit · Esc back", "0 restores a default limit · s settings"]
|
|
211
|
+
: screen.view === "main" || screen.view === "versions"
|
|
212
|
+
? ["↑↓ select · Enter details · a Auto", "m models · v versions · s settings · ?"]
|
|
213
|
+
: [
|
|
214
|
+
"↑↓ scroll · Esc back · a Auto",
|
|
215
|
+
screen.view === "details" ? "p prompt · f final · : command · ? help" : ": command · ? help",
|
|
216
|
+
];
|
|
213
217
|
const fixed = [
|
|
214
218
|
header,
|
|
215
219
|
session
|
|
@@ -223,7 +227,7 @@ export function renderDashboardScreen(options: DashboardScreenOptions): string {
|
|
|
223
227
|
"DASHBOARD KEYS",
|
|
224
228
|
"↑/↓ or j/k: select; Enter/d: details",
|
|
225
229
|
"a: toggle Daddy permission Auto",
|
|
226
|
-
"m: session models; v: ecosystem versions",
|
|
230
|
+
"m: session models; v: ecosystem versions; s: settings",
|
|
227
231
|
"h: show/hide finished history",
|
|
228
232
|
"Tab: Everything / Agents / Skills / Needs-you",
|
|
229
233
|
"p/f: selected retained prompt/final",
|
|
@@ -234,7 +238,9 @@ export function renderDashboardScreen(options: DashboardScreenOptions): string {
|
|
|
234
238
|
"Auto does not enable JEV or change LoRA consent.",
|
|
235
239
|
"Finished means observed completion, not acceptance.",
|
|
236
240
|
);
|
|
237
|
-
else if (screen.view === "
|
|
241
|
+
else if (screen.view === "settings") {
|
|
242
|
+
body.push(...dashboardSettingsLines(session?.settings, session?.auto.enabled, screen.settingKey ?? "auto"));
|
|
243
|
+
} else if (screen.view === "versions" || screen.view === "version-details") {
|
|
238
244
|
body.push("VERSIONS");
|
|
239
245
|
if (!session?.versions) body.push("Owner has not reported versions.");
|
|
240
246
|
else if (screen.view === "versions") {
|
|
@@ -323,6 +329,11 @@ export function renderDashboardScreen(options: DashboardScreenOptions): string {
|
|
|
323
329
|
if (row < screen.offset) screen.offset = row;
|
|
324
330
|
else if (row + 1 >= screen.offset + room) screen.offset = Math.max(0, row + 2 - room);
|
|
325
331
|
}
|
|
332
|
+
if (screen.view === "settings" && room > 0) {
|
|
333
|
+
const row = detailBody.findIndex((line) => line.startsWith(">"));
|
|
334
|
+
if (row < screen.offset) screen.offset = Math.max(0, row);
|
|
335
|
+
else if (row >= screen.offset + room) screen.offset = Math.max(0, row - room + 1);
|
|
336
|
+
}
|
|
326
337
|
const offset =
|
|
327
338
|
screen.view === "main" ? 0 : Math.max(0, Math.min(screen.offset, Math.max(0, detailBody.length - room)));
|
|
328
339
|
screen.offset = offset;
|
|
@@ -337,7 +348,7 @@ export function renderDashboardScreen(options: DashboardScreenOptions): string {
|
|
|
337
348
|
const safe = truncate(dashboardText(line), width);
|
|
338
349
|
if (!options.color) return safe;
|
|
339
350
|
const section =
|
|
340
|
-
/^(CURRENT REQUEST|LATEST REQUEST|CURRENT WORK|NEEDS YOU|NEEDS ATTENTION|WORK IN PROGRESS|HISTORY|VERSIONS|SESSION MODELS|DASHBOARD KEYS|MANAGE)/.test(
|
|
351
|
+
/^(CURRENT REQUEST|LATEST REQUEST|CURRENT WORK|NEEDS YOU|NEEDS ATTENTION|WORK IN PROGRESS|HISTORY|VERSIONS|SETTINGS|STARTUP LIMITS|SESSION MODELS|DASHBOARD KEYS|MANAGE)/.test(
|
|
341
352
|
safe,
|
|
342
353
|
);
|
|
343
354
|
const code =
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { DashboardSettingsSnapshot } from "./dashboard-settings.ts";
|
|
1
2
|
import type { EcosystemVersions } from "./ecosystem-versions.ts";
|
|
2
3
|
import { createConnection } from "node:net";
|
|
3
4
|
|
|
@@ -6,12 +7,17 @@ export interface DashboardSessionSnapshot {
|
|
|
6
7
|
cost: number | null;
|
|
7
8
|
activity?: { rootId: string; path: string; taskId: string };
|
|
8
9
|
versions?: EcosystemVersions;
|
|
10
|
+
settings?: DashboardSettingsSnapshot;
|
|
9
11
|
auto: { enabled: boolean; source: "default" | "environment" | "session" };
|
|
10
12
|
pendingApprovals: Array<{ id: string; subject: string; capability: string }>;
|
|
11
13
|
}
|
|
12
14
|
|
|
13
15
|
export type DashboardSessionAction =
|
|
14
|
-
|
|
16
|
+
| { action: "get" }
|
|
17
|
+
| { action: "set"; edits: string }
|
|
18
|
+
| { action: "set-auto"; enabled: boolean }
|
|
19
|
+
| { action: "set-jev"; enabled: boolean }
|
|
20
|
+
| { action: "set-limit"; key: "wall" | "idle"; seconds: number };
|
|
15
21
|
|
|
16
22
|
export async function dashboardSessionRequest(
|
|
17
23
|
socketPath: string,
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/** Settings are owner observations; rendering never grants consent or changes execution. */
|
|
2
|
+
export type DashboardSettingKey = "auto" | "jev" | "wall" | "idle" | "descendants" | "depth" | "perCall";
|
|
3
|
+
export const DASHBOARD_SETTING_KEYS: DashboardSettingKey[] = [
|
|
4
|
+
"auto",
|
|
5
|
+
"jev",
|
|
6
|
+
"wall",
|
|
7
|
+
"idle",
|
|
8
|
+
"descendants",
|
|
9
|
+
"depth",
|
|
10
|
+
"perCall",
|
|
11
|
+
];
|
|
12
|
+
export interface DashboardJevState {
|
|
13
|
+
available: boolean;
|
|
14
|
+
enabled: boolean;
|
|
15
|
+
pending: boolean;
|
|
16
|
+
mode?: string;
|
|
17
|
+
storage?: string | null;
|
|
18
|
+
remaining?: number;
|
|
19
|
+
availability?: string;
|
|
20
|
+
providerReadiness?: string;
|
|
21
|
+
error?: string;
|
|
22
|
+
}
|
|
23
|
+
export interface DashboardSettingsSnapshot {
|
|
24
|
+
editable: boolean;
|
|
25
|
+
wallSeconds: number;
|
|
26
|
+
idleSeconds: number;
|
|
27
|
+
wallSource: "session" | "startup";
|
|
28
|
+
idleSource: "session" | "startup";
|
|
29
|
+
descendants: number;
|
|
30
|
+
reserved: number;
|
|
31
|
+
maxDepth: number;
|
|
32
|
+
perCall: number;
|
|
33
|
+
jev: DashboardJevState;
|
|
34
|
+
}
|
|
35
|
+
export function dashboardSettingsLines(
|
|
36
|
+
settings: DashboardSettingsSnapshot | undefined,
|
|
37
|
+
auto: boolean | undefined,
|
|
38
|
+
selected: DashboardSettingKey,
|
|
39
|
+
): string[] {
|
|
40
|
+
if (!settings) return ["SETTINGS", "Owner settings unavailable. Reconnect from /grants dashboard."];
|
|
41
|
+
const row = (key: DashboardSettingKey, label: string) => `${key === selected ? ">" : " "} ${label}`;
|
|
42
|
+
const jev = settings.jev;
|
|
43
|
+
return [
|
|
44
|
+
"SETTINGS · current session",
|
|
45
|
+
row("auto", `Permission Auto: ${auto ? "ON" : "OFF"}`),
|
|
46
|
+
row(
|
|
47
|
+
"jev",
|
|
48
|
+
`JEV: ${jev.pending ? "waiting for consent" : !jev.available ? "unavailable" : jev.enabled ? "ON" : "OFF"}`,
|
|
49
|
+
),
|
|
50
|
+
...(jev.available
|
|
51
|
+
? [
|
|
52
|
+
` ${jev.availability ?? "unknown"} · ${jev.remaining ?? 0} paid calls left`,
|
|
53
|
+
` Key: ${jev.providerReadiness ?? "unknown"}; storage: ${jev.storage ?? "not granted"}`,
|
|
54
|
+
]
|
|
55
|
+
: [" Load skill-harness to connect JEV controls."]),
|
|
56
|
+
...(jev.error ? [` ${jev.error}`] : []),
|
|
57
|
+
row("wall", `Child wall: ${settings.wallSeconds}s (${settings.wallSource})`),
|
|
58
|
+
row("idle", `Child idle: ${settings.idleSeconds}s (${settings.idleSource})`),
|
|
59
|
+
settings.editable
|
|
60
|
+
? "Edits apply to future children from this parent."
|
|
61
|
+
: "Read-only: change settings in the owning parent.",
|
|
62
|
+
"Running children keep their captured limits.",
|
|
63
|
+
"JEV ON asks paid-call and LoRA-storage consent in Pi.",
|
|
64
|
+
"Auto never supplies that consent. No model token budget.",
|
|
65
|
+
"",
|
|
66
|
+
"STARTUP LIMITS · restart to change",
|
|
67
|
+
row("descendants", `Active descendants: ${settings.descendants} (${settings.reserved} reserved) · PI_DADDY_FANOUT`),
|
|
68
|
+
row("depth", `Maximum depth: ${settings.maxDepth} · PI_DADDY_MAX_DEPTH`),
|
|
69
|
+
row("perCall", `Children per call: ${settings.perCall} (fixed safety bound)`),
|
|
70
|
+
"s settings · m models · v versions",
|
|
71
|
+
];
|
|
72
|
+
}
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
/** Offline, explicit-selection export. Original files are read only; output is private and never training data. */
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { constants } from "node:fs";
|
|
4
|
+
import { lstat, mkdir, open, readdir, realpath, writeFile } from "node:fs/promises";
|
|
5
|
+
import { dirname, join, resolve } from "node:path";
|
|
6
|
+
import { readBoundedBytes } from "../kernel/bounded-read.ts";
|
|
7
|
+
import { parseRetentionJson } from "../governance/retention-json.ts";
|
|
8
|
+
import { parseExecutionRetentionManifest, RETENTION_CONTENT_KINDS } from "../governance/retention-contract.ts";
|
|
9
|
+
import { filterDiagnostic, type DiagnosticFormat } from "./diagnostic-filter.ts";
|
|
10
|
+
|
|
11
|
+
const MAX_FILE = 16 * 1024 * 1024;
|
|
12
|
+
const MAX_TOTAL = 128 * 1024 * 1024;
|
|
13
|
+
const MAX_SOURCES = 1024;
|
|
14
|
+
const sha256 = (bytes: Uint8Array) => createHash("sha256").update(bytes).digest("hex");
|
|
15
|
+
const object = (x: unknown): x is Record<string, unknown> => x !== null && typeof x === "object" && !Array.isArray(x);
|
|
16
|
+
const pathValue = (x: unknown): x is string =>
|
|
17
|
+
typeof x === "string" && x.length > 0 && x.length <= 4096 && !/[\x00-\x1f]/.test(x);
|
|
18
|
+
const hashValue = (x: unknown): x is string => typeof x === "string" && /^[a-f0-9]{64}$/.test(x);
|
|
19
|
+
interface Source {
|
|
20
|
+
path: string;
|
|
21
|
+
format: DiagnosticFormat;
|
|
22
|
+
sha256?: string;
|
|
23
|
+
}
|
|
24
|
+
export interface DiagnosticSelection {
|
|
25
|
+
version: 1;
|
|
26
|
+
sources: Source[];
|
|
27
|
+
retentionRoots?: string[];
|
|
28
|
+
}
|
|
29
|
+
interface InventoryEntry {
|
|
30
|
+
source: string;
|
|
31
|
+
format: string;
|
|
32
|
+
status: "included" | "missing" | "excluded";
|
|
33
|
+
reason?: string;
|
|
34
|
+
sourceSha256?: string;
|
|
35
|
+
sourceBytes?: number;
|
|
36
|
+
path?: string;
|
|
37
|
+
sha256?: string;
|
|
38
|
+
bytes?: number;
|
|
39
|
+
omissions?: Record<string, number>;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function parseDiagnosticSelection(text: string, base: string): DiagnosticSelection {
|
|
43
|
+
const value = parseRetentionJson(text, 256 * 1024);
|
|
44
|
+
if (
|
|
45
|
+
!object(value) ||
|
|
46
|
+
value.version !== 1 ||
|
|
47
|
+
!Array.isArray(value.sources) ||
|
|
48
|
+
Object.keys(value).some((key) => !["version", "sources", "retentionRoots"].includes(key)) ||
|
|
49
|
+
value.sources.length > MAX_SOURCES ||
|
|
50
|
+
(value.retentionRoots !== undefined && (!Array.isArray(value.retentionRoots) || value.retentionRoots.length > 16))
|
|
51
|
+
)
|
|
52
|
+
throw new Error("invalid diagnostic selection v1");
|
|
53
|
+
const sources = value.sources.map((source): Source => {
|
|
54
|
+
if (
|
|
55
|
+
!object(source) ||
|
|
56
|
+
!pathValue(source.path) ||
|
|
57
|
+
!["session", "json", "jsonl", "text"].includes(String(source.format)) ||
|
|
58
|
+
Object.keys(source).some((key) => !["path", "format", "sha256"].includes(key)) ||
|
|
59
|
+
(source.sha256 !== undefined && !hashValue(source.sha256))
|
|
60
|
+
)
|
|
61
|
+
throw new Error("invalid diagnostic source");
|
|
62
|
+
return {
|
|
63
|
+
path: resolve(base, source.path),
|
|
64
|
+
format: source.format as DiagnosticFormat,
|
|
65
|
+
...(source.sha256 ? { sha256: source.sha256 as string } : {}),
|
|
66
|
+
};
|
|
67
|
+
});
|
|
68
|
+
const retentionRoots = ((value.retentionRoots ?? []) as unknown[]).map((path) => {
|
|
69
|
+
if (!pathValue(path)) throw new Error("invalid retention root");
|
|
70
|
+
return resolve(base, path);
|
|
71
|
+
});
|
|
72
|
+
return { version: 1, sources, retentionRoots };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function readSource(path: string, limit = MAX_FILE): Promise<Buffer> {
|
|
76
|
+
const before = await lstat(path);
|
|
77
|
+
if (!before.isFile() || (await realpath(path)) !== resolve(path))
|
|
78
|
+
throw new Error("source-must-be-canonical-regular-file");
|
|
79
|
+
const result = await readBoundedBytes(
|
|
80
|
+
path,
|
|
81
|
+
{ maxBytes: limit, timeoutMs: 10000 },
|
|
82
|
+
{
|
|
83
|
+
open: (file, flags) => open(file, flags | constants.O_NOFOLLOW),
|
|
84
|
+
read: (handle, buffer, offset, length, position) => handle.read(buffer, offset, length, position),
|
|
85
|
+
close: (handle) => handle.close(),
|
|
86
|
+
},
|
|
87
|
+
);
|
|
88
|
+
if (!result.ok) throw new Error(`source-${result.why}`);
|
|
89
|
+
const after = await lstat(path);
|
|
90
|
+
if (
|
|
91
|
+
!after.isFile() ||
|
|
92
|
+
before.dev !== after.dev ||
|
|
93
|
+
before.ino !== after.ino ||
|
|
94
|
+
before.size !== after.size ||
|
|
95
|
+
before.mtimeMs !== after.mtimeMs ||
|
|
96
|
+
before.ctimeMs !== after.ctimeMs ||
|
|
97
|
+
after.size !== result.bytes.length
|
|
98
|
+
)
|
|
99
|
+
throw new Error("source-changed-during-read");
|
|
100
|
+
return result.bytes;
|
|
101
|
+
}
|
|
102
|
+
const reason = (error: unknown): string => {
|
|
103
|
+
const code = object(error) && typeof error.code === "string" ? error.code : undefined;
|
|
104
|
+
if (code) return code;
|
|
105
|
+
return error instanceof Error && /^source-[a-z-]+$/.test(error.message)
|
|
106
|
+
? error.message
|
|
107
|
+
: "invalid-or-unreadable-source";
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
export async function exportDiagnostics(selectionPath: string, destination: string) {
|
|
111
|
+
const selectedPath = resolve(selectionPath);
|
|
112
|
+
const selectedBytes = await readSource(selectedPath, 256 * 1024);
|
|
113
|
+
const selection = parseDiagnosticSelection(
|
|
114
|
+
new TextDecoder("utf-8", { fatal: true }).decode(selectedBytes),
|
|
115
|
+
dirname(selectedPath),
|
|
116
|
+
);
|
|
117
|
+
const choices = new Map<string, Source>();
|
|
118
|
+
for (const source of selection.sources) {
|
|
119
|
+
const prior = choices.get(source.path);
|
|
120
|
+
if (prior && (prior.format !== source.format || prior.sha256 !== source.sha256))
|
|
121
|
+
throw new Error("conflicting diagnostic selection for one source");
|
|
122
|
+
choices.set(source.path, source);
|
|
123
|
+
}
|
|
124
|
+
const target = resolve(destination);
|
|
125
|
+
if ((await realpath(dirname(target))) !== dirname(target)) throw new Error("destination parent must be canonical");
|
|
126
|
+
await mkdir(target, { mode: 0o700 }); // Exclusive: never overwrite or remove an existing directory.
|
|
127
|
+
await mkdir(join(target, "objects"), { mode: 0o700 });
|
|
128
|
+
const inventory: InventoryEntry[] = [];
|
|
129
|
+
const objects = new Map<string, number>();
|
|
130
|
+
const seen = new Map<string, { source: Source; row: InventoryEntry }>();
|
|
131
|
+
let total = 0;
|
|
132
|
+
let readTotal = 0;
|
|
133
|
+
const read = async (source: Source) => {
|
|
134
|
+
if (readTotal >= MAX_TOTAL) throw new Error("source-total-size-limit");
|
|
135
|
+
const raw = await readSource(source.path, Math.min(MAX_FILE, MAX_TOTAL - readTotal));
|
|
136
|
+
readTotal += raw.length;
|
|
137
|
+
return raw;
|
|
138
|
+
};
|
|
139
|
+
const add = async (source: Source, expectedBytes?: number, traverse = false): Promise<Buffer | undefined> => {
|
|
140
|
+
const prior = seen.get(source.path);
|
|
141
|
+
const expected = (digest: string | undefined, bytes: number | undefined) => {
|
|
142
|
+
if (source.sha256 && digest !== source.sha256) throw new Error("source-digest-mismatch");
|
|
143
|
+
if (expectedBytes !== undefined && bytes !== expectedBytes) throw new Error("source-size-mismatch");
|
|
144
|
+
};
|
|
145
|
+
if (prior) {
|
|
146
|
+
if (prior.source.format !== source.format) throw new Error("conflicting diagnostic selection for one source");
|
|
147
|
+
if (prior.row.status !== "included") return undefined;
|
|
148
|
+
try {
|
|
149
|
+
expected(prior.row.sourceSha256, prior.row.sourceBytes);
|
|
150
|
+
if (!traverse) return undefined;
|
|
151
|
+
// A selected manifest still needs traversal. Re-read and compare rather than caching all raw payloads.
|
|
152
|
+
const raw = await read(source);
|
|
153
|
+
if (sha256(raw) !== prior.row.sourceSha256 || raw.length !== prior.row.sourceBytes)
|
|
154
|
+
throw new Error("source-changed-before-traversal");
|
|
155
|
+
return raw;
|
|
156
|
+
} catch (error) {
|
|
157
|
+
inventory.push({ source: source.path, format: source.format, status: "missing", reason: reason(error) });
|
|
158
|
+
return undefined;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
if (seen.size >= MAX_SOURCES) throw new Error("diagnostic source count exceeds bounds");
|
|
162
|
+
const row: InventoryEntry = { source: source.path, format: source.format, status: "missing" };
|
|
163
|
+
seen.set(source.path, { source, row });
|
|
164
|
+
inventory.push(row);
|
|
165
|
+
try {
|
|
166
|
+
const raw = await read(source);
|
|
167
|
+
row.sourceSha256 = sha256(raw);
|
|
168
|
+
row.sourceBytes = raw.length;
|
|
169
|
+
expected(row.sourceSha256, row.sourceBytes);
|
|
170
|
+
const filtered = filterDiagnostic(raw, source.format);
|
|
171
|
+
const hash = sha256(filtered.bytes),
|
|
172
|
+
path = `objects/${hash}`;
|
|
173
|
+
if (!objects.has(hash)) {
|
|
174
|
+
if (total + filtered.bytes.length > MAX_TOTAL) throw new Error("source-export-size-limit");
|
|
175
|
+
await writeFile(join(target, path), filtered.bytes, { flag: "wx", mode: 0o600 });
|
|
176
|
+
objects.set(hash, filtered.bytes.length);
|
|
177
|
+
total += filtered.bytes.length;
|
|
178
|
+
}
|
|
179
|
+
Object.assign(row, {
|
|
180
|
+
status: "included",
|
|
181
|
+
path,
|
|
182
|
+
sha256: hash,
|
|
183
|
+
bytes: filtered.bytes.length,
|
|
184
|
+
omissions: filtered.omissions,
|
|
185
|
+
});
|
|
186
|
+
return raw;
|
|
187
|
+
} catch (error) {
|
|
188
|
+
row.reason = reason(error);
|
|
189
|
+
return undefined;
|
|
190
|
+
}
|
|
191
|
+
};
|
|
192
|
+
for (const source of [...choices.values()].sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0)))
|
|
193
|
+
await add(source);
|
|
194
|
+
const captures: unknown[] = [];
|
|
195
|
+
for (const root of [...new Set(selection.retentionRoots)].sort()) {
|
|
196
|
+
try {
|
|
197
|
+
if ((await realpath(root)) !== root || !(await lstat(root)).isDirectory())
|
|
198
|
+
throw new Error("source-invalid-retention-root");
|
|
199
|
+
const entries = await readdir(root, { withFileTypes: true });
|
|
200
|
+
if (entries.length > MAX_SOURCES) throw new Error("source-directory-size-limit");
|
|
201
|
+
for (const entry of entries.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))) {
|
|
202
|
+
if (!entry.isDirectory()) continue;
|
|
203
|
+
const manifestPath = join(root, entry.name, "manifest.json");
|
|
204
|
+
const raw = await add({ path: manifestPath, format: "json" }, undefined, true);
|
|
205
|
+
if (!raw) continue;
|
|
206
|
+
let manifest;
|
|
207
|
+
try {
|
|
208
|
+
manifest = parseExecutionRetentionManifest(raw.toString("utf8"));
|
|
209
|
+
} catch {
|
|
210
|
+
inventory.push({
|
|
211
|
+
source: `${manifestPath}#manifest`,
|
|
212
|
+
format: "retention-manifest",
|
|
213
|
+
status: "missing",
|
|
214
|
+
reason: "invalid-retention-manifest",
|
|
215
|
+
});
|
|
216
|
+
captures.push({ source: manifestPath, status: "invalid-retention-manifest" });
|
|
217
|
+
continue;
|
|
218
|
+
}
|
|
219
|
+
const losses = manifest.coverage.losses;
|
|
220
|
+
captures.push({
|
|
221
|
+
source: manifestPath,
|
|
222
|
+
nativeSession: manifest.nativeSession,
|
|
223
|
+
state: manifest.state,
|
|
224
|
+
currentLosses: losses.filter((loss) => loss !== "native-session-read-failed-earlier"),
|
|
225
|
+
recoveredObservations: losses.filter((loss) => loss === "native-session-read-failed-earlier"),
|
|
226
|
+
acceptance: "not-assessed",
|
|
227
|
+
});
|
|
228
|
+
for (const kind of RETENTION_CONTENT_KINDS) {
|
|
229
|
+
const value = manifest.content[kind];
|
|
230
|
+
const format = kind === "session" ? "session" : kind === "checkReceipt" ? "json" : "text";
|
|
231
|
+
const source = `${manifestPath}#${kind}`;
|
|
232
|
+
if (!object(value) || value.status !== "retained") {
|
|
233
|
+
inventory.push({ source, format, status: "missing", reason: "not-retained" });
|
|
234
|
+
continue;
|
|
235
|
+
}
|
|
236
|
+
if (!["session", "checkReceipt", "result"].includes(kind)) {
|
|
237
|
+
inventory.push({
|
|
238
|
+
source,
|
|
239
|
+
format,
|
|
240
|
+
status: "excluded",
|
|
241
|
+
reason: "raw-stream-or-pane",
|
|
242
|
+
sourceSha256: hashValue(value.sha256) ? value.sha256 : undefined,
|
|
243
|
+
});
|
|
244
|
+
continue;
|
|
245
|
+
}
|
|
246
|
+
await add(
|
|
247
|
+
{ path: join(dirname(manifestPath), value.path as string), format, sha256: value.sha256! },
|
|
248
|
+
value.bytes as number,
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
} catch (error) {
|
|
253
|
+
inventory.push({ source: root, format: "retention-root", status: "missing", reason: reason(error) });
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
// Capture metadata is also filtered; an untrusted manifest must not smuggle fields past the projection.
|
|
257
|
+
const manifest = {
|
|
258
|
+
version: 1,
|
|
259
|
+
policy: "pi-daddy-visible-diagnostic-v1",
|
|
260
|
+
selectionSha256: sha256(selectedBytes),
|
|
261
|
+
entries: inventory,
|
|
262
|
+
objects: objects.size,
|
|
263
|
+
exportedBytes: total,
|
|
264
|
+
captures,
|
|
265
|
+
private: true,
|
|
266
|
+
trainingEligible: false,
|
|
267
|
+
exportEligible: false,
|
|
268
|
+
acceptance: "not-assessed",
|
|
269
|
+
limitations: [
|
|
270
|
+
"Not an atomic filesystem snapshot; sources are checked individually.",
|
|
271
|
+
"Known reasoning/environment fields omitted; free-text secret detection is heuristic. Review before sharing.",
|
|
272
|
+
"Raw streams, images, unsupported native entries and unselected files are not exported.",
|
|
273
|
+
"Source hashes identify original bytes; object hashes identify filtered bytes. Neither grants approval or training rights.",
|
|
274
|
+
],
|
|
275
|
+
};
|
|
276
|
+
const safeManifest = filterDiagnostic(Buffer.from(JSON.stringify(manifest)), "json").bytes;
|
|
277
|
+
await writeFile(join(target, "inventory.json"), safeManifest, { mode: 0o600, flag: "wx" });
|
|
278
|
+
const missing = inventory.filter((entry) => entry.status === "missing").length;
|
|
279
|
+
return {
|
|
280
|
+
directory: target,
|
|
281
|
+
inventory: join(target, "inventory.json"),
|
|
282
|
+
sha256: sha256(safeManifest),
|
|
283
|
+
entries: inventory.length,
|
|
284
|
+
objects: objects.size,
|
|
285
|
+
missing,
|
|
286
|
+
trainingEligible: false,
|
|
287
|
+
};
|
|
288
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/** Local diagnostic projection. Known private fields are omitted; free-text secret detection is heuristic. */
|
|
2
|
+
import { parseRetentionJson } from "../governance/retention-json.ts";
|
|
3
|
+
|
|
4
|
+
const object = (value: unknown): value is Record<string, unknown> =>
|
|
5
|
+
value !== null && typeof value === "object" && !Array.isArray(value);
|
|
6
|
+
const privateKey = new RegExp(
|
|
7
|
+
"^(?:thinking|reasoning|reasoning_content|reasoning_details|encrypted_content|thoughtSignature|thinkingSignature|signature|" +
|
|
8
|
+
"env|environment|environ|credentials?|authorization|api[-_]?key|token|access[-_]?token|refresh[-_]?token|password|secret)$",
|
|
9
|
+
"i",
|
|
10
|
+
);
|
|
11
|
+
const privateType = /^(?:thinking|reasoning|redacted_thinking|reasoning_content|reasoning_details)$/i;
|
|
12
|
+
export interface DiagnosticFilterResult {
|
|
13
|
+
bytes: Buffer;
|
|
14
|
+
omissions: Record<string, number>;
|
|
15
|
+
}
|
|
16
|
+
export type DiagnosticFormat = "session" | "json" | "jsonl" | "text";
|
|
17
|
+
|
|
18
|
+
export function filterDiagnostic(bytes: Uint8Array, format: DiagnosticFormat): DiagnosticFilterResult {
|
|
19
|
+
const omissions: Record<string, number> = {};
|
|
20
|
+
const note = (reason: string) => (omissions[reason] = (omissions[reason] ?? 0) + 1);
|
|
21
|
+
const text = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
|
22
|
+
const redact = (input: string): string => {
|
|
23
|
+
let output = input.replace(
|
|
24
|
+
/\b(?:sk-(?:or-v1-)?[A-Za-z0-9_-]{16,}|gh[pousr]_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,})\b/g,
|
|
25
|
+
() => {
|
|
26
|
+
note("credential-pattern");
|
|
27
|
+
return "[REDACTED]";
|
|
28
|
+
},
|
|
29
|
+
);
|
|
30
|
+
output = output.replace(/\b([a-z][a-z0-9+.-]*:\/\/)[^\s/<>"']+@/gi, (_match, scheme: string) => {
|
|
31
|
+
note("credential-url-userinfo");
|
|
32
|
+
return `${scheme}[REDACTED]@`;
|
|
33
|
+
});
|
|
34
|
+
output = output.replace(/\bBearer\s+[A-Za-z0-9._~+/=-]+/gi, () => {
|
|
35
|
+
note("credential-pattern");
|
|
36
|
+
return "Bearer [REDACTED]";
|
|
37
|
+
});
|
|
38
|
+
output = output.replace(
|
|
39
|
+
/\b([A-Z][A-Z0-9_]*(?:API_KEY|TOKEN|PASSWORD|SECRET)|API_KEY|TOKEN|PASSWORD|SECRET)(\s*[=:]\s*)(?:"[^"\r\n]*"|'[^'\r\n]*'|[^\s,;]+)/gi,
|
|
40
|
+
(_match, key: string, separator: string) => {
|
|
41
|
+
note("credential-assignment");
|
|
42
|
+
return `${key}${separator}[REDACTED]`;
|
|
43
|
+
},
|
|
44
|
+
);
|
|
45
|
+
return output;
|
|
46
|
+
};
|
|
47
|
+
const clean = (value: unknown, depth = 0): unknown => {
|
|
48
|
+
if (depth > 64) throw new Error("diagnostic nesting exceeds bounds");
|
|
49
|
+
if (typeof value === "string") {
|
|
50
|
+
// JSON embedded in a packet is a byte-bearing string. Keep it exactly when no redaction is needed.
|
|
51
|
+
if (/^\s*[\[{]/.test(value)) {
|
|
52
|
+
let parsed: unknown;
|
|
53
|
+
try {
|
|
54
|
+
parsed = parseRetentionJson(value, 16 * 1024 * 1024);
|
|
55
|
+
} catch {
|
|
56
|
+
return redact(value);
|
|
57
|
+
}
|
|
58
|
+
const filtered = clean(parsed, depth + 1);
|
|
59
|
+
return JSON.stringify(filtered) === JSON.stringify(parsed) ? value : JSON.stringify(filtered);
|
|
60
|
+
}
|
|
61
|
+
return redact(value);
|
|
62
|
+
}
|
|
63
|
+
if (Array.isArray(value))
|
|
64
|
+
return value.flatMap((item) => {
|
|
65
|
+
if (object(item) && typeof item.type === "string" && privateType.test(item.type)) {
|
|
66
|
+
note("private-reasoning");
|
|
67
|
+
return [];
|
|
68
|
+
}
|
|
69
|
+
return [clean(item, depth + 1)];
|
|
70
|
+
});
|
|
71
|
+
if (!object(value)) return value;
|
|
72
|
+
if (typeof value.type === "string" && privateType.test(value.type)) {
|
|
73
|
+
note("private-reasoning");
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
const result: Record<string, unknown> = Object.create(null);
|
|
77
|
+
for (const [key, item] of Object.entries(value)) {
|
|
78
|
+
if (privateKey.test(key) || /(?:^|_)(?:API_KEY|ACCESS_TOKEN|REFRESH_TOKEN|PASSWORD|SECRET)$/.test(key)) {
|
|
79
|
+
note("private-field");
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
result[key] = clean(item, depth + 1);
|
|
83
|
+
}
|
|
84
|
+
return result;
|
|
85
|
+
};
|
|
86
|
+
const pick = (value: Record<string, unknown>, keys: string[]) =>
|
|
87
|
+
Object.fromEntries(keys.filter((key) => Object.hasOwn(value, key)).map((key) => [key, value[key]]));
|
|
88
|
+
const session = (value: unknown): unknown => {
|
|
89
|
+
if (!object(value)) throw new Error("invalid native session record");
|
|
90
|
+
const envelope = pick(value, ["type", "id", "parentId", "timestamp"]);
|
|
91
|
+
if (value.type === "session") return clean({ ...envelope, ...pick(value, ["version", "cwd", "parentSession"]) });
|
|
92
|
+
if (value.type === "message" && object(value.message)) {
|
|
93
|
+
const message = value.message;
|
|
94
|
+
if (!["user", "assistant", "toolResult"].includes(String(message.role))) {
|
|
95
|
+
note("unsupported-message");
|
|
96
|
+
return { ...envelope, omitted: "unsupported-message" };
|
|
97
|
+
}
|
|
98
|
+
const content = typeof message.content === "string" ? [{ type: "text", text: message.content }] : message.content;
|
|
99
|
+
if (!Array.isArray(content)) throw new Error("invalid native message content");
|
|
100
|
+
const visible = content.flatMap((block: unknown) => {
|
|
101
|
+
if (!object(block)) throw new Error("invalid native content block");
|
|
102
|
+
if (block.type === "text" && typeof block.text === "string") return [pick(block, ["type", "text"])];
|
|
103
|
+
if (block.type === "toolCall") return [pick(block, ["type", "id", "name", "arguments"])];
|
|
104
|
+
note(typeof block.type === "string" && privateType.test(block.type) ? "private-reasoning" : "non-text-content");
|
|
105
|
+
return [];
|
|
106
|
+
});
|
|
107
|
+
return clean({
|
|
108
|
+
...envelope,
|
|
109
|
+
message: {
|
|
110
|
+
...pick(message, ["role", "toolCallId", "toolName", "isError", "provider", "model", "stopReason", "usage"]),
|
|
111
|
+
content: visible,
|
|
112
|
+
},
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
if (["model_change", "thinking_level_change", "session_info"].includes(String(value.type)))
|
|
116
|
+
return clean({ ...envelope, ...pick(value, ["provider", "modelId", "thinkingLevel", "name"]) });
|
|
117
|
+
note("unsupported-session-entry");
|
|
118
|
+
return clean({ ...envelope, omitted: "unsupported-session-entry" });
|
|
119
|
+
};
|
|
120
|
+
let output: string;
|
|
121
|
+
if (format === "text") output = redact(text);
|
|
122
|
+
else if (format === "json") output = JSON.stringify(clean(parseRetentionJson(text, 16 * 1024 * 1024))) + "\n";
|
|
123
|
+
else {
|
|
124
|
+
const lines = text.split("\n");
|
|
125
|
+
if (lines.at(-1) === "") lines.pop();
|
|
126
|
+
if (format === "session") {
|
|
127
|
+
const header = parseRetentionJson(lines[0] ?? "", 16 * 1024 * 1024);
|
|
128
|
+
if (!object(header) || header.type !== "session" || header.version !== 3 || typeof header.id !== "string")
|
|
129
|
+
throw new Error("expected native Pi session v3");
|
|
130
|
+
}
|
|
131
|
+
output =
|
|
132
|
+
lines
|
|
133
|
+
.map((line) => {
|
|
134
|
+
const value = parseRetentionJson(line, 16 * 1024 * 1024);
|
|
135
|
+
return JSON.stringify(format === "session" ? session(value) : clean(value));
|
|
136
|
+
})
|
|
137
|
+
.join("\n") + "\n";
|
|
138
|
+
}
|
|
139
|
+
return { bytes: Buffer.from(output), omissions };
|
|
140
|
+
}
|