@visual-z/xeno 0.7.2 → 0.8.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 +60 -0
- package/README.md +36 -9
- package/docs/foundations.md +1 -1
- package/package.json +3 -1
- package/src/apps/cli.ts +4 -3
- package/src/apps/commands.ts +8 -0
- package/src/apps/goal-flow.ts +70 -0
- package/src/apps/render.ts +0 -2
- package/src/apps/session-hub.ts +6 -5
- package/src/apps/session-lifecycle.ts +64 -0
- package/src/apps/shell.ts +89 -34
- package/src/apps/startup.ts +32 -31
- package/src/apps/ui/app.ts +105 -13
- package/src/apps/ui/control-model.ts +18 -1
- package/src/apps/ui/surface.ts +2 -0
- package/src/apps/update-check.ts +1 -1
- package/src/core/acceptance.ts +41 -9
- package/src/core/blind-checks.ts +72 -0
- package/src/core/branch-receipt.ts +3 -1
- package/src/core/checkpoints.ts +21 -0
- package/src/core/churn.ts +2 -2
- package/src/core/completion-review.ts +18 -11
- package/src/core/continuation.ts +11 -10
- package/src/core/control-view.ts +5 -8
- package/src/core/deadline.ts +2 -1
- package/src/core/derive.ts +0 -7
- package/src/core/environment.ts +2 -2
- package/src/core/event-validation.ts +19 -6
- package/src/core/events.ts +41 -32
- package/src/core/evidence-ladder.ts +62 -0
- package/src/core/evidence-links.ts +0 -10
- package/src/core/evidence.ts +1 -2
- package/src/core/export.ts +6 -11
- package/src/core/goal-view.ts +64 -0
- package/src/core/goal.ts +200 -0
- package/src/core/history.ts +1 -11
- package/src/core/json-event.ts +0 -6
- package/src/core/outcome.ts +0 -1
- package/src/core/program-status.ts +11 -2
- package/src/core/progress.ts +14 -8
- package/src/core/project.ts +6 -16
- package/src/core/read-bounds.ts +11 -2
- package/src/core/read-observation.ts +24 -0
- package/src/core/request-budget.ts +13 -3
- package/src/core/request-state.ts +178 -19
- package/src/core/response-recovery.ts +15 -2
- package/src/core/session-entry.ts +8 -3
- package/src/core/session-exit.ts +19 -3
- package/src/core/session-format.ts +169 -0
- package/src/core/settings.ts +7 -27
- package/src/core/stall.ts +30 -7
- package/src/core/stop-report.ts +4 -3
- package/src/core/transcript.ts +22 -2
- package/src/runtime/blind-check.ts +36 -0
- package/src/runtime/counter-check.ts +74 -0
- package/src/runtime/exec-sessions.ts +11 -0
- package/src/runtime/foundation.ts +46 -8
- package/src/runtime/loop.ts +239 -430
- package/src/runtime/oauth.ts +1 -3
- package/src/runtime/openai.ts +2 -2
- package/src/runtime/pi-provider.ts +3 -3
- package/src/runtime/provider-request.ts +2 -1
- package/src/runtime/provider.ts +33 -3
- package/src/runtime/retry.ts +9 -2
- package/src/runtime/runtime-target.ts +2 -2
- package/src/runtime/session-control.ts +4 -4
- package/src/runtime/tools.ts +44 -42
- package/src/runtime/usage.ts +28 -0
- package/src/runtime/version.ts +12 -0
- package/src/runtime/workspace-lifecycle.ts +31 -0
- package/src/store/blind-checks.ts +32 -0
- package/src/store/checkpoint-history.ts +1 -12
- package/src/store/file-index.ts +88 -7
- package/src/store/log.ts +25 -27
- package/src/store/recovery.ts +1 -3
- package/src/store/replay.ts +2 -2
- package/src/store/restore.ts +10 -202
- package/src/store/search.ts +7 -5
- package/src/store/session-archive.ts +0 -1
- package/src/store/sessions.ts +31 -33
- package/src/store/workspace-clone.ts +42 -0
- package/src/store/workspace.ts +10 -157
- package/src/tui/ui/duration.ts +16 -0
- package/src/tui/ui/layout.ts +4 -4
- package/src/tui/ui/screen.ts +13 -7
- package/src/tui/ui/status.ts +99 -17
- package/src/core/benchmark.ts +0 -195
- package/src/core/evaluation-coverage.ts +0 -74
- package/src/core/experiment.ts +0 -173
- package/src/core/harness-comparison.ts +0 -90
- package/src/core/harness-diagnostics.ts +0 -206
- package/src/core/native-eval.ts +0 -25
- package/src/core/policy-evaluation.ts +0 -55
- package/src/core/query-eval-contract.ts +0 -14
- package/src/core/recall-analysis.ts +0 -90
- package/src/core/recall-trial.ts +0 -153
- package/src/core/recovery-corpus.ts +0 -17
- package/src/core/recovery-evaluation.ts +0 -60
- package/src/core/tool-mode-protocol.ts +0 -25
- package/src/core/trial-lifecycle.ts +0 -14
- package/src/core/window-trial.ts +0 -65
- package/src/index.ts +0 -10
- package/src/runtime/benchmark.ts +0 -187
- package/src/runtime/harness-comparison.ts +0 -376
- package/src/runtime/harness-eval.ts +0 -462
- package/src/runtime/native-eval-fixtures.ts +0 -33
- package/src/runtime/native-eval.ts +0 -146
- package/src/runtime/query-eval-verifier.ts +0 -28
- package/src/runtime/recall-trial.ts +0 -226
- package/src/runtime/recovery-coverage.ts +0 -71
- package/src/runtime/recovery-evidence.ts +0 -76
- package/src/runtime/recovery-fixtures.ts +0 -116
- package/src/runtime/tool-mode-eval.ts +0 -146
- package/src/runtime/trial-control.ts +0 -45
- package/src/runtime/window-trial.ts +0 -141
- package/src/store/git-branch.ts +0 -35
- package/src/tui/ui/testing.ts +0 -5
package/src/runtime/oauth.ts
CHANGED
|
@@ -16,9 +16,7 @@
|
|
|
16
16
|
import { createServer, type Server } from "node:http";
|
|
17
17
|
import { pollDeviceCode } from "./oauth-device.ts";
|
|
18
18
|
import { parseDeviceAuthorization, parseOAuthTokens } from "../core/oauth-fields.ts";
|
|
19
|
-
|
|
20
|
-
/** Matches package.json. Sent as User-Agent so the server can see who we are (D18). */
|
|
21
|
-
export const XENO_VERSION = "0.7.2";
|
|
19
|
+
import { XENO_VERSION } from "./version.ts";
|
|
22
20
|
|
|
23
21
|
/**
|
|
24
22
|
* Minutes subtracted from server expiry so a request issued at the edge
|
package/src/runtime/openai.ts
CHANGED
|
@@ -23,7 +23,7 @@ import type { Message, ContentBlock, FrozenPrefix } from "../core/project.ts";
|
|
|
23
23
|
import { imageData } from "./images.ts";
|
|
24
24
|
import type { AssistantBlock } from "../core/events.ts";
|
|
25
25
|
import { assertNever } from "../core/exhaustive.ts";
|
|
26
|
-
import type
|
|
26
|
+
import { coherentTurn, type Provider, type ProviderConfig, type Turn, type ToolSchema, type OnDelta } from "./provider.ts";
|
|
27
27
|
import { DEFAULT_COMPAT, type Compat, type Effort, type ModelSpec } from "./model.ts";
|
|
28
28
|
import { withRetry, DEFAULT_RETRY, type RetryPolicy } from "./retry.ts";
|
|
29
29
|
import type { ProviderSendOptions } from "./provider-tool-policy.ts";
|
|
@@ -502,7 +502,7 @@ export class OpenAICompletionsProvider implements Provider {
|
|
|
502
502
|
}
|
|
503
503
|
|
|
504
504
|
const assembled = await readCompletionsSse(response.body, onDelta);
|
|
505
|
-
return parseResponse(assembled, node, Date.now() - started);
|
|
505
|
+
return coherentTurn(parseResponse(assembled, node, Date.now() - started), assembled.usage !== undefined);
|
|
506
506
|
}
|
|
507
507
|
}
|
|
508
508
|
|
|
@@ -20,7 +20,7 @@ import { providerRequest } from "./provider-request.ts";
|
|
|
20
20
|
import { grokVersionHint } from "./oauth.ts";
|
|
21
21
|
import type { ResolveEffects } from "./auth.ts";
|
|
22
22
|
import { imageData } from "./images.ts";
|
|
23
|
-
import { FailedResponseError, type OnDelta, type Provider, type ProviderConfig, type ToolSchema, type Turn } from "./provider.ts";
|
|
23
|
+
import { coherentTurn, FailedResponseError, type OnDelta, type Provider, type ProviderConfig, type ToolSchema, type Turn } from "./provider.ts";
|
|
24
24
|
import { DEFAULT_RETRY, withRetry, type RetryPolicy } from "./retry.ts";
|
|
25
25
|
import { HttpError, StreamError } from "./openai.ts";
|
|
26
26
|
import type { UsageSample } from "./usage.ts";
|
|
@@ -392,7 +392,7 @@ export class PiProvider implements Provider {
|
|
|
392
392
|
if (identityErrors.length) throw new ProviderIntegrityError(identityErrors, hasKnownUsage ? usage : undefined);
|
|
393
393
|
// The SDK has already classified native STOP/end_turn/toolUse/length values. The loop consumes
|
|
394
394
|
// xeno's closed completion vocabulary, never a raw native spelling that could skip tool execution.
|
|
395
|
-
return { blocks: piBlocks(completed, originalArguments.values), stopReason: completed.stopReason === "length" ? "max_tokens" : completed.stopReason === "toolUse" ? "tool_use" : "end_turn",
|
|
396
|
-
usage };
|
|
395
|
+
return coherentTurn({ blocks: piBlocks(completed, originalArguments.values), stopReason: completed.stopReason === "length" ? "max_tokens" : completed.stopReason === "toolUse" ? "tool_use" : "end_turn",
|
|
396
|
+
usage }, hasKnownUsage);
|
|
397
397
|
}
|
|
398
398
|
}
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { providerEnvironment, resolveKeyValue, type ResolveEffects } from "./auth.ts";
|
|
2
2
|
import { providerAccess } from "./provider-access.ts";
|
|
3
3
|
import type { ModelSpec } from "./model.ts";
|
|
4
|
-
import {
|
|
4
|
+
import { GROK_PROVIDER, GROK_BASE_URL, GROK_PROTOCOL_VERSION } from "./oauth.ts";
|
|
5
|
+
import { XENO_VERSION } from "./version.ts";
|
|
5
6
|
|
|
6
7
|
const SDK_IDENTITY_HEADERS = ["x-app", "editor-version", "editor-plugin-version", "copilot-integration-id", "http-referer", "x-title"];
|
|
7
8
|
|
package/src/runtime/provider.ts
CHANGED
|
@@ -35,10 +35,12 @@ import type { Message, ContentBlock, FrozenPrefix } from "../core/project.ts";
|
|
|
35
35
|
import { imageData } from "./images.ts";
|
|
36
36
|
import type { AssistantBlock } from "../core/events.ts";
|
|
37
37
|
import { assertNever } from "../core/exhaustive.ts";
|
|
38
|
+
import { isToolStopWithoutCall } from "../core/response-recovery.ts";
|
|
38
39
|
import type { UsageSample } from "./usage.ts";
|
|
39
40
|
import { type Effort, type ModelSpec, CLAUDE_OPUS_5 } from "./model.ts";
|
|
40
41
|
import { withRetry, DEFAULT_RETRY, type RetryPolicy } from "./retry.ts";
|
|
41
42
|
import { anthropicOAuthHeaders } from "./oauth.ts";
|
|
43
|
+
import { XENO_VERSION } from "./version.ts";
|
|
42
44
|
import type { ProviderSendOptions } from "./provider-tool-policy.ts";
|
|
43
45
|
|
|
44
46
|
export interface ToolSchema {
|
|
@@ -159,6 +161,27 @@ export class FailedResponseError extends Error {
|
|
|
159
161
|
}
|
|
160
162
|
}
|
|
161
163
|
|
|
164
|
+
/**
|
|
165
|
+
* A 200 response that contradicts itself, such as a tool stop with no tool
|
|
166
|
+
* call. retry.ts treats it like a transient stream failure: a fresh sample is
|
|
167
|
+
* the remedy, and exhausting retries must surface as a provider error rather
|
|
168
|
+
* than reach the loop as a silent final answer. The output token count tells
|
|
169
|
+
* "generated but lost in transit" apart from "nothing generated".
|
|
170
|
+
*/
|
|
171
|
+
export class MalformedResponseError extends FailedResponseError {
|
|
172
|
+
constructor(detail: string, usage?: UsageSample) {
|
|
173
|
+
super(`malformed provider response: ${detail}${usage ? ` (${usage.output_tokens} output tokens)` : ""}`, usage);
|
|
174
|
+
this.name = "MalformedResponseError";
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Every adapter returns through this, so no dialect can hand the loop a contradictory turn. */
|
|
179
|
+
export function coherentTurn(turn: Turn, usageKnown = true): Turn {
|
|
180
|
+
if (isToolStopWithoutCall(turn.stopReason, turn.blocks))
|
|
181
|
+
throw new MalformedResponseError(`stop_reason ${JSON.stringify(turn.stopReason)} without a tool call`, usageKnown ? turn.usage : undefined);
|
|
182
|
+
return turn;
|
|
183
|
+
}
|
|
184
|
+
|
|
162
185
|
/** Thrown for a refusal, which arrives as HTTP 200 rather than an error. */
|
|
163
186
|
export class RefusalError extends FailedResponseError {
|
|
164
187
|
readonly category: string | null;
|
|
@@ -176,6 +199,12 @@ function buildAnthropicClient(
|
|
|
176
199
|
apiKey: string | undefined,
|
|
177
200
|
options: { fetch?: typeof fetch; oauth?: boolean; baseUrl?: string },
|
|
178
201
|
): AnthropicTransport {
|
|
202
|
+
// D18: the User-Agent, not SDK headers, identifies xeno on the wire. Left alone
|
|
203
|
+
// the SDK sends `Anthropic/JS <version>`, which model gateways fold into "custom
|
|
204
|
+
// client" attribution; provider-request.ts already stamps xeno's UA on the Pi
|
|
205
|
+
// path. Constructor defaultHeaders merge after the SDK's built-in UA, so this
|
|
206
|
+
// entry wins without touching per-request headers.
|
|
207
|
+
const identity = { "User-Agent": `xeno/${XENO_VERSION}` };
|
|
179
208
|
if (options.oauth === true && apiKey) {
|
|
180
209
|
// authToken is Bearer. Passing apiKey here would send x-api-key, which
|
|
181
210
|
// an OAuth token is not accepted as.
|
|
@@ -183,7 +212,7 @@ function buildAnthropicClient(
|
|
|
183
212
|
maxRetries: 0,
|
|
184
213
|
...(options.baseUrl ? { baseURL: options.baseUrl } : {}),
|
|
185
214
|
authToken: apiKey,
|
|
186
|
-
defaultHeaders: anthropicOAuthHeaders(),
|
|
215
|
+
defaultHeaders: { ...anthropicOAuthHeaders(), ...identity },
|
|
187
216
|
...(options.fetch ? { fetch: options.fetch } : {}),
|
|
188
217
|
});
|
|
189
218
|
}
|
|
@@ -191,6 +220,7 @@ function buildAnthropicClient(
|
|
|
191
220
|
maxRetries: 0,
|
|
192
221
|
...(options.baseUrl ? { baseURL: options.baseUrl } : {}),
|
|
193
222
|
...(apiKey ? { apiKey } : {}),
|
|
223
|
+
defaultHeaders: identity,
|
|
194
224
|
...(options.fetch ? { fetch: options.fetch } : {}),
|
|
195
225
|
});
|
|
196
226
|
}
|
|
@@ -319,11 +349,11 @@ export class AnthropicProvider implements Provider {
|
|
|
319
349
|
throw new RefusalError(details?.category ?? null, details?.explanation ?? null, assembled.usageKnown ? usage : undefined);
|
|
320
350
|
}
|
|
321
351
|
|
|
322
|
-
return {
|
|
352
|
+
return coherentTurn({
|
|
323
353
|
blocks: assembled.blocks,
|
|
324
354
|
stopReason: assembled.stopReason,
|
|
325
355
|
usage,
|
|
326
|
-
};
|
|
356
|
+
}, assembled.usageKnown);
|
|
327
357
|
}
|
|
328
358
|
}
|
|
329
359
|
|
package/src/runtime/retry.ts
CHANGED
|
@@ -33,6 +33,11 @@ function isStreamFailure(error: unknown): error is Error & { code?: string } {
|
|
|
33
33
|
return error instanceof Error && error.name === "StreamError";
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
+
/** provider.ts MalformedResponseError: a self-contradictory 200, structural for the same reason. */
|
|
37
|
+
function isMalformedResponse(error: unknown): boolean {
|
|
38
|
+
return error instanceof Error && error.name === "MalformedResponseError";
|
|
39
|
+
}
|
|
40
|
+
|
|
36
41
|
export interface RetryPolicy {
|
|
37
42
|
/**
|
|
38
43
|
* Attempts after the first. Four means a request can take about 15s of
|
|
@@ -140,8 +145,10 @@ export function retryDecision(
|
|
|
140
145
|
// A failure reported inside a 200 stream carries no status. Relays and
|
|
141
146
|
// Anthropic's own `overloaded_error` use it for exactly the conditions a
|
|
142
147
|
// 5xx would signal; anything else in a stream is a real rejection.
|
|
143
|
-
if (status === undefined && isStreamFailure(error)) {
|
|
144
|
-
|
|
148
|
+
if (status === undefined && (isStreamFailure(error) || isMalformedResponse(error))) {
|
|
149
|
+
// A contradictory response is a bad sample, not a rejection of the request:
|
|
150
|
+
// resend it like a dropped stream, and let the bound turn it into an error.
|
|
151
|
+
if (isStreamFailure(error) && !TRANSIENT_STREAM.test(`${error.code ?? ""} ${error.message}`)) {
|
|
145
152
|
return { retry: false, reason: "the stream reported a non-transient error" };
|
|
146
153
|
}
|
|
147
154
|
const base = Math.min(0.5 * 2 ** attempt, 8) * 1000;
|
|
@@ -13,7 +13,7 @@ import { executionOutcome } from "../core/outcome.ts";
|
|
|
13
13
|
import { archiveCommandArtifacts, artifactExclusions, observeArtifactFiles, observeArtifactInputs } from "../store/command-artifacts.ts";
|
|
14
14
|
import { readObservation } from "../store/observations.ts";
|
|
15
15
|
import { targetDirectory, targetProgram, observeRuntimeIdentity } from "../store/runtime-target.ts";
|
|
16
|
-
import { findSession, startSession, pollSession } from "./exec-sessions.ts";
|
|
16
|
+
import { findSession, startSession, pollSession, yieldWait } from "./exec-sessions.ts";
|
|
17
17
|
import { sessionResult, type ToolContext, type ToolResult } from "./tools.ts";
|
|
18
18
|
|
|
19
19
|
type History = readonly { node: number; events: readonly LogEvent[] }[];
|
|
@@ -63,7 +63,7 @@ export class RuntimeTargets {
|
|
|
63
63
|
const live: LiveTarget = { request: state.request, contract: state.contract_event, build: receipt.build!.event,
|
|
64
64
|
instance: { id: randomUUID(), session: session.id, pid: session.pid, source, entry: { path: entryPath, sha256: entry.sha256 }, program } };
|
|
65
65
|
this.live.set(target.id, live);
|
|
66
|
-
const taken = await pollSession(session, archive, ctx.maxOutputBytes, input.yield_time_ms ?? 1000, "", ctx.signal);
|
|
66
|
+
const taken = await pollSession(session, archive, ctx.maxOutputBytes, yieldWait(input.yield_time_ms ?? 1000).ms, "", ctx.signal);
|
|
67
67
|
raw.push(taken.stored.ref);
|
|
68
68
|
processResult = await sessionResult(ctx, session.command, session.timeoutMs, taken);
|
|
69
69
|
}
|
|
@@ -9,7 +9,7 @@ import type { PermissionRequest } from "../core/permissions.ts";
|
|
|
9
9
|
import type { ExecutionOutcome } from "../core/outcome.ts";
|
|
10
10
|
import type { LogEvent } from "../core/events.ts";
|
|
11
11
|
import { readNodeHistory } from "../store/restore.ts";
|
|
12
|
-
import type { Session, LoopEvent } from "./loop.ts";
|
|
12
|
+
import type { Session, LoopEvent, RequestOptions } from "./loop.ts";
|
|
13
13
|
import type { ApprovalChoice } from "./tools.ts";
|
|
14
14
|
import { validateQuestionReply, type QuestionRequest, type QuestionReply } from "../core/question.ts";
|
|
15
15
|
import type { QuestionSlot } from "./question-tool.ts";
|
|
@@ -87,7 +87,7 @@ export class SessionControl {
|
|
|
87
87
|
// Ancestors are sealed. The live segment must share the cursor's publication
|
|
88
88
|
// boundary: a disk read can finish between append and in-memory publication.
|
|
89
89
|
const parts = history.nodes.map(part => part.node === node ? { node, events: structuredClone(s.state.events) } : part);
|
|
90
|
-
const projectedGoal = goalView(parts);
|
|
90
|
+
const projectedGoal = goalView(parts, { strict: s.config.strictVerification === true, blind: s.config.blindChecks !== undefined });
|
|
91
91
|
const goal = this.active && projectedGoal?.state.request.event !== this.active.id ? null : projectedGoal;
|
|
92
92
|
return { version: "xeno-control/0.1", threadId: this.threadId, streamId: this.streamId, sequence: this.sequence, node,
|
|
93
93
|
activeTurnId: this.activeTurnId, goal, waiting: this.waiting, workflowMode: s.config.tools.workflowMode ?? "build",
|
|
@@ -107,7 +107,7 @@ export class SessionControl {
|
|
|
107
107
|
}
|
|
108
108
|
|
|
109
109
|
/** Both local and remote turns pass through this iterator; Session remains the execution owner. */
|
|
110
|
-
async *send(input: UserInput, signal?: AbortSignal, pull?: (phase: Phase) => Promise<UserInput | null
|
|
110
|
+
async *send(input: UserInput, signal?: AbortSignal, pull?: (phase: Phase) => Promise<UserInput | null>, options?: RequestOptions): AsyncGenerator<LoopEvent> {
|
|
111
111
|
if (this.busy) throw Error("a turn is already running");
|
|
112
112
|
const active = { id: this.session.clock.nextId(), abort: new AbortController(), queue: [] as UserInput[] };
|
|
113
113
|
this.active = active;
|
|
@@ -123,7 +123,7 @@ export class SessionControl {
|
|
|
123
123
|
const text = data.map(value => value.text).join("\n\n"), images = data.flatMap(value => value.images ?? []);
|
|
124
124
|
return images.length ? { text, images } : text || null;
|
|
125
125
|
};
|
|
126
|
-
for await (const event of this.session.send(input, signals, steering, active.id)) {
|
|
126
|
+
for await (const event of this.session.send(input, signals, steering, active.id, options)) {
|
|
127
127
|
this.emit("turn/event", { event });
|
|
128
128
|
if (event.kind === "done") outcome = event.outcome;
|
|
129
129
|
yield event;
|
package/src/runtime/tools.ts
CHANGED
|
@@ -28,20 +28,20 @@ import { archiveObservation, readObservation, type ArchivedObservation } from ".
|
|
|
28
28
|
import { blobAddress } from "../store/blobs.ts";
|
|
29
29
|
import { commandHeader, headTailView } from "../core/observation-view.ts";
|
|
30
30
|
import {
|
|
31
|
-
|
|
31
|
+
yieldWait, findSession, closedSession, runningSessions, startSession, pollSession,
|
|
32
32
|
} from "./exec-sessions.ts";
|
|
33
33
|
import { recoverProcessReceipt, type ProcessReceipt, type latestProcessReceipt } from "../core/process-receipt.ts";
|
|
34
34
|
import { observeCommand, observeArgvCommand } from "../store/command.ts";
|
|
35
35
|
import { executionOutcome, type ExecutionOutcome } from "../core/outcome.ts";
|
|
36
36
|
import { commandCompletion, commandTimeout } from "../core/command-contract.ts";
|
|
37
|
-
import { previewReadObservation, readObservationText, type ObservationReceipt } from "../core/read-observation.ts";
|
|
37
|
+
import { linesWithin, previewReadObservation, readObservationText, type ObservationReceipt } from "../core/read-observation.ts";
|
|
38
38
|
import { checkFilePrecondition, isFileDigest, isFileReadDescriptor, type FileReadDescriptor, type FilePrecondition } from "../core/file-precondition.ts";
|
|
39
39
|
import { previewSearchObservation, searchObservationText } from "../core/search-observation.ts";
|
|
40
40
|
import { waitForAdmission } from "./execution-control.ts";
|
|
41
41
|
import { assertNever } from "../core/exhaustive.ts";
|
|
42
42
|
import { artifactRecovery, toolDescription, type ToolGuidance } from "../core/prompt-policy.ts";
|
|
43
43
|
import { classifyGate } from "../core/evidence.ts";
|
|
44
|
-
import { readBounds } from "../core/read-bounds.ts";
|
|
44
|
+
import { readBounds, readNotice } from "../core/read-bounds.ts";
|
|
45
45
|
import { closestName, inputError, isJsonValue, minimalInput, type JsonValue, type ToolDiagnostic } from "../core/tool-contract.ts";
|
|
46
46
|
import { failureDiagnostic, failureFromError, scopeFailureGuidance, NOT_STARTED, READ_ATTEMPTED, UNKNOWN_EFFECT, type FailureFacts } from "../core/tool-failure.ts";
|
|
47
47
|
import { planEdits, type EditSpec } from "../core/edit-plan.ts";
|
|
@@ -66,7 +66,7 @@ import { imageDescription } from "../core/images.ts";
|
|
|
66
66
|
import { attachImageBytes } from "./images.ts";
|
|
67
67
|
import { detectSupportedImageMimeType } from "../vendor/pi-rich/mime.ts";
|
|
68
68
|
import { find as findFiles, grep, ls, MAX_FILE_BYTES, MAX_SEARCH_CAPTURE_BYTES, type SearchResult } from "../store/search.ts";
|
|
69
|
-
import {
|
|
69
|
+
import { DEFAULT_IGNORE } from "../store/workspace.ts";
|
|
70
70
|
import { observeWorkspace } from "../store/workspace-observation.ts";
|
|
71
71
|
import { readEditableFile, replaceFile } from "../store/file-checkpoints.ts";
|
|
72
72
|
import type { WorkspaceLifecycle } from "./workspace-lifecycle.ts";
|
|
@@ -98,14 +98,26 @@ export interface ToolResult {
|
|
|
98
98
|
data?: JsonValue;
|
|
99
99
|
diagnostic?: ToolDiagnostic;
|
|
100
100
|
warnings?: readonly ToolDiagnostic[];
|
|
101
|
+
/** Scoped instructions attached to a read-only call that ran (S.6 item 3). */
|
|
102
|
+
instructions?: { keys: readonly string[]; text: string };
|
|
101
103
|
}
|
|
102
104
|
|
|
105
|
+
/**
|
|
106
|
+
* What target-scoped instruction discovery decided for one call: hold a change
|
|
107
|
+
* back until the rules are read, or let a read-only call run and carry them.
|
|
108
|
+
*/
|
|
109
|
+
export type InstructionCheck =
|
|
110
|
+
| { kind: "block"; result: ToolResult }
|
|
111
|
+
| { kind: "attach"; instructions: { keys: readonly string[]; text: string } };
|
|
112
|
+
|
|
103
113
|
export interface ToolContext {
|
|
104
114
|
historyEvents?: () => Promise<import("../core/events.ts").LogEvent[]>;
|
|
105
115
|
checkpointBlobs?: string;
|
|
106
116
|
frozenSystem?: () => string;
|
|
107
|
-
instructionCheck?: (ctx: ToolContext, name: string, input: unknown) => Promise<
|
|
117
|
+
instructionCheck?: (ctx: ToolContext, name: string, input: unknown) => Promise<InstructionCheck | null>;
|
|
108
118
|
workflowMode?: "build" | "plan";
|
|
119
|
+
/** What a Plan-mode refusal tells the model instead of the default "select Build". */
|
|
120
|
+
planHint?: string;
|
|
109
121
|
/** CLI owns its approval defaults; SDK callers retain their supplied host policy across reload. */
|
|
110
122
|
appOwnsPermissions?: true;
|
|
111
123
|
hostPermissionRules?: PermissionRule[];
|
|
@@ -121,8 +133,6 @@ export interface ToolContext {
|
|
|
121
133
|
/** P.3: admitted execution owns file recovery; extensions cannot supply model-authored effects. */
|
|
122
134
|
workspace?: WorkspaceLifecycle;
|
|
123
135
|
ownedStatePaths?: readonly string[];
|
|
124
|
-
/** Historical fingerprint compatibility, never enabled by the normal CLI. */
|
|
125
|
-
legacyWorkspace?: boolean;
|
|
126
136
|
/** O.15: optional disposable view; the reader still validates the original log. */
|
|
127
137
|
historyCache?: HistoryCache;
|
|
128
138
|
/** O.14: opaque process ownership is supplied by Session, never by model input. */
|
|
@@ -349,7 +359,7 @@ export const TOOL_DEFINITIONS: ToolDefinition[] = [
|
|
|
349
359
|
input_schema: { type: "object", required: ["action", "target"], properties: {
|
|
350
360
|
action: { type: "string", enum: ["start", "inspect"] }, target: { type: "string" },
|
|
351
361
|
session_id: { type: "integer", minimum: 1, description: "Inspect this exact previously returned handle; never adopts a process" },
|
|
352
|
-
yield_time_ms: { type: "integer",
|
|
362
|
+
yield_time_ms: { type: "integer", description: "Milliseconds to wait for launch; default 1000, distinct from the hard timeout; effective range 100-30000, values outside are clamped" },
|
|
353
363
|
timeout: { type: "integer", minimum: 1, maximum: 3600, description: "Hard process lifetime in seconds; defaults to the session command timeout" },
|
|
354
364
|
} },
|
|
355
365
|
},
|
|
@@ -620,7 +630,7 @@ export const TOOL_DEFINITIONS: ToolDefinition[] = [
|
|
|
620
630
|
artifacts: commandArtifactsSchema,
|
|
621
631
|
workdir: { type: "string", description: "Directory to run in; default the workspace root" },
|
|
622
632
|
tty: { type: "boolean", description: "Run in a pseudo-terminal; default false" },
|
|
623
|
-
yield_time_ms: { type: "integer",
|
|
633
|
+
yield_time_ms: { type: "integer", description: "Milliseconds to wait before returning; default 10000; effective range 100-30000, values outside are clamped" },
|
|
624
634
|
timeout: { type: "integer", minimum: 1, maximum: 3600, description: "Seconds before the session is killed; default 300" },
|
|
625
635
|
},
|
|
626
636
|
required: ["cmd"],
|
|
@@ -640,7 +650,7 @@ export const TOOL_DEFINITIONS: ToolDefinition[] = [
|
|
|
640
650
|
properties: {
|
|
641
651
|
session_id: { type: "integer", minimum: 1 },
|
|
642
652
|
chars: { type: "string", description: "Characters to write; empty to poll" },
|
|
643
|
-
yield_time_ms: { type: "integer",
|
|
653
|
+
yield_time_ms: { type: "integer", description: "Bounded wait when sending input or using low-level polling; effective range 100-30000, values outside are clamped; agent empty waits are owned until an observation changes" },
|
|
644
654
|
},
|
|
645
655
|
required: ["session_id"],
|
|
646
656
|
},
|
|
@@ -831,11 +841,16 @@ async function toolRead(ctx: ToolContext, input: Record<string, unknown>): Promi
|
|
|
831
841
|
// cannot take the whole observation budget; the notice names the next start.
|
|
832
842
|
const first = input["start"] ?? 1;
|
|
833
843
|
const end = input["end"] === undefined && Number.isSafeInteger(first) ? (first as number) + READ_DEFAULT_LINES - 1 : input["end"];
|
|
834
|
-
const
|
|
835
|
-
if (!
|
|
836
|
-
const { start } =
|
|
837
|
-
const slice = lines.slice(start - 1, selected.end);
|
|
844
|
+
const bounds = readBounds(lines.length, input["start"], end);
|
|
845
|
+
if (!bounds.ok) return failure("invalid_input", bounds.message, READ_ATTEMPTED);
|
|
846
|
+
const { start } = bounds;
|
|
838
847
|
const version = sha256(bytes);
|
|
848
|
+
// The selection is what fits whole, so the receipt's range is the range shown.
|
|
849
|
+
const sized = (count: number, handle: string) =>
|
|
850
|
+
`${readNotice(start, start + count - 1, lines.length, count < bounds.end - start + 1 ? ctx.maxOutputBytes : undefined)}\nsha256: ${version}\nread_handle: ${handle}\n`;
|
|
851
|
+
const shown = linesWithin(count => sized(count, "0".repeat(64)), lines.slice(start - 1, bounds.end), start, ctx.maxOutputBytes, undefined, adviceTools(ctx));
|
|
852
|
+
const selected = { end: start + shown - 1 };
|
|
853
|
+
const slice = lines.slice(start - 1, selected.end);
|
|
839
854
|
const store = ctx.outputStore ?? join(ctx.root, ".xeno", "observations");
|
|
840
855
|
async function* one(value: Uint8Array): AsyncGenerator<Uint8Array> { yield value; }
|
|
841
856
|
// Archive actual bytes before either presentation cut. Re-reading the current file is not recovery.
|
|
@@ -844,7 +859,7 @@ async function toolRead(ctx: ToolContext, input: Record<string, unknown>): Promi
|
|
|
844
859
|
const selection = { start, end: selected.end, total_lines: lines.length };
|
|
845
860
|
const descriptor: FileReadDescriptor = { kind: "file_read", version: 1, source, selection };
|
|
846
861
|
const handle = await archiveObservation(store, one(new TextEncoder().encode(JSON.stringify(descriptor))), 0);
|
|
847
|
-
const header =
|
|
862
|
+
const header = sized(shown, handle.ref.sha256);
|
|
848
863
|
const full = new TextEncoder().encode(readObservationText(header, slice, start));
|
|
849
864
|
const observation = await archiveObservation(store, one(full), 0);
|
|
850
865
|
const preview = previewReadObservation(header, slice, start, observation.ref.sha256, ctx.maxOutputBytes, undefined, adviceTools(ctx));
|
|
@@ -1048,19 +1063,6 @@ async function toolBash(ctx: ToolContext, input: Record<string, unknown>): Promi
|
|
|
1048
1063
|
};
|
|
1049
1064
|
}
|
|
1050
1065
|
|
|
1051
|
-
/**
|
|
1052
|
-
* Derived output a command's fingerprint leaves out.
|
|
1053
|
-
*
|
|
1054
|
-
* The fingerprint keys verification evidence to the source state it ran
|
|
1055
|
-
* against; build products and caches change on every build without the
|
|
1056
|
-
* sources changing, and walking them (a Rust `target/` or a virtualenv) costs
|
|
1057
|
-
* seconds after every command. Snapshots and rewind keep DEFAULT_IGNORE --
|
|
1058
|
-
* whether to restore derived files is their decision, not this one.
|
|
1059
|
-
*/
|
|
1060
|
-
const FINGERPRINT_IGNORE: readonly string[] = [...DEFAULT_IGNORE,
|
|
1061
|
-
"target", ".venv", "venv", "__pycache__", ".pytest_cache", ".mypy_cache", ".ruff_cache", ".tox",
|
|
1062
|
-
".gradle", ".next", ".turbo", ".cache"];
|
|
1063
|
-
|
|
1064
1066
|
async function commitEdit(ctx: ToolContext, absolute: string, before: Awaited<ReturnType<typeof readEditableFile>>, after: Uint8Array): Promise<void> {
|
|
1065
1067
|
const path = relative(ctx.root, absolute);
|
|
1066
1068
|
if (ctx.workspace && path !== ".." && !path.startsWith(`..${sep}`)) await ctx.workspace.edit(path, before, after, ctx.signal);
|
|
@@ -1068,16 +1070,10 @@ async function commitEdit(ctx: ToolContext, absolute: string, before: Awaited<Re
|
|
|
1068
1070
|
}
|
|
1069
1071
|
|
|
1070
1072
|
async function commandFingerprint(ctx: ToolContext): Promise<{ workspace_sha256?: string; workspace_observed?: boolean; workspace_scope?: "source-v1" }> {
|
|
1071
|
-
if (ctx.legacyWorkspace) {
|
|
1072
|
-
const observed = await scanWorkspace(ctx.root, FINGERPRINT_IGNORE);
|
|
1073
|
-
return observed.ok ? { workspace_sha256: workspaceVersion(observed.files) } : {};
|
|
1074
|
-
}
|
|
1075
1073
|
const observed = await observeWorkspace(ctx.root, ctx.signal, ctx.ownedStatePaths);
|
|
1076
1074
|
return observed.ok ? { workspace_sha256: observed.fingerprint, workspace_observed: true, workspace_scope: "source-v1" } : { workspace_observed: false, workspace_scope: "source-v1" };
|
|
1077
1075
|
}
|
|
1078
1076
|
|
|
1079
|
-
const clampYield = (value: unknown) =>
|
|
1080
|
-
Math.min(MAX_YIELD_MS, Math.max(MIN_YIELD_MS, typeof value === "number" && Number.isFinite(value) ? value : DEFAULT_YIELD_MS));
|
|
1081
1077
|
|
|
1082
1078
|
async function toolExec(ctx: ToolContext, input: Record<string, unknown>): Promise<ToolResult> {
|
|
1083
1079
|
const command = String(input["cmd"] ?? "");
|
|
@@ -1093,7 +1089,8 @@ async function toolExec(ctx: ToolContext, input: Record<string, unknown>): Promi
|
|
|
1093
1089
|
...(artifactObserver ? { artifactObserver } : {}),
|
|
1094
1090
|
...(ctx.execOwner === undefined ? {} : { owner: ctx.execOwner }) });
|
|
1095
1091
|
if ("error" in session) return failure("execution_failed", session.error);
|
|
1096
|
-
|
|
1092
|
+
const wait = yieldWait(input["yield_time_ms"]);
|
|
1093
|
+
return sessionResult(ctx, command, timeoutMs, await pollSession(session, outputStore, ctx.maxOutputBytes, wait.ms, "", ctx.signal), wait.note);
|
|
1097
1094
|
}
|
|
1098
1095
|
|
|
1099
1096
|
async function toolWriteStdin(ctx: ToolContext, input: Record<string, unknown>): Promise<ToolResult> {
|
|
@@ -1111,8 +1108,9 @@ async function toolWriteStdin(ctx: ToolContext, input: Record<string, unknown>):
|
|
|
1111
1108
|
data: { kind: "process", version: 1, session_id: id, state: "unknown", capability: "unknown", source: null, running: null, exit: null, replayed: true } };
|
|
1112
1109
|
}
|
|
1113
1110
|
const outputStore = ctx.outputStore ?? join(ctx.root, ".xeno", "observations");
|
|
1114
|
-
|
|
1115
|
-
|
|
1111
|
+
const wait = yieldWait(input["yield_time_ms"]);
|
|
1112
|
+
return sessionResult(ctx, session.command, session.timeoutMs, await pollSession(session, outputStore, ctx.maxOutputBytes, wait.ms, chars, ctx.signal,
|
|
1113
|
+
ctx.processWait && chars.length === 0 ? { wake: ctx.processWait.wake(), onWaiting: active => ctx.onProcessWait?.(id, active) } : undefined), wait.note);
|
|
1116
1114
|
}
|
|
1117
1115
|
|
|
1118
1116
|
/** A successful historical query proves only that the receipt was found, never a new command exit. */
|
|
@@ -1133,15 +1131,16 @@ function receiptResult(ctx: ToolContext, receipt: ProcessReceipt, chars: string)
|
|
|
1133
1131
|
* carries no effect; only an observed exit does, exactly as bash's would (D11).
|
|
1134
1132
|
*/
|
|
1135
1133
|
export async function sessionResult(ctx: ToolContext, command: string, timeoutMs: number,
|
|
1136
|
-
taken: Awaited<ReturnType<typeof pollSession
|
|
1134
|
+
taken: Awaited<ReturnType<typeof pollSession>>, waitNote = ""): Promise<ToolResult> {
|
|
1137
1135
|
const { stored, exit } = taken;
|
|
1138
1136
|
const id = taken.receipt.session_id;
|
|
1139
1137
|
if (taken.receipt.replayed) return receiptResult(ctx, taken.receipt, "");
|
|
1140
|
-
const
|
|
1138
|
+
const shown = exit === null
|
|
1141
1139
|
? `running · session ${id} · ${(taken.wallMs / 1000).toFixed(1)}s elapsed; ` + (adviceTools(ctx).includes("write_stdin")
|
|
1142
1140
|
? `poll or send input with write_stdin(session_id=${id})`
|
|
1143
1141
|
: "process-control capability unavailable; execution remains unfinished; do not replay")
|
|
1144
1142
|
: commandHeader({ exit, wallMs: taken.wallMs, timeoutMs, managed: true, ...(taken.stopped ? { stopped: taken.stopped } : {}) }) + ` · session ${id} closed`;
|
|
1143
|
+
const header = shown + waitNote;
|
|
1145
1144
|
const artifactSummary = commandArtifactSummary(taken.receipt.artifacts);
|
|
1146
1145
|
const content = header + artifactSummary + "\n" + observationView(stored, ctx.maxOutputBytes - utf8Length(header + artifactSummary) - 1, 0, adviceTools(ctx));
|
|
1147
1146
|
const warnings = taken.receipt.capture === "partial" ? [failureDiagnostic("unknown_effect", "output capture is partial; full_output contains only captured bytes", UNKNOWN_EFFECT)] : [];
|
|
@@ -1338,7 +1337,7 @@ export async function gateTool(
|
|
|
1338
1337
|
const definition = toolDefinition(name, ctx);
|
|
1339
1338
|
if (ctx.workflowMode === "plan" && definition && definition.access !== "read" &&
|
|
1340
1339
|
!(definition.access === "state" && (name === "note" || name === "question" && ctx.questionSlot)))
|
|
1341
|
-
return failure("permission_denied", `${name} is unavailable in Plan mode; select Build at an idle checkpoint
|
|
1340
|
+
return failure("permission_denied", `${name} is unavailable in Plan mode; ${ctx.planHint ?? "select Build at an idle checkpoint."}`);
|
|
1342
1341
|
input = repairToolInput(name, input);
|
|
1343
1342
|
let invalid: string | null = null;
|
|
1344
1343
|
if (definition?.validateInput) {
|
|
@@ -1434,11 +1433,13 @@ function observerValue<T>(value: T): T {
|
|
|
1434
1433
|
export async function prepareNormalizedTool(ctx: ToolContext, name: string, input: unknown,
|
|
1435
1434
|
prepareAction?: () => Promise<() => Promise<ToolResult>>): Promise<ToolResult | (() => Promise<ToolResult>)> {
|
|
1436
1435
|
const scoped = <T>(action: () => T): T => ctx.extensionHooks?.scope ? ctx.extensionHooks.scope(ctx, action) : action();
|
|
1436
|
+
let attached: ToolResult["instructions"];
|
|
1437
1437
|
try {
|
|
1438
1438
|
const gated = await gateTool(ctx, name, input);
|
|
1439
1439
|
if (gated) return scopeToolAdvice(ctx, finishToolResult(gated));
|
|
1440
1440
|
const instructions = await ctx.instructionCheck?.(ctx, name, input);
|
|
1441
|
-
if (instructions) return scopeToolAdvice(ctx, finishToolResult(instructions));
|
|
1441
|
+
if (instructions?.kind === "block") return scopeToolAdvice(ctx, finishToolResult(instructions.result));
|
|
1442
|
+
if (instructions?.kind === "attach") attached = instructions.instructions;
|
|
1442
1443
|
const veto = await scoped(() => ctx.extensionHooks?.before(name, observerValue(input), ctx.callId, ctx.parentCallId));
|
|
1443
1444
|
await ctx.drainNested?.();
|
|
1444
1445
|
if (veto) return scopeToolAdvice(ctx, finishToolResult(failure("permission_denied", veto)));
|
|
@@ -1488,7 +1489,8 @@ export async function prepareNormalizedTool(ctx: ToolContext, name: string, inpu
|
|
|
1488
1489
|
else observed = finishToolResult(caughtFailure(error, UNKNOWN_EFFECT));
|
|
1489
1490
|
}
|
|
1490
1491
|
await Promise.all(updates);
|
|
1491
|
-
|
|
1492
|
+
const result = warnings.length ? { ...observed, warnings: [...(observed.warnings ?? []), ...warnings] } : observed;
|
|
1493
|
+
return scopeToolAdvice(ctx, attached ? { ...result, instructions: attached } : result);
|
|
1492
1494
|
};
|
|
1493
1495
|
}
|
|
1494
1496
|
|
package/src/runtime/usage.ts
CHANGED
|
@@ -88,6 +88,34 @@ export function totalUsage(samples: readonly UsageSample[]): UsageTotals {
|
|
|
88
88
|
return t;
|
|
89
89
|
}
|
|
90
90
|
|
|
91
|
+
/**
|
|
92
|
+
* Below this a request's speed is not shown: a response that returns at once
|
|
93
|
+
* (a test double, a local cache) would divide into thousands of tokens per
|
|
94
|
+
* second. Assumed, not measured.
|
|
95
|
+
*/
|
|
96
|
+
export const SPEED_MIN_MS = 250;
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Output tokens per second over a set of model requests, or undefined when
|
|
100
|
+
* none can be measured (feat-sidebar-goal-and-speed). OpenCode's built-in
|
|
101
|
+
* turnTokensPerSecond uses the same definition: output over the time the
|
|
102
|
+
* requests took, each request counted once. Not output over the time since
|
|
103
|
+
* the first visible output -- output counts include reasoning that providers
|
|
104
|
+
* do not always stream (OpenAI summaries arrive late, Anthropic can omit
|
|
105
|
+
* them), so that would keep reasoning tokens and drop reasoning time, and
|
|
106
|
+
* overstate. Whole request time never overstates; an overloaded upstream
|
|
107
|
+
* reads slow, which is what the user is waiting on.
|
|
108
|
+
*/
|
|
109
|
+
export function generationSpeed(samples: readonly UsageSample[]): number | undefined {
|
|
110
|
+
let tokens = 0, ms = 0;
|
|
111
|
+
for (const sample of samples) {
|
|
112
|
+
if (sample.output_tokens <= 0 || !(sample.ms >= SPEED_MIN_MS)) continue;
|
|
113
|
+
tokens += sample.output_tokens;
|
|
114
|
+
ms += sample.ms;
|
|
115
|
+
}
|
|
116
|
+
return ms > 0 ? tokens / (ms / 1000) : undefined;
|
|
117
|
+
}
|
|
118
|
+
|
|
91
119
|
/**
|
|
92
120
|
* Cost in equivalent base-input tokens, the unit appendix A's model uses.
|
|
93
121
|
*
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one place xeno's version is known (S.4: a release edits package.json and
|
|
3
|
+
* nothing else). It is sent as the User-Agent (D18: xeno names itself
|
|
4
|
+
* honestly) and compared against npm for update notices.
|
|
5
|
+
*
|
|
6
|
+
* Read at load rather than imported: `bun run check` requires every import to
|
|
7
|
+
* be a literal relative `.ts` path. package.json ships in every install, so
|
|
8
|
+
* this resolves the same way from a checkout and from a global install.
|
|
9
|
+
*/
|
|
10
|
+
import { readFileSync } from "node:fs";
|
|
11
|
+
|
|
12
|
+
export const XENO_VERSION: string = (JSON.parse(readFileSync(new URL("../../package.json", import.meta.url), "utf8")) as { version: string }).version;
|
|
@@ -100,9 +100,40 @@ export class WorkspaceLifecycle {
|
|
|
100
100
|
source: "git", phase: "unavailable", changes: {}, reason: "process ended without an observed checkpoint boundary" });
|
|
101
101
|
this.pending.clear();
|
|
102
102
|
}
|
|
103
|
+
/**
|
|
104
|
+
* A full source snapshot through the private Git checkpoint store, or null
|
|
105
|
+
* where Git checkpoints cannot run (not a Git working directory, over
|
|
106
|
+
* budget). Never touches the user's HEAD, index or stash (P.3).
|
|
107
|
+
*/
|
|
108
|
+
async snapshot(signal?: AbortSignal): Promise<GitCheckpoint | null> {
|
|
109
|
+
try { return await this.git.capture(signal); } catch { return null; }
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** What changed between two snapshots, with both versions' bytes in the blob store. */
|
|
113
|
+
async snapshotChanges(before: GitCheckpoint, after: GitCheckpoint, signal?: AbortSignal): Promise<FileChanges> {
|
|
114
|
+
return this.git.changes(before, after, this.blobs, signal);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Records the workspace as a goal request begins, always: the state a
|
|
119
|
+
* counter-check must rebuild exactly before it may run (feat-counter-check).
|
|
120
|
+
*/
|
|
121
|
+
async baseline(signal?: AbortSignal): Promise<void> {
|
|
122
|
+
const observation = await observeWorkspace(this.root, signal, this.ownedPaths);
|
|
123
|
+
const fingerprint = observation.ok ? observation.fingerprint : undefined;
|
|
124
|
+
await this.record({ t: "workspace_observation", scope: "source-v1", baseline: true,
|
|
125
|
+
changed: !observation.ok || this.fingerprint !== undefined && fingerprint !== this.fingerprint,
|
|
126
|
+
...(observation.ok ? { fingerprint: observation.fingerprint } : { reason: observation.reason }) });
|
|
127
|
+
this.fingerprint = fingerprint;
|
|
128
|
+
}
|
|
129
|
+
|
|
103
130
|
async observe(previous?: string, signal?: AbortSignal): Promise<void> {
|
|
104
131
|
const observation = await observeWorkspace(this.root, signal, this.ownedPaths);
|
|
105
132
|
const fingerprint = observation.ok ? observation.fingerprint : undefined;
|
|
133
|
+
// S.1: an observation identical to the last known fingerprint adds no fact.
|
|
134
|
+
// Failures, a first look without a baseline, and changes are still recorded.
|
|
135
|
+
const known = previous ?? this.fingerprint;
|
|
136
|
+
if (fingerprint !== undefined && known !== undefined && fingerprint === known) { this.fingerprint = fingerprint; return; }
|
|
106
137
|
await this.record({ t: "workspace_observation", scope: "source-v1", changed: !observation.ok || (previous ?? this.fingerprint) !== undefined && fingerprint !== (previous ?? this.fingerprint),
|
|
107
138
|
...(observation.ok ? { fingerprint: observation.fingerprint } : { reason: observation.reason }) });
|
|
108
139
|
this.fingerprint = fingerprint;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reading a blind check directory (feat-blind-checks). It must sit outside
|
|
3
|
+
* the workspace -- a check inside it is a file the model can open -- and is
|
|
4
|
+
* frozen into the session with every file's digest, so a resumed session
|
|
5
|
+
* runs exactly the checks it started with.
|
|
6
|
+
*/
|
|
7
|
+
import { readdir, readFile, realpath, stat } from "node:fs/promises";
|
|
8
|
+
import { isAbsolute, join, relative, resolve } from "node:path";
|
|
9
|
+
import { BLIND_BYTES_LIMIT, BLIND_FILE_LIMIT, parseBlindManifest, type BlindChecks } from "../core/blind-checks.ts";
|
|
10
|
+
import { blobAddress } from "./blobs.ts";
|
|
11
|
+
|
|
12
|
+
export async function loadBlindChecks(dir: string, workspace: string): Promise<BlindChecks> {
|
|
13
|
+
const given = resolve(workspace, dir), root = await realpath(given), work = await realpath(workspace);
|
|
14
|
+
const inside = relative(work, root);
|
|
15
|
+
if (inside === "" || !inside.startsWith("..") && !isAbsolute(inside)) throw Error("--blind-checks must be outside the workspace, where the model cannot read it");
|
|
16
|
+
const files: Record<string, string> = {};
|
|
17
|
+
let bytes = 0;
|
|
18
|
+
const walk = async (path: string): Promise<void> => {
|
|
19
|
+
for (const entry of await readdir(path, { withFileTypes: true })) {
|
|
20
|
+
const full = join(path, entry.name), rel = relative(root, full);
|
|
21
|
+
if (entry.isDirectory()) { await walk(full); continue; }
|
|
22
|
+
if (!entry.isFile()) throw Error(`blind check ${rel} is not a regular file`);
|
|
23
|
+
if (Object.keys(files).length >= BLIND_FILE_LIMIT) throw Error(`--blind-checks holds more than ${BLIND_FILE_LIMIT} files`);
|
|
24
|
+
bytes += (await stat(full)).size;
|
|
25
|
+
if (bytes > BLIND_BYTES_LIMIT) throw Error(`--blind-checks holds more than ${BLIND_BYTES_LIMIT} bytes`);
|
|
26
|
+
files[rel] = blobAddress(await readFile(full));
|
|
27
|
+
}
|
|
28
|
+
};
|
|
29
|
+
await walk(root);
|
|
30
|
+
const manifest = parseBlindManifest(JSON.parse(await readFile(join(root, "blind.json"), "utf8")));
|
|
31
|
+
return { dir: root, aliases: given === root ? [] : [given], manifest, files };
|
|
32
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/** File ancestry is independent of model-context ancestry (P.3). */
|
|
2
2
|
import { readNodeHistory, type RestoreTarget } from "./restore.ts";
|
|
3
|
-
import { planFileTransition, type CheckpointEvent, type
|
|
3
|
+
import { planFileTransition, type CheckpointEvent, type FileChanges } from "../core/checkpoints.ts";
|
|
4
4
|
|
|
5
5
|
export async function checkpointHistory(dir: string, target: RestoreTarget, visited = new Set<string>()): Promise<CheckpointEvent[]> {
|
|
6
6
|
const position = typeof target === "number" ? { node: target } : target;
|
|
@@ -10,7 +10,6 @@ export async function checkpointHistory(dir: string, target: RestoreTarget, visi
|
|
|
10
10
|
const history = await readNodeHistory(dir, position.node);
|
|
11
11
|
if (!history.ok) throw new Error(history.error);
|
|
12
12
|
let checkpoints: CheckpointEvent[] = [];
|
|
13
|
-
const legacy = new Map<string, FileVersion>();
|
|
14
13
|
for (const node of history.nodes) {
|
|
15
14
|
let events = node.events;
|
|
16
15
|
if (node.node === position.node && position.atEvent !== undefined) {
|
|
@@ -23,16 +22,6 @@ export async function checkpointHistory(dir: string, target: RestoreTarget, visi
|
|
|
23
22
|
checkpoints = await checkpointHistory(dir, { node: event.files_from.node,
|
|
24
23
|
...(event.files_from.at_event ? { atEvent: event.files_from.at_event } : {}) }, next);
|
|
25
24
|
} else if (event.t === "checkpoint") checkpoints.push(event);
|
|
26
|
-
else if (event.t === "snapshot") {
|
|
27
|
-
// Interpret historical full snapshots without rewriting them or scanning today's disk.
|
|
28
|
-
const changes: FileChanges = Object.create(null) as FileChanges;
|
|
29
|
-
for (const [path, sha256] of Object.entries({ ...event.added, ...event.changed })) {
|
|
30
|
-
changes[path] = { before: legacy.get(path) ?? null, after: { sha256, mode: 0o644 } };
|
|
31
|
-
legacy.set(path, changes[path]!.after);
|
|
32
|
-
}
|
|
33
|
-
for (const path of event.removed) { changes[path] = { before: legacy.get(path) ?? null, after: null }; legacy.delete(path); }
|
|
34
|
-
checkpoints.push({ t: "checkpoint", id: event.id, at: event.at, operation: event.id, source: "git", phase: "applied", changes });
|
|
35
|
-
}
|
|
36
25
|
}
|
|
37
26
|
}
|
|
38
27
|
return checkpoints;
|