gentle-pi 2.5.0 → 2.6.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 +139 -30
- package/assets/agents/gentle-ai-worker.md +4 -0
- package/assets/agents/jd-fix-agent.md +18 -0
- package/assets/agents/jd-judge-a.md +1 -1
- package/assets/agents/jd-judge-b.md +1 -1
- package/assets/agents/sdd-apply.md +7 -5
- package/assets/agents/sdd-archive.md +5 -3
- package/assets/agents/sdd-design.md +4 -0
- package/assets/agents/sdd-explore.md +4 -0
- package/assets/agents/sdd-init.md +4 -0
- package/assets/agents/sdd-onboard.md +4 -0
- package/assets/agents/sdd-proposal.md +4 -0
- package/assets/agents/sdd-remediate.md +37 -0
- package/assets/agents/sdd-research.md +26 -3
- package/assets/agents/sdd-spec.md +4 -0
- package/assets/agents/sdd-status.md +9 -75
- package/assets/agents/sdd-sync.md +4 -0
- package/assets/agents/sdd-tasks.md +4 -0
- package/assets/agents/sdd-verify.md +5 -3
- package/assets/chains/sdd-full.chain.md +4 -0
- package/assets/chains/sdd-plan.chain.md +4 -0
- package/assets/chains/sdd-verify.chain.md +4 -0
- package/assets/migrations/managed-assets-v2.5.0.json +7 -0
- package/assets/orchestrator-delegation.md +21 -3
- package/assets/sdd-orchestrator-workflow.md +54 -21
- package/assets/support/sdd-status-contract.md +34 -90
- package/contracts/telemetry/runtime-aggregate-v1.schema.json +65 -0
- package/docs/telemetry.md +57 -1
- package/docs/windows-startup-console-visibility.md +18 -0
- package/extensions/ask-user-choice.ts +143 -15
- package/extensions/codegraph-tools.ts +1 -0
- package/extensions/gentle-agents.ts +799 -50
- package/extensions/gentle-ai.ts +2033 -322
- package/extensions/gentle-shell.ts +145 -42
- package/extensions/gentle-todo.ts +47 -12
- package/extensions/quiet-tools.ts +1 -0
- package/extensions/runtime-metrics.ts +130 -0
- package/extensions/sdd-init.ts +2 -2
- package/extensions/startup-banner.ts +52 -75
- package/lib/agent-profiles.ts +550 -0
- package/lib/agents-completion-delivery.ts +72 -0
- package/lib/agents-config.ts +7 -10
- package/lib/agents-history.ts +9 -1
- package/lib/agents-messaging.ts +187 -0
- package/lib/agents-protocol.ts +77 -5
- package/lib/agents-runner.ts +548 -26
- package/lib/agents-thread-view.ts +57 -0
- package/lib/agents-view-layout.ts +40 -0
- package/lib/agents-view.ts +548 -191
- package/lib/agents-widget.ts +33 -14
- package/lib/gentle-ai-binary.ts +3 -1
- package/lib/gentle-ai-renderer.ts +8 -6
- package/lib/native-review-cli.ts +283 -1
- package/lib/orchestrator-presence.ts +337 -0
- package/lib/profiles-orchestrator.ts +203 -0
- package/lib/review-candidate-view-owner.ts +296 -46
- package/lib/review-candidate-view.ts +30 -20
- package/lib/review-consent-component.ts +247 -0
- package/lib/review-consent-ui.ts +53 -8
- package/lib/review-host-relay.ts +28 -0
- package/lib/review-integration-v2.ts +187 -5
- package/lib/review-last-event-controller.ts +7 -4
- package/lib/review-reminder-receipt.ts +74 -0
- package/lib/review-session-standing-permission.ts +27 -6
- package/lib/runtime-metrics-children.ts +199 -0
- package/lib/runtime-metrics-delivery.ts +68 -0
- package/lib/runtime-metrics-native.ts +166 -0
- package/lib/runtime-metrics-pi-identity.ts +113 -0
- package/lib/runtime-metrics-policy.ts +51 -0
- package/lib/runtime-metrics.ts +255 -0
- package/lib/sdd-preflight.ts +362 -81
- package/lib/sdd-research-capabilities.ts +228 -0
- package/lib/sdd-status.ts +29 -7
- package/lib/session-worktree-registry.ts +118 -0
- package/lib/shell-bar.ts +47 -1
- package/lib/shell-card.ts +1 -4
- package/lib/shell-changes-view.ts +362 -37
- package/lib/shell-changes.ts +81 -1
- package/lib/shell-prompt.ts +11 -15
- package/lib/shell-sidebar-banner.ts +11 -0
- package/lib/shell-sidebar-layout.ts +213 -0
- package/lib/shell-sidebar.ts +41 -0
- package/lib/shell-todo.ts +28 -11
- package/lib/telemetry-trigger.ts +2 -0
- package/package.json +6 -3
- package/runtime/gentle-ai-binary.mjs +3 -1
- package/runtime/native-review-cli.mjs +283 -1
- package/runtime/review-integration-v2.mjs +187 -5
- package/runtime/telemetry-trigger.mjs +2 -0
- package/scripts/build-runtime-modules.mjs +9 -1
- package/scripts/check-types.mjs +125 -0
- package/scripts/gentle-ai-installer.mjs +10 -10
- package/scripts/install-gentle-ai.mjs +12 -0
- package/scripts/install-tui-mode-setting.mjs +114 -0
- package/scripts/test-packed-runner.mjs +16 -2
- package/scripts/types-baseline.json +99 -0
- package/scripts/verify-package-files.mjs +4 -2
- package/skills/_shared/review-ledger-contract.md +17 -1
- package/skills/issue-creation/SKILL.md +3 -3
- package/skills/judgment-day/SKILL.md +17 -3
- package/skills/judgment-day/references/prompts-and-formats.md +14 -3
- package/tests/agent-profiles.test.ts +722 -0
- package/tests/agents-completion-delivery.test.ts +94 -0
- package/tests/agents-config.test.ts +62 -0
- package/tests/agents-fake-child.ts +15 -1
- package/tests/agents-grouping.test.ts +179 -0
- package/tests/agents-integration.test.ts +100 -0
- package/tests/agents-messaging.test.ts +94 -0
- package/tests/agents-protocol.test.ts +45 -0
- package/tests/agents-queries.test.ts +190 -0
- package/tests/agents-responsive.test.ts +43 -0
- package/tests/agents-runner.test.ts +571 -14
- package/tests/agents-thread-view.test.ts +45 -0
- package/tests/agents-view.test.ts +476 -65
- package/tests/agents-widget.test.ts +31 -1
- package/tests/artifact-language.test.ts +25 -2
- package/tests/ask-user-choice.test.ts +169 -3
- package/tests/asset-installation-runtime.test.ts +108 -0
- package/tests/autonomous-guard.test.ts +116 -1
- package/tests/codegraph-tools.test.ts +2 -1
- package/tests/delegated-key-learnings-contract.test.ts +1 -1
- package/tests/devbinary/native-review-parity.devtest.ts +2 -0
- package/tests/feature-request-form.test.ts +67 -0
- package/tests/fixtures/agents-messaging-child.mjs +5 -0
- package/tests/fixtures/runtime-metrics-native-batches.json +6 -0
- package/tests/gentle-agents.test.ts +1460 -33
- package/tests/gentle-ai-binary.test.ts +7 -2
- package/tests/gentle-ai-installer.test.ts +47 -47
- package/tests/gentle-ai-renderer.test.ts +38 -0
- package/tests/gentle-ai.test.ts +944 -4
- package/tests/gentle-shell.test.ts +303 -12
- package/tests/gentle-todo.test.ts +54 -10
- package/tests/install-tui-mode-setting.test.ts +324 -0
- package/tests/issue-creation-skill.test.ts +22 -0
- package/tests/model-routing-authority.test.ts +12 -0
- package/tests/native-review-capability-contract.test.ts +12 -1
- package/tests/native-review-cli.test.ts +277 -3
- package/tests/native-review-parity.test.ts +14 -7
- package/tests/native-sdd-attempt-authority.test.ts +7 -2
- package/tests/orchestrator-presence.test.ts +389 -0
- package/tests/package-manifest.test.ts +232 -7
- package/tests/profiles-orchestrator.test.ts +208 -0
- package/tests/quiet-tool-rendering.test.ts +1 -0
- package/tests/rdd-aware-verification-contract.test.ts +10 -0
- package/tests/review-agent-end-preflight.test.ts +332 -24
- package/tests/review-candidate-view.test.ts +304 -6
- package/tests/review-consent-ui.test.ts +352 -0
- package/tests/review-contract-prompt.test.ts +14 -0
- package/tests/review-controller-native-routing.test.ts +563 -3
- package/tests/review-controller.test.ts +1 -1
- package/tests/review-host-relay-restart-parity.test.ts +142 -1
- package/tests/review-host-relay-routing.test.ts +364 -4
- package/tests/review-host-relay.test.ts +29 -0
- package/tests/review-integration-v2-forward.test.ts +44 -0
- package/tests/review-integration-v2.test.ts +164 -0
- package/tests/review-last-event-closure.test.ts +105 -1
- package/tests/review-ledger-contract.test.ts +61 -6
- package/tests/review-reminder-receipt.test.ts +62 -0
- package/tests/review-session-standing-permission-controller.test.ts +52 -4
- package/tests/review-session-standing-permission.test.ts +30 -0
- package/tests/runtime-harness.mjs +447 -39
- package/tests/runtime-metrics-children.test.ts +206 -0
- package/tests/runtime-metrics-delivery.test.ts +85 -0
- package/tests/runtime-metrics-extension.test.ts +187 -0
- package/tests/runtime-metrics-native.test.ts +209 -0
- package/tests/runtime-metrics-pi-identity.test.ts +113 -0
- package/tests/runtime-metrics-policy.test.ts +62 -0
- package/tests/runtime-metrics.test.ts +184 -0
- package/tests/sdd-agent-tools.test.ts +10 -1
- package/tests/sdd-execution-routing-contract.test.ts +28 -0
- package/tests/sdd-managed-runtime-settlement.test.ts +331 -0
- package/tests/sdd-native-managed-uptake.test.ts +253 -0
- package/tests/sdd-planning-routing-contract.test.ts +45 -0
- package/tests/sdd-preflight.test.ts +252 -8
- package/tests/sdd-research-capabilities.test.ts +256 -0
- package/tests/sdd-research-live.test.ts +241 -0
- package/tests/sdd-selection-transport.test.ts +504 -0
- package/tests/sdd-status.test.ts +51 -0
- package/tests/session-worktree-registry.test.ts +135 -0
- package/tests/shell-card.test.ts +24 -3
- package/tests/shell-changes-view.test.ts +471 -8
- package/tests/shell-changes.test.ts +168 -0
- package/tests/shell-prompt.test.ts +28 -6
- package/tests/shell-sidebar-banner.test.ts +23 -0
- package/tests/shell-sidebar-layout.test.ts +387 -0
- package/tests/shell-sidebar.test.ts +50 -0
- package/tests/shell-todo.test.ts +100 -11
- package/tests/startup-banner.test.ts +126 -0
- package/tests/telemetry-trigger.test.ts +3 -1
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import { readFileSync, readdirSync } from "node:fs";
|
|
2
|
+
import { parseAgentDefinition, type AgentDefinition, type ModelRef } from "./agents-config.ts";
|
|
3
|
+
import type { ChildObservationSnapshot } from "./agents-runner.ts";
|
|
4
|
+
import { FINISHED_STATUSES, type TaskStatus } from "./agents-protocol.ts";
|
|
5
|
+
import { classifyRuntimeModelId, EFFORTS, parseAgentClass, RuntimeMetrics, UNKNOWN_AGENT_CLASS, validRuntimeResponse, type AgentClass, type FinalResponse, type RuntimeMetricBucket } from "./runtime-metrics.ts";
|
|
6
|
+
import { classifyPiCatalogName } from "./runtime-metrics-pi-identity.ts";
|
|
7
|
+
export const CHILD_METRICS_EVENT = "gentle:runtime-metrics:child/v1";
|
|
8
|
+
// Local revocation notification invalidates active observations. Contains only
|
|
9
|
+
// the local session join, never policy output.
|
|
10
|
+
export const CHILD_METRICS_REVOKED = "gentle:runtime-metrics:revoked/v1";
|
|
11
|
+
const missing = { state: "unavailable" } as const;
|
|
12
|
+
const providers = ["anthropic", "openai", "openai-codex", "google", "google-vertex", "amazon-bedrock", "openrouter", "custom", "unknown"];
|
|
13
|
+
const tokenFields = ["input", "output", "cacheRead", "cacheWrite", "reasoning", "totalTokens"] as const;
|
|
14
|
+
/** Recognize only names from this package's fixed assets and the transport's
|
|
15
|
+
* closed agent_class enum. Customized packaged agents retain their schema name;
|
|
16
|
+
* user-defined names never enter telemetry. Exact fingerprints preserve the
|
|
17
|
+
* worker/explore/verify compatibility mapping whose packaged names are prefixed.
|
|
18
|
+
* Model/thinking routing is excluded from fingerprints because installation
|
|
19
|
+
* rewrites it. No installed files are read; this class is not execution/review authority.
|
|
20
|
+
* Only the fixed package catalog is cached; runtime instructions are not retained.
|
|
21
|
+
*/
|
|
22
|
+
let definitions: Array<{ name: string; fingerprint: string; fingerprintClass?: AgentClass }> | undefined;
|
|
23
|
+
const packagedAgentClassAliases = new Map([["sdd-proposal", "sdd-propose"]] as const);
|
|
24
|
+
function fingerprintAgentClassName(name: string): string {
|
|
25
|
+
const compatibilityName = name.startsWith("gentle-ai-") ? name.slice("gentle-ai-".length) : name;
|
|
26
|
+
return packagedAgentClassAliases.get(compatibilityName) ?? compatibilityName;
|
|
27
|
+
}
|
|
28
|
+
function fingerprint(agent: AgentDefinition): string {
|
|
29
|
+
return JSON.stringify([agent.name, agent.description, agent.instructions, agent.tools, agent.mode]);
|
|
30
|
+
}
|
|
31
|
+
export function classifyBuiltinAgent(agent: AgentDefinition): AgentClass {
|
|
32
|
+
try {
|
|
33
|
+
definitions ??= readdirSync(new URL("../assets/agents/", import.meta.url))
|
|
34
|
+
.filter(file => file.endsWith(".md")).map(file => {
|
|
35
|
+
const path = new URL(`../assets/agents/${file}`, import.meta.url);
|
|
36
|
+
const parsed = parseAgentDefinition(readFileSync(path, "utf8"), path.pathname, "global");
|
|
37
|
+
if (!("instructions" in parsed)) throw new Error("Invalid packaged definition");
|
|
38
|
+
return { name: parsed.name, fingerprint: fingerprint(parsed),
|
|
39
|
+
fingerprintClass: parseAgentClass(fingerprintAgentClassName(parsed.name)) };
|
|
40
|
+
});
|
|
41
|
+
const namedClass = parseAgentClass(packagedAgentClassAliases.get(agent.name) ?? agent.name);
|
|
42
|
+
if (namedClass && definitions.some(entry => entry.name === agent.name)) return namedClass;
|
|
43
|
+
return definitions.find(entry => entry.fingerprintClass && entry.fingerprint === fingerprint(agent))?.fingerprintClass ?? UNKNOWN_AGENT_CLASS;
|
|
44
|
+
} catch { return UNKNOWN_AGENT_CLASS; }
|
|
45
|
+
}
|
|
46
|
+
function provider(value: unknown): FinalResponse["provider"] {
|
|
47
|
+
return providers.includes(value as string) ? value as FinalResponse["provider"] : value ? "custom" : "unknown";
|
|
48
|
+
}
|
|
49
|
+
function modelId(namespace: unknown, value: unknown): string {
|
|
50
|
+
if (value === "unknown" || value === undefined) return "unknown";
|
|
51
|
+
return classifyRuntimeModelId(namespace, value, classifyPiCatalogName);
|
|
52
|
+
}
|
|
53
|
+
function effort(value: unknown): FinalResponse["effort"] {
|
|
54
|
+
return EFFORTS.includes(value as FinalResponse["effort"]) ? value as FinalResponse["effort"] : "unavailable";
|
|
55
|
+
}
|
|
56
|
+
export interface LaunchSelection {
|
|
57
|
+
agentClass: AgentClass;
|
|
58
|
+
selectedProvider: FinalResponse["provider"];
|
|
59
|
+
selectedModelId: string;
|
|
60
|
+
selectedEffort: FinalResponse["effort"];
|
|
61
|
+
}
|
|
62
|
+
export interface ChildLaunchBucket extends LaunchSelection {
|
|
63
|
+
evidence: "launch_configuration";
|
|
64
|
+
launches: number;
|
|
65
|
+
}
|
|
66
|
+
export function launchSelection(agent: AgentDefinition, model: ModelRef | undefined, thinking: unknown): LaunchSelection {
|
|
67
|
+
return { agentClass: classifyBuiltinAgent(agent), selectedProvider: provider(model?.provider),
|
|
68
|
+
selectedModelId: modelId(model?.provider, model?.id), selectedEffort: effort(thinking) };
|
|
69
|
+
}
|
|
70
|
+
/** LOCAL event only: these two bounded join IDs must never enter output/export. */
|
|
71
|
+
export interface ChildMetricsEvent {
|
|
72
|
+
schema: typeof CHILD_METRICS_EVENT;
|
|
73
|
+
parentSessionId: string;
|
|
74
|
+
taskId: string;
|
|
75
|
+
launchedAt: number;
|
|
76
|
+
launch: LaunchSelection;
|
|
77
|
+
status: TaskStatus;
|
|
78
|
+
agentSettled: boolean;
|
|
79
|
+
coverage: "final_assistant_messages_only";
|
|
80
|
+
droppedResponses: number;
|
|
81
|
+
responses: FinalResponse[];
|
|
82
|
+
}
|
|
83
|
+
export function childEvent(parentSessionId: string, taskId: string, launch: LaunchSelection, status: TaskStatus,
|
|
84
|
+
snapshot: ChildObservationSnapshot, launchedAt = 0): ChildMetricsEvent | undefined {
|
|
85
|
+
const result: ChildMetricsEvent = { schema: CHILD_METRICS_EVENT, parentSessionId, taskId, launchedAt, launch: { ...launch }, status,
|
|
86
|
+
agentSettled: snapshot.agentSettled, coverage: snapshot.coverage, droppedResponses: snapshot.droppedResponses,
|
|
87
|
+
responses: snapshot.responses.slice(0, 128).map((row, index) => {
|
|
88
|
+
const namespace = row.provider.state === "observed" ? row.provider.value : undefined;
|
|
89
|
+
const name = (value: typeof row.model) => value.state === "observed" ? modelId(namespace, value.value) : undefined;
|
|
90
|
+
return { kind: "final_assistant_response", responseId: String(index), agentClass: launch.agentClass,
|
|
91
|
+
executor: "worker", provider: provider(namespace), modelFamily: "unknown", observedModelId: name(row.model),
|
|
92
|
+
responseModelId: name(row.responseModel), providerThinkingLevel: effort(row.providerThinkingLevel.state === "observed" ? row.providerThinkingLevel.value : undefined),
|
|
93
|
+
selectedProvider: launch.selectedProvider, selectedModelId: launch.selectedModelId, effort: launch.selectedEffort,
|
|
94
|
+
error: row.stopReason === "error" ? "unknown" : row.stopReason === "aborted" ? "aborted" : "none",
|
|
95
|
+
tokens: Object.fromEntries(tokenFields.map(field => [field, { ...row.tokens[field] }])) as FinalResponse["tokens"],
|
|
96
|
+
responseHeadersMs: missing, fullResponseMs: missing };
|
|
97
|
+
}) };
|
|
98
|
+
return validEvent(result) ? result : undefined;
|
|
99
|
+
}
|
|
100
|
+
function validEvent(value: unknown): value is ChildMetricsEvent {
|
|
101
|
+
if (!value || typeof value !== "object") return false;
|
|
102
|
+
const e = value as ChildMetricsEvent;
|
|
103
|
+
const id = (value: unknown) => typeof value === "string" && value.length > 0 && value.length <= 128;
|
|
104
|
+
const l = e.launch;
|
|
105
|
+
return e.schema === CHILD_METRICS_EVENT && id(e.taskId) && id(e.parentSessionId)
|
|
106
|
+
&& Number.isFinite(e.launchedAt) && e.launchedAt >= 0
|
|
107
|
+
&& e.coverage === "final_assistant_messages_only" && typeof e.agentSettled === "boolean"
|
|
108
|
+
&& FINISHED_STATUSES.includes(e.status) && Number.isSafeInteger(e.droppedResponses) && e.droppedResponses >= 0
|
|
109
|
+
&& e.droppedResponses <= Number.MAX_SAFE_INTEGER && !!l && !!parseAgentClass(l.agentClass) && l.agentClass !== "orchestrator"
|
|
110
|
+
&& provider(l.selectedProvider) === l.selectedProvider && modelId(l.selectedProvider, l.selectedModelId) === l.selectedModelId
|
|
111
|
+
&& effort(l.selectedEffort) === l.selectedEffort && Array.isArray(e.responses) && e.responses.length <= 128
|
|
112
|
+
&& e.responses.every(row => validRuntimeResponse(row) && row.agentClass === l.agentClass
|
|
113
|
+
&& row.executor === "worker" && row.effort === l.selectedEffort && row.selectedProvider === l.selectedProvider && row.selectedModelId === l.selectedModelId
|
|
114
|
+
&& provider(row.provider) === row.provider && effort(row.providerThinkingLevel) === row.providerThinkingLevel
|
|
115
|
+
&& (row.observedModelId === undefined || modelId(row.provider, row.observedModelId) === row.observedModelId)
|
|
116
|
+
&& (row.responseModelId === undefined || modelId(row.provider, row.responseModelId) === row.responseModelId)
|
|
117
|
+
&& row.fullResponseMs.state === "unavailable" && row.responseHeadersMs.state === "unavailable");
|
|
118
|
+
}
|
|
119
|
+
/** Copy only validated local fields at the completion callback.
|
|
120
|
+
* Do not clone arbitrary event properties or retain a caller-mutable bus object.
|
|
121
|
+
*/
|
|
122
|
+
export function snapshotChildEvent(value: unknown): ChildMetricsEvent | undefined {
|
|
123
|
+
if (!validEvent(value)) return undefined;
|
|
124
|
+
const l = value.launch;
|
|
125
|
+
return { schema: CHILD_METRICS_EVENT, parentSessionId: value.parentSessionId, taskId: value.taskId, launchedAt: value.launchedAt,
|
|
126
|
+
launch: { agentClass: l.agentClass, selectedProvider: l.selectedProvider, selectedModelId: l.selectedModelId, selectedEffort: l.selectedEffort },
|
|
127
|
+
status: value.status, agentSettled: value.agentSettled, coverage: value.coverage, droppedResponses: value.droppedResponses,
|
|
128
|
+
responses: value.responses.map((row, index) => ({ kind: "final_assistant_response", responseId: String(index),
|
|
129
|
+
agentClass: row.agentClass, executor: "worker", provider: row.provider, observedModelId: row.observedModelId,
|
|
130
|
+
responseModelId: row.responseModelId, providerThinkingLevel: row.providerThinkingLevel, modelFamily: "unknown",
|
|
131
|
+
selectedProvider: l.selectedProvider, selectedModelId: l.selectedModelId, effort: l.selectedEffort, error: row.error,
|
|
132
|
+
tokens: Object.fromEntries(tokenFields.map(field => {
|
|
133
|
+
const t = row.tokens[field];
|
|
134
|
+
return [field, t?.state === "reported" ? { state: "reported", value: t.value } : { state: t?.state ?? "unavailable" }];
|
|
135
|
+
})) as FinalResponse["tokens"], responseHeadersMs: missing, fullResponseMs: missing })) };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Export-facing shape: no IDs, paths, task labels, or raw agent/model names.
|
|
139
|
+
* Rankings are descending counts, NOT quality/success comparisons. Native
|
|
140
|
+
* transport is deliberately absent. Response buckets retain selected launch
|
|
141
|
+
* identity and effort separately from effective response evidence.
|
|
142
|
+
*/
|
|
143
|
+
export interface ChildCompositionSnapshot {
|
|
144
|
+
launches: ChildLaunchBucket[];
|
|
145
|
+
responses: RuntimeMetricBucket[];
|
|
146
|
+
settled: number;
|
|
147
|
+
statuses: Record<"completed" | "failed" | "cancelled" | "timed_out", number>;
|
|
148
|
+
droppedResponses: number;
|
|
149
|
+
saturated: boolean;
|
|
150
|
+
}
|
|
151
|
+
export class ChildComposition {
|
|
152
|
+
#seen = new Set<string>();
|
|
153
|
+
#pending = new WeakSet<object>();
|
|
154
|
+
#metrics = new RuntimeMetrics();
|
|
155
|
+
#launches = new Map<string, ChildLaunchBucket>();
|
|
156
|
+
#count = 0;
|
|
157
|
+
#settled = 0;
|
|
158
|
+
#statuses = { completed: 0, failed: 0, cancelled: 0, timed_out: 0 };
|
|
159
|
+
#dropped = 0;
|
|
160
|
+
#saturated = false;
|
|
161
|
+
reserve(value: unknown, session: string): value is ChildMetricsEvent {
|
|
162
|
+
if (!validEvent(value) || value.parentSessionId !== session || this.#seen.has(value.taskId)) return false;
|
|
163
|
+
if (this.#seen.size >= 256) { this.#saturated = true; return false; }
|
|
164
|
+
this.#seen.add(value.taskId);
|
|
165
|
+
this.#pending.add(value);
|
|
166
|
+
return true;
|
|
167
|
+
}
|
|
168
|
+
record(event: ChildMetricsEvent): void {
|
|
169
|
+
if (!this.#pending.delete(event) || !validEvent(event)) return;
|
|
170
|
+
const key = JSON.stringify(event.launch);
|
|
171
|
+
let bucket = this.#launches.get(key);
|
|
172
|
+
if (!bucket && this.#launches.size < 64) {
|
|
173
|
+
bucket = { ...event.launch, evidence: "launch_configuration", launches: 0 };
|
|
174
|
+
this.#launches.set(key, bucket);
|
|
175
|
+
}
|
|
176
|
+
if (bucket) bucket.launches++;
|
|
177
|
+
else this.#saturated = true;
|
|
178
|
+
this.#settled += Number(event.agentSettled);
|
|
179
|
+
this.#statuses[event.status as keyof ChildCompositionSnapshot["statuses"]]++;
|
|
180
|
+
this.#dropped = Math.min(Number.MAX_SAFE_INTEGER, this.#dropped + event.droppedResponses);
|
|
181
|
+
for (const row of event.responses) {
|
|
182
|
+
if (this.#metrics.record({ ...row, responseId: String(this.#count + 1) }) === "recorded") this.#count++;
|
|
183
|
+
else { this.#dropped = Math.min(Number.MAX_SAFE_INTEGER, this.#dropped + 1); this.#saturated = true; }
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
clear(): void {
|
|
187
|
+
this.#metrics = new RuntimeMetrics();
|
|
188
|
+
this.#pending = new WeakSet();
|
|
189
|
+
this.#launches.clear();
|
|
190
|
+
this.#count = this.#settled = this.#dropped = 0;
|
|
191
|
+
this.#statuses = { completed: 0, failed: 0, cancelled: 0, timed_out: 0 };
|
|
192
|
+
// Bounded tombstones survive revocation; denied tasks cannot replay.
|
|
193
|
+
}
|
|
194
|
+
snapshot(): ChildCompositionSnapshot {
|
|
195
|
+
return structuredClone({ launches: [...this.#launches.values()].sort((a, b) => b.launches - a.launches),
|
|
196
|
+
responses: this.#metrics.snapshot().sort((a, b) => b.responses - a.responses),
|
|
197
|
+
settled: this.#settled, statuses: this.#statuses, droppedResponses: this.#dropped, saturated: this.#saturated });
|
|
198
|
+
}
|
|
199
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// Shared across extension instances, including a cancelled process still exiting.
|
|
2
|
+
let busy = false;
|
|
3
|
+
|
|
4
|
+
/** One accepted attempt, never a queue. Defer resolver/process work beyond the
|
|
5
|
+
* provider callback. Busy events are discarded rather than captured for later.
|
|
6
|
+
* The transport must settle only after its process exits. Print-mode shutdown
|
|
7
|
+
* may join the one accepted attempt for a bounded deadline; it never queues or
|
|
8
|
+
* retries work. Every other disposal remains immediate.
|
|
9
|
+
*/
|
|
10
|
+
export class RuntimeMetricsAttempt {
|
|
11
|
+
#closed = false;
|
|
12
|
+
#scheduled?: ReturnType<typeof setImmediate>;
|
|
13
|
+
#abort?: AbortController;
|
|
14
|
+
#settled?: Promise<void>;
|
|
15
|
+
#release?: () => void;
|
|
16
|
+
offer(work: (signal: AbortSignal) => Promise<unknown>): boolean {
|
|
17
|
+
if (this.#closed || busy) return false;
|
|
18
|
+
busy = true;
|
|
19
|
+
const abort = new AbortController();
|
|
20
|
+
this.#abort = abort;
|
|
21
|
+
let release!: () => void;
|
|
22
|
+
const settled = new Promise<void>(resolve => { release = resolve; });
|
|
23
|
+
this.#settled = settled;
|
|
24
|
+
this.#release = release;
|
|
25
|
+
this.#scheduled = setImmediate(() => {
|
|
26
|
+
this.#scheduled = undefined;
|
|
27
|
+
void (async () => {
|
|
28
|
+
try { if (!abort.signal.aborted) await work(abort.signal); }
|
|
29
|
+
catch { /* An attempt is always best effort; never retry or report. */ }
|
|
30
|
+
finally {
|
|
31
|
+
this.#abort = undefined;
|
|
32
|
+
busy = false;
|
|
33
|
+
if (this.#settled === settled) {
|
|
34
|
+
this.#settled = undefined;
|
|
35
|
+
this.#release = undefined;
|
|
36
|
+
}
|
|
37
|
+
release();
|
|
38
|
+
}
|
|
39
|
+
})();
|
|
40
|
+
});
|
|
41
|
+
return true;
|
|
42
|
+
}
|
|
43
|
+
/** Await only the already accepted attempt, up to the caller's deadline. */
|
|
44
|
+
async waitForSettled(timeoutMs: number): Promise<void> {
|
|
45
|
+
const settled = this.#settled;
|
|
46
|
+
if (!settled) return;
|
|
47
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
48
|
+
try {
|
|
49
|
+
await Promise.race([settled, new Promise<void>(resolve => { timer = setTimeout(resolve, timeoutMs); })]);
|
|
50
|
+
} finally {
|
|
51
|
+
if (timer) clearTimeout(timer);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
dispose(): void {
|
|
55
|
+
if (this.#closed) return;
|
|
56
|
+
this.#closed = true;
|
|
57
|
+
if (this.#scheduled) {
|
|
58
|
+
clearImmediate(this.#scheduled);
|
|
59
|
+
this.#scheduled = undefined;
|
|
60
|
+
busy = false;
|
|
61
|
+
this.#settled = undefined;
|
|
62
|
+
this.#release?.();
|
|
63
|
+
this.#release = undefined;
|
|
64
|
+
}
|
|
65
|
+
this.#abort?.abort();
|
|
66
|
+
this.#abort = undefined;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { spawn, type ChildProcessWithoutNullStreams, type SpawnOptionsWithoutStdio } from "node:child_process";
|
|
2
|
+
import { resolveGentleAiBinary } from "./gentle-ai-binary.ts";
|
|
3
|
+
import { runtimeMetricsEnvAllows } from "./runtime-metrics-policy.ts";
|
|
4
|
+
import type { RuntimeMetricBucket } from "./runtime-metrics.ts";
|
|
5
|
+
import type { ChildLaunchBucket } from "./runtime-metrics-children.ts";
|
|
6
|
+
|
|
7
|
+
import schema from "../contracts/telemetry/runtime-aggregate-v1.schema.json" with { type: "json" };
|
|
8
|
+
|
|
9
|
+
export type NativeRuntimeResult = "stored" | "duplicate" | "discarded" | "disabled";
|
|
10
|
+
export const NATIVE_SEND_ACK_SCHEMA = "gentle-ai.telemetry-runtime-send/v1";
|
|
11
|
+
const MAX_METRIC = schema.$defs.count.maximum;
|
|
12
|
+
const count = (value: number) => Number.isSafeInteger(value) && value >= 0 && value <= MAX_METRIC;
|
|
13
|
+
function publicModel(provider: string, id: string) {
|
|
14
|
+
const matches = (rule: { const?: string; enum?: string[] }, value: string) => rule.const === value || rule.enum?.includes(value);
|
|
15
|
+
if (schema.$defs.model.oneOf.some(({ properties }) => matches(properties.provider, provider) && matches(properties.id, id))) return { provider, id };
|
|
16
|
+
const kind = !id || id === "unknown" ? "unknown" : "custom";
|
|
17
|
+
return { provider: kind, id: kind };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Closed field projection of one event, not a session snapshot. Oversized or
|
|
21
|
+
* invalid events discard whole; never split into queued sends or invent IDs.
|
|
22
|
+
*/
|
|
23
|
+
export function encodeNativeRuntimeEvent(source: readonly RuntimeMetricBucket[], launches: readonly ChildLaunchBucket[] = []): string | undefined {
|
|
24
|
+
if (!Array.isArray(source) || !Array.isArray(launches) || source.length < 1
|
|
25
|
+
|| source.length + launches.length > schema.properties.rows.maxItems) return undefined;
|
|
26
|
+
try {
|
|
27
|
+
const rows: Array<Record<string, unknown>> = source.map(row => {
|
|
28
|
+
if (!count(row.responses)) throw new Error("Invalid occurrence coverage");
|
|
29
|
+
const token = (field: keyof RuntimeMetricBucket["tokens"]) => {
|
|
30
|
+
const t = row.tokens[field];
|
|
31
|
+
const coverage = { reported: t.reported, unavailable: t.unavailable, unsupported: t.unsupported, sum: t.sum };
|
|
32
|
+
if (!Object.values(coverage).every(count) || (!coverage.reported && coverage.sum !== 0)) throw new Error("Invalid coverage");
|
|
33
|
+
return coverage;
|
|
34
|
+
};
|
|
35
|
+
const agentClass = schema.$defs.row.properties.agent_class.enum.includes(row.agentClass) ? row.agentClass : "unknown";
|
|
36
|
+
const effort = (value: string) => schema.$defs.effort.enum.includes(value) ? value : "unavailable";
|
|
37
|
+
const response = typeof row.responseModelId === "string" && row.responseModelId !== "unknown" && row.responseModelId !== "";
|
|
38
|
+
const selected = typeof row.selectedModelId === "string" && row.selectedModelId !== "unknown" && row.selectedModelId !== "";
|
|
39
|
+
const model = response ? publicModel(row.provider, row.responseModelId)
|
|
40
|
+
: selected ? publicModel(row.selectedProvider, row.selectedModelId) : { provider: "unknown", id: "unknown" };
|
|
41
|
+
const time = row.fullResponseMs;
|
|
42
|
+
if (!count(time.measured) || !Number.isFinite(time.sum) || time.sum < 0 || time.sum > MAX_METRIC
|
|
43
|
+
|| (!time.measured && time.sum !== 0)) throw new Error("Invalid duration");
|
|
44
|
+
return { model, model_evidence: response ? "response" : selected ? "selected" : "unknown",
|
|
45
|
+
agent_kind: agentClass === "orchestrator" || agentClass === "unknown" ? agentClass : "built_in", agent_class: agentClass,
|
|
46
|
+
selected_effort: effort(row.effort), effective_effort: effort(row.providerThinkingLevel),
|
|
47
|
+
launches: null, responses: row.responses,
|
|
48
|
+
input_tokens: token("input"), output_tokens: token("output"), cache_read_tokens: token("cacheRead"),
|
|
49
|
+
cache_creation_tokens: token("cacheWrite"), reasoning_tokens: token("reasoning"), total_tokens: token("totalTokens"),
|
|
50
|
+
error_category: row.error === "authentication" ? "auth" : ["none", "aborted", "rate_limit"].includes(row.error) ? row.error : "unknown",
|
|
51
|
+
duration: time.measured > 0 ? { kind: "request", measured_count: time.measured, sum_ms: time.sum }
|
|
52
|
+
: { kind: "unavailable", measured_count: 0, sum_ms: null } };
|
|
53
|
+
});
|
|
54
|
+
for (const launch of launches) {
|
|
55
|
+
if (!count(launch.launches) || launch.evidence !== "launch_configuration") return undefined;
|
|
56
|
+
const agentClass = schema.$defs.row.properties.agent_class.enum.includes(launch.agentClass) ? launch.agentClass : "unknown";
|
|
57
|
+
const empty = { reported: 0, unavailable: 0, unsupported: 0, sum: 0 };
|
|
58
|
+
rows.push({ model: publicModel(launch.selectedProvider, launch.selectedModelId), model_evidence: "selected",
|
|
59
|
+
agent_kind: agentClass === "orchestrator" || agentClass === "unknown" ? agentClass : "built_in", agent_class: agentClass,
|
|
60
|
+
selected_effort: schema.$defs.effort.enum.includes(launch.selectedEffort) ? launch.selectedEffort : "unavailable",
|
|
61
|
+
effective_effort: "unavailable", launches: launch.launches, responses: null,
|
|
62
|
+
input_tokens: empty, output_tokens: empty, cache_read_tokens: empty, cache_creation_tokens: empty,
|
|
63
|
+
reasoning_tokens: empty, total_tokens: empty, error_category: "unknown",
|
|
64
|
+
duration: { kind: "unavailable", measured_count: 0, sum_ms: null } });
|
|
65
|
+
}
|
|
66
|
+
const payload = JSON.stringify({ schema: schema.properties.schema.const, registry: schema.properties.registry.const, host: "pi", rows });
|
|
67
|
+
return Buffer.byteLength(payload) <= 16384 ? payload : undefined;
|
|
68
|
+
} catch { return undefined; }
|
|
69
|
+
}
|
|
70
|
+
export interface NativeRuntimeTransportDeps {
|
|
71
|
+
/** Observed launch occurrence accompanying this child completion, not totals. */
|
|
72
|
+
launches?: readonly ChildLaunchBucket[];
|
|
73
|
+
env?: NodeJS.ProcessEnv;
|
|
74
|
+
signal?: AbortSignal;
|
|
75
|
+
current?: () => boolean;
|
|
76
|
+
/** Injected test seams; production uses the verified pin or validated dev override. */
|
|
77
|
+
resolve?: () => string;
|
|
78
|
+
encode?: typeof encodeNativeRuntimeEvent;
|
|
79
|
+
spawn?: (file: string, args: string[], options: SpawnOptionsWithoutStdio) => ChildProcessWithoutNullStreams;
|
|
80
|
+
}
|
|
81
|
+
let busy = false;
|
|
82
|
+
|
|
83
|
+
/** Native owns fresh policy and exactly one POST. No policy/capability probe,
|
|
84
|
+
* disk intake, flush, queue or retry. Call via RuntimeMetricsAttempt so binary
|
|
85
|
+
* verification is outside the provider callback. Cancellation requests a kill;
|
|
86
|
+
* the slot stays occupied until close, never permitting overlapping processes.
|
|
87
|
+
*/
|
|
88
|
+
export async function sendNativeRuntimeEvent(rows: readonly RuntimeMetricBucket[], cwd: string,
|
|
89
|
+
deps: NativeRuntimeTransportDeps = {}): Promise<NativeRuntimeResult> {
|
|
90
|
+
const env = deps.env ?? process.env;
|
|
91
|
+
if (!runtimeMetricsEnvAllows(env)) return "disabled";
|
|
92
|
+
if (busy || deps.signal?.aborted || deps.current?.() === false) return "discarded";
|
|
93
|
+
busy = true;
|
|
94
|
+
try {
|
|
95
|
+
const payload = (deps.encode ?? encodeNativeRuntimeEvent)(rows, deps.launches);
|
|
96
|
+
if (!payload || Buffer.byteLength(payload) > 16384) return "discarded";
|
|
97
|
+
const file = (deps.resolve ?? resolveGentleAiBinary)();
|
|
98
|
+
if (!runtimeMetricsEnvAllows(env)) return "disabled";
|
|
99
|
+
if (deps.signal?.aborted || deps.current?.() === false) return "discarded";
|
|
100
|
+
return await sendProcess(file, cwd, payload, env, deps.spawn ?? spawn, deps.signal);
|
|
101
|
+
} catch { return "discarded"; }
|
|
102
|
+
finally { busy = false; }
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function sendProcess(file: string, cwd: string, payload: string, env: NodeJS.ProcessEnv,
|
|
106
|
+
launch: NonNullable<NativeRuntimeTransportDeps["spawn"]>, signal?: AbortSignal): Promise<NativeRuntimeResult> {
|
|
107
|
+
return new Promise(done => {
|
|
108
|
+
let child: ChildProcessWithoutNullStreams | undefined;
|
|
109
|
+
let output = "";
|
|
110
|
+
let rejected = false;
|
|
111
|
+
let settled = false;
|
|
112
|
+
const ignore = () => {};
|
|
113
|
+
const finish = (result: NativeRuntimeResult) => {
|
|
114
|
+
if (settled) return;
|
|
115
|
+
settled = true;
|
|
116
|
+
clearTimeout(timer);
|
|
117
|
+
signal?.removeEventListener("abort", cancel);
|
|
118
|
+
output = "";
|
|
119
|
+
if (child) {
|
|
120
|
+
for (const stream of [child.stdin, child.stdout, child.stderr]) {
|
|
121
|
+
stream.on("error", ignore);
|
|
122
|
+
try { stream.destroy(); } catch { /* Best effort cleanup. */ }
|
|
123
|
+
}
|
|
124
|
+
try { child.unref(); } catch { /* No diagnostics. */ }
|
|
125
|
+
}
|
|
126
|
+
done(result);
|
|
127
|
+
};
|
|
128
|
+
const cancel = () => {
|
|
129
|
+
if (rejected || settled) return;
|
|
130
|
+
rejected = true;
|
|
131
|
+
output = "";
|
|
132
|
+
try { child?.kill("SIGKILL"); } catch { /* Keep busy until close. */ }
|
|
133
|
+
};
|
|
134
|
+
const timer = setTimeout(cancel, 1000);
|
|
135
|
+
timer.unref();
|
|
136
|
+
try {
|
|
137
|
+
child = launch(file, ["telemetry", "runtime", "send", "--json"],
|
|
138
|
+
{ cwd, env, shell: false, windowsHide: true, stdio: ["pipe", "pipe", "pipe"] });
|
|
139
|
+
child.on("error", cancel);
|
|
140
|
+
for (const stream of [child.stdin, child.stdout, child.stderr]) stream.on("error", cancel);
|
|
141
|
+
child.stdout.on("data", (chunk: Buffer) => {
|
|
142
|
+
if (rejected || settled) return;
|
|
143
|
+
if (Buffer.byteLength(output) + chunk.length > 2048) { cancel(); return; }
|
|
144
|
+
output += chunk.toString("utf8");
|
|
145
|
+
});
|
|
146
|
+
child.stderr.on("data", cancel); // Do not retain or expose raw errors.
|
|
147
|
+
child.on("close", (code: number | null, exitSignal: NodeJS.Signals | null) => {
|
|
148
|
+
let result: NativeRuntimeResult = "discarded";
|
|
149
|
+
if (!rejected && code === 0 && exitSignal === null) {
|
|
150
|
+
try {
|
|
151
|
+
const value = JSON.parse(output);
|
|
152
|
+
if (Object.keys(value).sort().join(",") === "decision,schema" && value.schema === NATIVE_SEND_ACK_SCHEMA
|
|
153
|
+
&& ["stored", "duplicate", "discarded", "disabled"].includes(value.decision)) result = value.decision;
|
|
154
|
+
} catch { /* Invalid/old acknowledgements discard. */ }
|
|
155
|
+
}
|
|
156
|
+
finish(result);
|
|
157
|
+
});
|
|
158
|
+
signal?.addEventListener("abort", cancel, { once: true });
|
|
159
|
+
if (signal?.aborted) cancel();
|
|
160
|
+
if (!rejected) child.stdin.end(payload, "utf8");
|
|
161
|
+
} catch {
|
|
162
|
+
if (child) cancel();
|
|
163
|
+
else finish("discarded");
|
|
164
|
+
}
|
|
165
|
+
});
|
|
166
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { findPackageJSON } from "node:module";
|
|
2
|
+
|
|
3
|
+
// Catalog-name privacy classification ONLY. No identity issuance, route evidence,
|
|
4
|
+
// registry reads, SDK hooks, auth resolution, network, or injected catalogs.
|
|
5
|
+
export interface PiCatalogName {
|
|
6
|
+
readonly classification: "catalog_public" | "custom" | "unknown";
|
|
7
|
+
readonly modelId: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const UNKNOWN: PiCatalogName = Object.freeze({ classification: "unknown", modelId: "unknown" });
|
|
11
|
+
const CUSTOM: PiCatalogName = Object.freeze({ classification: "custom", modelId: "custom" });
|
|
12
|
+
// Initial provider coverage matches the accumulator. Other providers are custom;
|
|
13
|
+
// additions require deliberate review. These are providers, not model-ID lists.
|
|
14
|
+
const CATALOGS = ["anthropic", "openai", "openai-codex", "google", "google-vertex", "amazon-bedrock", "openrouter"] as const;
|
|
15
|
+
const MAX_MODELS = 4096;
|
|
16
|
+
const MAX_LOAD_ATTEMPTS = 3;
|
|
17
|
+
|
|
18
|
+
function object(value: unknown): value is Record<string, unknown> {
|
|
19
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function text(value: unknown, max: number): value is string {
|
|
23
|
+
return typeof value === "string" && value.length > 0 && value.length <= max;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function key(provider: string, modelId: string): string {
|
|
27
|
+
return JSON.stringify([provider, modelId]);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function loadCatalog(): Promise<ReadonlyMap<string, PiCatalogName>> {
|
|
31
|
+
// Node >=22.19 supports findPackageJSON. Verify ESM lookup from this module
|
|
32
|
+
// selects Pi's own pi-ai package; fail rather than silently use another copy.
|
|
33
|
+
const pi = import.meta.resolve("@earendil-works/pi-coding-agent");
|
|
34
|
+
const ownPackage = findPackageJSON("@earendil-works/pi-ai", import.meta.url);
|
|
35
|
+
if (!ownPackage || ownPackage !== findPackageJSON("@earendil-works/pi-ai", pi)) {
|
|
36
|
+
throw new Error("Pi catalog dependency mismatch");
|
|
37
|
+
}
|
|
38
|
+
const entries = new Map<string, PiCatalogName>();
|
|
39
|
+
let count = 0;
|
|
40
|
+
for (const provider of CATALOGS) {
|
|
41
|
+
// pi-ai 0.85.1 providers/* has an import-only export condition: use ESM,
|
|
42
|
+
// never require.resolve. Generated modules load packaged JSON, not registry
|
|
43
|
+
// overrides. Specifiers and export names come only from the fixed list.
|
|
44
|
+
const module = await import(`@earendil-works/pi-ai/providers/${provider}.models`);
|
|
45
|
+
const models: unknown = module[`${provider.replaceAll("-", "_").toUpperCase()}_MODELS`];
|
|
46
|
+
if (!object(models)) throw new Error("Invalid packaged model catalog");
|
|
47
|
+
for (const model of Object.values(models)) {
|
|
48
|
+
if (++count > MAX_MODELS) throw new RangeError("Packaged model catalog exceeds capacity");
|
|
49
|
+
if (!object(model) || model.provider !== provider || !text(model.id, 128)) {
|
|
50
|
+
throw new Error("Invalid packaged model name");
|
|
51
|
+
}
|
|
52
|
+
entries.set(key(provider, model.id), Object.freeze({ classification: "catalog_public", modelId: model.id }));
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return entries;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function createPiCatalogNameLookup(
|
|
59
|
+
load: () => Promise<ReadonlyMap<string, PiCatalogName>> = loadCatalog,
|
|
60
|
+
maxAttempts = MAX_LOAD_ATTEMPTS,
|
|
61
|
+
): { lookup(input: unknown): Promise<PiCatalogName>; classify(input: unknown): PiCatalogName } {
|
|
62
|
+
let catalogPromise: Promise<ReadonlyMap<string, PiCatalogName>> | undefined;
|
|
63
|
+
let loadedCatalog: ReadonlyMap<string, PiCatalogName> | undefined;
|
|
64
|
+
let attempts = 0;
|
|
65
|
+
const classify = (input: unknown): PiCatalogName => {
|
|
66
|
+
if (!object(input) || !text(input.provider, 32) || !text(input.modelId, 128)) return UNKNOWN;
|
|
67
|
+
if (!CATALOGS.includes(input.provider as typeof CATALOGS[number])) return CUSTOM;
|
|
68
|
+
return loadedCatalog ? loadedCatalog.get(key(input.provider, input.modelId)) ?? CUSTOM : UNKNOWN;
|
|
69
|
+
};
|
|
70
|
+
const lookup = async (input: unknown): Promise<PiCatalogName> => {
|
|
71
|
+
if (!object(input) || !text(input.provider, 32) || !text(input.modelId, 128)) return UNKNOWN;
|
|
72
|
+
if (!CATALOGS.includes(input.provider as typeof CATALOGS[number])) return CUSTOM;
|
|
73
|
+
if (!loadedCatalog) {
|
|
74
|
+
if (!catalogPromise) {
|
|
75
|
+
if (attempts >= maxAttempts) throw new Error("Pi catalog load attempts exhausted");
|
|
76
|
+
attempts++;
|
|
77
|
+
catalogPromise = load().then(catalog => {
|
|
78
|
+
loadedCatalog = catalog;
|
|
79
|
+
return catalog;
|
|
80
|
+
}, error => {
|
|
81
|
+
catalogPromise = undefined;
|
|
82
|
+
throw error;
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
await catalogPromise;
|
|
86
|
+
}
|
|
87
|
+
return classify(input);
|
|
88
|
+
};
|
|
89
|
+
return { lookup, classify };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const catalogLookup = createPiCatalogNameLookup();
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Returns only a privacy-safe catalog name, NOT the model actually dispatched.
|
|
96
|
+
* Matching a public ID remains a public-name fact even on a custom endpoint;
|
|
97
|
+
* origin/API/endpoint strings are ignored, never treated as provenance evidence.
|
|
98
|
+
* Missing/malformed names are unknown; non-catalog names/aliases are custom.
|
|
99
|
+
* No modelVersion is invented. This labels a caller-observed selection only;
|
|
100
|
+
* actual dispatch/response identity requires separate future host evidence.
|
|
101
|
+
* One cached snapshot retains <=4096 names of <=128 code units; no input is kept.
|
|
102
|
+
* Missing/incompatible dependencies and catalog overflow reject, never skip.
|
|
103
|
+
*/
|
|
104
|
+
export async function lookupPiCatalogName(input: unknown): Promise<PiCatalogName> {
|
|
105
|
+
return catalogLookup.lookup(input);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Pure lookup after async catalog loading; unknown until initialization succeeds.
|
|
109
|
+
* Always recheck membership, never trust a caller's classification/public label.
|
|
110
|
+
*/
|
|
111
|
+
export function classifyPiCatalogName(input: unknown): PiCatalogName {
|
|
112
|
+
return catalogLookup.classify(input);
|
|
113
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { gentleAiDevBinaryOverrideConfigured, resolveGentleAiBinary } from "./gentle-ai-binary.ts";
|
|
2
|
+
import { createNodeExecFileAdapter, type ExecFileAdapter } from "./native-review-cli.ts";
|
|
3
|
+
|
|
4
|
+
export interface RuntimeMetricsPolicyDeps { resolve?: () => string; exec?: ExecFileAdapter }
|
|
5
|
+
|
|
6
|
+
export function runtimeMetricsEnvAllows(env: NodeJS.ProcessEnv): boolean {
|
|
7
|
+
// Unknown nonempty spellings veto too: never weaken a native environment veto.
|
|
8
|
+
const truthy = (value: string | undefined) => !["", "0", "false", "no", "off"].includes(value?.trim().toLowerCase() ?? "");
|
|
9
|
+
return !truthy(env.DO_NOT_TRACK) && !truthy(env.CI) && !truthy(env.GITHUB_ACTIONS)
|
|
10
|
+
&& env.GENTLE_AI_TELEMETRY?.trim() !== "0";
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function publishedBinary(): string {
|
|
14
|
+
// This feature must not select an unpublished development binary or install one.
|
|
15
|
+
if (gentleAiDevBinaryOverrideConfigured()) throw new Error("Policy binary unavailable");
|
|
16
|
+
return resolveGentleAiBinary();
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Only a validated boolean crosses this boundary; never raw stdout or errors.
|
|
20
|
+
* The shared adapter and outer timer bound asynchronous process waiting, NOT
|
|
21
|
+
* end-to-end time: the supported resolver synchronously reads/hashes the binary
|
|
22
|
+
* on every check. It exposes no supported validation cache. Keep verification
|
|
23
|
+
* intact rather than cache an unchecked path or invent stat-based trust.
|
|
24
|
+
* No retries, status/preview fallback, enrollment, installation or polling.
|
|
25
|
+
*/
|
|
26
|
+
export async function readRuntimeMetricsPolicy(cwd: string,
|
|
27
|
+
{ resolve = publishedBinary, exec = createNodeExecFileAdapter() }: RuntimeMetricsPolicyDeps = {}): Promise<boolean> {
|
|
28
|
+
const controller = new AbortController();
|
|
29
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
30
|
+
try {
|
|
31
|
+
const deadline = new Promise<false>(done => {
|
|
32
|
+
timer = setTimeout(() => { controller.abort(); done(false); }, 1000);
|
|
33
|
+
});
|
|
34
|
+
const probe = async () => {
|
|
35
|
+
const result = await exec({ file: resolve(), arguments: ["telemetry", "policy", "--json"],
|
|
36
|
+
cwd, timeoutMs: 1000, maxBufferBytes: 2048, signal: controller.signal });
|
|
37
|
+
if (result.exitCode !== 0 || result.signal !== null || result.timedOut !== false
|
|
38
|
+
|| result.outputLimitExceeded !== false || result.stderr !== ""
|
|
39
|
+
|| typeof result.stdout !== "string" || Buffer.byteLength(result.stdout) > 2048) return false;
|
|
40
|
+
const value: unknown = JSON.parse(result.stdout);
|
|
41
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
|
42
|
+
const row = value as Record<string, unknown>;
|
|
43
|
+
return Object.keys(row).sort().join(",") === "enabled,operation,reason,schema,source"
|
|
44
|
+
&& row.schema === "gentle-ai.telemetry-policy/v1" && row.operation === "policy"
|
|
45
|
+
&& typeof row.enabled === "boolean" && row.enabled && row.reason === "enabled"
|
|
46
|
+
&& ["DO_NOT_TRACK", "GENTLE_AI_TELEMETRY", "CI", "state", "default"].includes(row.source as string);
|
|
47
|
+
};
|
|
48
|
+
return await Promise.race([probe(), deadline]);
|
|
49
|
+
} catch { return false; }
|
|
50
|
+
finally { clearTimeout(timer); }
|
|
51
|
+
}
|