@visual-z/xeno 0.4.0 → 0.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.
Files changed (90) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/README.md +41 -10
  3. package/docs/app-server.md +35 -4
  4. package/docs/foundations.md +88 -0
  5. package/docs/install.md +23 -2
  6. package/docs/providers.md +4 -0
  7. package/docs/research/foundation-clients-2026-10-09.md +27 -0
  8. package/docs/research/foundation-editing-2026-10-09.md +56 -0
  9. package/docs/research/foundation-network-2026-10-09.md +37 -0
  10. package/docs/research/foundation-workflow-2026-10-09.md +37 -0
  11. package/package.json +10 -2
  12. package/src/apps/acp-server.ts +41 -0
  13. package/src/apps/agent-profiles.ts +19 -0
  14. package/src/apps/app-server.ts +4 -2
  15. package/src/apps/cli.ts +18 -2
  16. package/src/apps/commands.ts +20 -0
  17. package/src/apps/control-transport.ts +3 -2
  18. package/src/apps/model-catalog.ts +57 -31
  19. package/src/apps/preferences.ts +20 -0
  20. package/src/apps/question-ui.ts +27 -0
  21. package/src/apps/resources.ts +63 -25
  22. package/src/apps/review.ts +109 -0
  23. package/src/apps/session-hub.ts +84 -0
  24. package/src/apps/shell.ts +88 -30
  25. package/src/apps/startup-input.ts +7 -2
  26. package/src/apps/startup.ts +180 -25
  27. package/src/apps/ui/app.ts +74 -91
  28. package/src/apps/ui/surface.ts +1 -0
  29. package/src/apps/update-check.ts +71 -0
  30. package/src/apps/workflow.ts +58 -0
  31. package/src/core/agent-profile.ts +130 -0
  32. package/src/core/completion-review.ts +13 -4
  33. package/src/core/control-protocol.ts +8 -1
  34. package/src/core/file-precondition.ts +6 -0
  35. package/src/core/lsp-framing.ts +34 -0
  36. package/src/core/mcp-config.ts +68 -0
  37. package/src/core/model-preferences.ts +28 -0
  38. package/src/core/patch.ts +130 -0
  39. package/src/core/permissions.ts +156 -11
  40. package/src/core/question.ts +94 -0
  41. package/src/core/review.ts +68 -0
  42. package/src/core/settings.ts +1 -1
  43. package/src/core/update-check.ts +24 -5
  44. package/src/core/web-content.ts +80 -0
  45. package/src/core/workflow-mode.ts +34 -0
  46. package/src/runtime/acp-agent.ts +289 -0
  47. package/src/runtime/agent-profiles.ts +21 -0
  48. package/src/runtime/catalog.ts +10 -3
  49. package/src/runtime/control-client.ts +149 -0
  50. package/src/runtime/control-connection.ts +39 -4
  51. package/src/runtime/extensions.ts +20 -2
  52. package/src/runtime/formatter-tool.ts +38 -0
  53. package/src/runtime/formatter.ts +50 -0
  54. package/src/runtime/foundation.ts +78 -0
  55. package/src/runtime/integrations.ts +8 -2
  56. package/src/runtime/loop.ts +68 -10
  57. package/src/runtime/lsp-client.ts +201 -0
  58. package/src/runtime/mcp-client.ts +264 -0
  59. package/src/runtime/mcp-oauth.ts +168 -0
  60. package/src/runtime/mcp-schema.ts +51 -0
  61. package/src/runtime/oauth.ts +1 -1
  62. package/src/runtime/output-schema.ts +26 -0
  63. package/src/runtime/patch-tool.ts +38 -0
  64. package/src/runtime/permission-resources.ts +11 -0
  65. package/src/runtime/provider.ts +2 -0
  66. package/src/runtime/question-tool.ts +62 -0
  67. package/src/runtime/review-tool.ts +48 -0
  68. package/src/runtime/session-control.ts +60 -2
  69. package/src/runtime/session-hub.ts +85 -0
  70. package/src/runtime/tool-discovery.ts +4 -3
  71. package/src/runtime/tools.ts +48 -16
  72. package/src/runtime/update-check.ts +3 -2
  73. package/src/runtime/web-http.ts +56 -0
  74. package/src/runtime/web-tools.ts +88 -0
  75. package/src/runtime/workspace-lifecycle.ts +14 -1
  76. package/src/store/agent-profile-files.ts +13 -0
  77. package/src/store/context-files.ts +77 -1
  78. package/src/store/file-checkpoints.ts +5 -4
  79. package/src/store/foundation-config.ts +24 -0
  80. package/src/store/lsp-document.ts +30 -0
  81. package/src/store/mcp-credentials.ts +85 -0
  82. package/src/store/patch.ts +92 -0
  83. package/src/store/permission-paths.ts +34 -0
  84. package/src/store/resources.ts +13 -22
  85. package/src/store/review.ts +109 -0
  86. package/src/store/search.ts +48 -9
  87. package/src/store/user-settings.ts +30 -0
  88. package/src/tui/ui/control-panel.ts +7 -5
  89. package/src/tui/ui/control-sidebar.ts +49 -0
  90. package/src/tui/ui/screen.ts +107 -11
@@ -0,0 +1,94 @@
1
+ /** R13 / D11: an answer is bound to its original active invocation, never guessed. */
2
+
3
+ // Product limits keep one interaction reviewable, not measured model limits.
4
+ export const MAX_QUESTIONS = 3;
5
+ export const MAX_QUESTION_CHOICES = 8;
6
+ export interface QuestionChoice { readonly id: string; readonly label: string; readonly description?: string }
7
+ export interface QuestionSpec {
8
+ readonly id: string;
9
+ readonly header: string;
10
+ readonly question: string;
11
+ readonly options: readonly QuestionChoice[];
12
+ readonly multiple: boolean;
13
+ readonly custom: boolean;
14
+ }
15
+ export interface QuestionRequest {
16
+ readonly request_id: string;
17
+ readonly turn_id: string;
18
+ readonly call_id: string;
19
+ readonly questions: readonly QuestionSpec[];
20
+ }
21
+ export interface QuestionAnswer { readonly id: string; readonly choices: readonly string[]; readonly text?: string }
22
+ export interface QuestionReply {
23
+ readonly request_id: string;
24
+ readonly turn_id: string;
25
+ readonly answers: readonly QuestionAnswer[];
26
+ }
27
+ function object(value: unknown, label: string, fields: readonly string[]): Record<string, unknown> {
28
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw Error(`${label} must be an object`);
29
+ const result = value as Record<string, unknown>;
30
+ if (Object.keys(result).some(key => !fields.includes(key))) throw Error(`${label} contains an unknown field`);
31
+ return result;
32
+ }
33
+ function string(value: unknown, label: string, max = 2000): string {
34
+ if (typeof value !== "string" || !value.trim() || value.length > max || /[\0\x1b]/.test(value)) throw Error(`${label} must be nonempty plain text (max ${max} characters)`);
35
+ return value;
36
+ }
37
+ function identifier(value: unknown, label: string): string {
38
+ const id = string(value, label, 64);
39
+ if (!/^[a-z][a-z0-9_-]*$/.test(id)) throw Error(`${label} must be a stable lowercase identifier`);
40
+ return id;
41
+ }
42
+
43
+ export function parseQuestions(value: unknown): QuestionSpec[] {
44
+ const input = object(value, "question input", ["questions"]);
45
+ const questions = input["questions"];
46
+ if (!Array.isArray(questions) || questions.length < 1 || questions.length > MAX_QUESTIONS) throw Error(`question requires 1..${MAX_QUESTIONS} questions`);
47
+ const parsed = questions.map(value => {
48
+ const item = object(value, "question", ["id", "header", "question", "options", "multiple", "custom"]);
49
+ const options = item["options"] ?? [];
50
+ if (!Array.isArray(options) || options.length > MAX_QUESTION_CHOICES) throw Error(`question options must contain at most ${MAX_QUESTION_CHOICES} choices`);
51
+ const choices = options.map(value => {
52
+ const choice = object(value, "question option", ["id", "label", "description"]);
53
+ return { id: identifier(choice["id"], "option id"), label: string(choice["label"], "option label", 120),
54
+ ...(choice["description"] !== undefined ? { description: string(choice["description"], "option description", 600) } : {}) };
55
+ });
56
+ if (new Set(choices.map(choice => choice.id)).size !== choices.length) throw Error("question option ids must be unique");
57
+ if (new Set(choices.map(choice => choice.label)).size !== choices.length) throw Error("question option labels must be unique");
58
+ for (const key of ["multiple", "custom"]) if (item[key] !== undefined && typeof item[key] !== "boolean") throw Error(`question ${key} must be boolean`);
59
+ const multiple = item["multiple"] === true, custom = item["custom"] !== false;
60
+ if (!choices.length && !custom) throw Error("question must offer choices or custom text");
61
+ return { id: identifier(item["id"], "question id"), header: string(item["header"], "question header", 40),
62
+ question: string(item["question"], "question text"), options: choices, multiple, custom };
63
+ });
64
+ if (new Set(parsed.map(question => question.id)).size !== parsed.length) throw Error("question ids must be unique");
65
+ return parsed;
66
+ }
67
+
68
+ export function questionRequest(turnId: string, callId: string, questions: readonly QuestionSpec[]): QuestionRequest {
69
+ if (!turnId || !callId || /[\r\n\0]/.test(turnId + callId)) throw Error("question requires a trusted active turn and call identity");
70
+ // Tuple encoding avoids slash/delimiter collisions in opaque runtime IDs.
71
+ return { request_id: JSON.stringify([turnId, callId]), turn_id: turnId, call_id: callId, questions: structuredClone(questions) };
72
+ }
73
+
74
+ export function validateQuestionReply(request: QuestionRequest, value: unknown): QuestionReply {
75
+ const reply = object(value, "question reply", ["request_id", "turn_id", "answers"]);
76
+ if (reply["request_id"] !== request.request_id || reply["turn_id"] !== request.turn_id) throw Error("question reply belongs to a different invocation or turn");
77
+ const answers = reply["answers"];
78
+ if (!Array.isArray(answers) || answers.length !== request.questions.length) throw Error("question reply must answer every question exactly once");
79
+ const parsed = answers.map(value => {
80
+ const answer = object(value, "question answer", ["id", "choices", "text"]);
81
+ const id = identifier(answer["id"], "answer id"), question = request.questions.find(question => question.id === id);
82
+ if (!question) throw Error("question reply contains an unknown question id");
83
+ const choices = answer["choices"];
84
+ if (!Array.isArray(choices) || choices.some(choice => typeof choice !== "string" || !question.options.some(option => option.id === choice)) || new Set(choices).size !== choices.length) throw Error("question reply contains unknown or duplicate choices");
85
+ const text = answer["text"] === undefined ? undefined : string(answer["text"], "answer text", 10000);
86
+ if (text !== undefined && !question.custom) throw Error("custom text is not permitted for this question");
87
+ const count = choices.length + (text === undefined ? 0 : 1);
88
+ if (count === 0 || (!question.multiple && count !== 1)) throw Error("question reply requires one answer, or multiple only when enabled");
89
+ return { id, choices: [...choices] as string[], ...(text !== undefined ? { text } : {}) };
90
+ });
91
+ if (new Set(parsed.map(answer => answer.id)).size !== parsed.length) throw Error("question reply contains duplicate question ids");
92
+ return { request_id: request.request_id, turn_id: request.turn_id,
93
+ answers: request.questions.map(question => parsed.find(answer => answer.id === question.id)!) };
94
+ }
@@ -0,0 +1,68 @@
1
+ /** Review scopes and checkpoint projection are data, never authorization (D8 / D11). */
2
+ import type { LogEvent } from "./events.ts";
3
+ import { sameVersion, changedPaths, type FileChanges } from "./checkpoints.ts";
4
+ export type ReviewScope = { kind: "working" } | { kind: "branch"; base: string } |
5
+ { kind: "commit"; commit: string } | { kind: "last-turn" };
6
+ export interface ReviewSnapshot {
7
+ scope: ReviewScope;
8
+ label: string;
9
+ diff: string;
10
+ paths: string[];
11
+ limitations: string[];
12
+ /** Resolved commits are immutable review premises, never raw shell expressions. */
13
+ base?: string;
14
+ }
15
+
16
+ /** Fold observed operations after the latest request; uncaptured effects remain unknown. */
17
+ export function lastTurnChanges(events: readonly LogEvent[]): { changes: FileChanges; limitations: string[] } {
18
+ let start = 0;
19
+ for (let i = events.length - 1; i >= 0; i--) if (events[i]!.t === "user" || events[i]!.t === "user_bash") { start = i + 1; break; }
20
+ const changes: FileChanges = Object.create(null) as FileChanges;
21
+ const limitations = new Set<string>(["Last-turn diff covers durable file checkpoints; uncaptured command or external changes are unknown."]);
22
+ const suffix = events.slice(start);
23
+ const finalPhases = new Map(suffix.filter(event => event.t === "checkpoint").map(event => [event.operation, event.phase]));
24
+ for (const event of suffix) {
25
+ if (event.t !== "checkpoint") continue;
26
+ if (event.phase === "unavailable" && finalPhases.get(event.operation) !== "applied") limitations.add(event.reason ?? "A file checkpoint is unavailable.");
27
+ if (event.phase !== "applied" || finalPhases.get(event.operation) !== "applied") continue;
28
+ for (const path of changedPaths(event.changes)) {
29
+ const next = event.changes[path]!, previous = changes[path];
30
+ if (previous && !sameVersion(previous.after, next.before)) throw new Error(`discontinuous last-turn checkpoint: ${path}; external changes cannot be attributed`);
31
+ changes[path] = { before: previous ? previous.before : next.before, after: next.after };
32
+ }
33
+ }
34
+ return { changes: Object.fromEntries(changedPaths(changes).map(path => [path, changes[path]!])), limitations: [...limitations] };
35
+ }
36
+
37
+ /** A linear contiguous hunk avoids quadratic whole-file diff memory. No bytes are omitted. */
38
+ export function unifiedFileDiff(path: string, before: string | null, after: string | null, destination = path): string {
39
+ if (before === after && path === destination) return "";
40
+ const lines = (text: string | null): { text: string; newline: boolean }[] => {
41
+ if (!text) return [];
42
+ const result = text.split("\n");
43
+ if (text.endsWith("\n")) result.pop();
44
+ return result.map((line, index) => ({ text: line, newline: index < result.length - 1 || text.endsWith("\n") }));
45
+ };
46
+ const oldLines = lines(before), newLines = lines(after);
47
+ const equal = (left: typeof oldLines[number] | undefined, right: typeof oldLines[number] | undefined) =>
48
+ left !== undefined && right !== undefined && left.text === right.text && left.newline === right.newline;
49
+ let prefix = 0, suffix = 0;
50
+ while (prefix < oldLines.length && prefix < newLines.length && equal(oldLines[prefix], newLines[prefix])) prefix++;
51
+ while (suffix < oldLines.length - prefix && suffix < newLines.length - prefix && equal(oldLines.at(-suffix - 1), newLines.at(-suffix - 1))) suffix++;
52
+ // Assumed familiar Git default: retain three lines of unchanged context per side.
53
+ const start = Math.max(0, prefix - 3), oldEnd = Math.min(oldLines.length, oldLines.length - suffix + 3), newEnd = Math.min(newLines.length, newLines.length - suffix + 3);
54
+ const quoted = (value: string) => /[\s"\\]/.test(value) ? JSON.stringify(value) : value;
55
+ const emit = (entries: typeof oldLines, marker: string) => entries.flatMap(line =>
56
+ [marker + line.text, ...(line.newline ? [] : ["\"])]);
57
+ const body = [...emit(oldLines.slice(start, prefix), " "),
58
+ ...emit(oldLines.slice(prefix, oldLines.length - suffix), "-"),
59
+ ...emit(newLines.slice(prefix, newLines.length - suffix), "+"),
60
+ ...emit(oldLines.slice(oldLines.length - suffix, oldEnd), " ")];
61
+ const range = (count: number) => count ? start + 1 : 0;
62
+ const oldCount = oldEnd - start, newCount = newEnd - start;
63
+ const headers = [`diff --git ${quoted("a/" + path)} ${quoted("b/" + destination)}`,
64
+ ...(path === destination ? [] : [`rename from ${quoted(path)}`, `rename to ${quoted(destination)}`]),
65
+ `--- ${before === null ? "/dev/null" : quoted("a/" + path)}`, `+++ ${after === null ? "/dev/null" : quoted("b/" + destination)}`];
66
+ if (body.length) headers.push(`@@ -${range(oldCount)},${oldCount} +${range(newCount)},${newCount} @@`, ...body);
67
+ return headers.join("\n") + "\n";
68
+ }
@@ -17,7 +17,7 @@ export interface Settings {
17
17
  readonly defaultThinking?: string;
18
18
  readonly modelThinkingLevels?: Readonly<Record<string, ThinkingLevel>>;
19
19
  readonly scopedModels?: readonly string[];
20
- /** provider/model keys, most recent first; the /model picker's first section. */
20
+ /** provider/model keys, most recent first; picker ordering and startup fallback for older settings. */
21
21
  readonly recentModels?: readonly string[];
22
22
  readonly steeringMode?: "all" | "one-at-a-time";
23
23
  readonly followUpMode?: "all" | "one-at-a-time";
@@ -2,13 +2,14 @@
2
2
  * Whether to look for a newer xeno and what to say about it (docs/tasks/feat-update-check.md). Pure.
3
3
  *
4
4
  * Notify only: xeno names the command for the way it was installed and never
5
- * runs it. At most one registry request a day, recorded even when it fails,
6
- * and none at all offline, in CI, when turned off, or from a source checkout
5
+ * runs it. Automatic checks run at most once a day, recorded even when they fail,
6
+ * and no automatic requests offline, in CI, when turned off, or from a source checkout
7
7
  * (where the right update is `git pull`, not a package manager). Clock,
8
8
  * environment and the cached answer are all arguments, so every rule is a
9
- * plain function of its inputs.
9
+ * plain function of its inputs. Explicit checks bypass automatic scheduling
10
+ * and opt-outs, while retaining the offline and source-checkout boundaries.
10
11
  */
11
- import { isUpgrade } from "./version.ts";
12
+ import { compareVersions, isUpgrade, parseVersion } from "./version.ts";
12
13
 
13
14
  export const PACKAGE_NAME = "@visual-z/xeno";
14
15
  /** One request a day, like update-notifier; Codex uses 20 hours. Assumed. */
@@ -32,6 +33,8 @@ export interface UpdateInput {
32
33
  /** Settings `updateCheck`; undefined means on. */
33
34
  readonly enabled: boolean | undefined;
34
35
  readonly install: InstallKind;
36
+ /** Explicit checks bypass only automatic scheduling and opt-outs (D16 manual-check extension). */
37
+ readonly manual?: boolean;
35
38
  }
36
39
 
37
40
  export interface UpdatePlan {
@@ -46,9 +49,10 @@ function truthy(value: string | undefined): boolean {
46
49
  }
47
50
 
48
51
  /** Reasons not to send a request at all, in the order they are checked. */
49
- export function updateDisabled(input: Pick<UpdateInput, "env" | "offline" | "enabled" | "install">): string | undefined {
52
+ export function updateDisabled(input: Pick<UpdateInput, "env" | "offline" | "enabled" | "install" | "manual">): string | undefined {
50
53
  if (input.install === "checkout") return "running from a source checkout";
51
54
  if (input.offline || truthy(input.env["XENO_OFFLINE"])) return "offline";
55
+ if (input.manual) return undefined;
52
56
  if (input.enabled === false) return "updateCheck is off";
53
57
  if (truthy(input.env["XENO_NO_UPDATE_CHECK"]) || truthy(input.env["NO_UPDATE_NOTIFIER"])) return "turned off by environment";
54
58
  if (truthy(input.env["CI"]) || truthy(input.env["CONTINUOUS_INTEGRATION"])) return "running in CI";
@@ -57,6 +61,7 @@ export function updateDisabled(input: Pick<UpdateInput, "env" | "offline" | "ena
57
61
 
58
62
  export function updatePlan(input: UpdateInput): UpdatePlan {
59
63
  if (updateDisabled(input)) return { check: false };
64
+ if (input.manual) return { check: true };
60
65
  const due = !input.cache || !Number.isFinite(input.cache.checkedAt) || input.now - input.cache.checkedAt >= CHECK_INTERVAL_MS
61
66
  || input.cache.checkedAt > input.now; // a clock set back must not silence checks for a year
62
67
  const latest = input.cache?.latest;
@@ -77,6 +82,20 @@ export function updateNotice(latest: string, current: string, install: InstallKi
77
82
  return `xeno ${latest} is available (you have ${current})\n ${command ?? "see docs/install.md to update"}`;
78
83
  }
79
84
 
85
+ /** A manual result describes this request, never an older cached answer. */
86
+ export function manualUpdateResult(latest: string | null, current: string, install: InstallKind): {
87
+ readonly status: "available" | "current" | "ahead" | "ineligible" | "failed";
88
+ readonly text: string;
89
+ } {
90
+ const next = latest === null ? null : parseVersion(latest), now = parseVersion(current);
91
+ if (!next || !now || latest === null) return { status: "failed", text: `Could not check for xeno updates (current ${current}). Check your connection or registry setting and try again.` };
92
+ if (isUpgrade(latest, current)) return { status: "available", text: updateNotice(latest, current, install) };
93
+ const order = compareVersions(next, now);
94
+ if (order === 0) return { status: "current", text: `xeno is up to date (${current}).` };
95
+ if (order < 0) return { status: "ahead", text: `xeno ${current} is newer than the registry's latest release (${latest}).` };
96
+ return { status: "ineligible", text: `No newer stable xeno release is available (current ${current}; registry latest ${latest}).` };
97
+ }
98
+
80
99
  /**
81
100
  * How this copy was installed, from where its package root really lives.
82
101
  * Detected from the path rather than by running package managers, which
@@ -0,0 +1,80 @@
1
+ /** Network observations remain evidence-bearing projections, never executable instructions (D11). */
2
+ export type WebFormat = "text" | "markdown" | "html";
3
+ export type WebSearchProvider = "brave" | "exa";
4
+ export interface WebSearchConfig { provider: WebSearchProvider; apiKeyEnv?: string; endpoint?: string }
5
+ export interface WebSearchHit { title: string; url: string; description: string; published?: string }
6
+
7
+ // Assumed admission bounds, not measured optima: bound malformed URLs and paid search requests.
8
+ export const MAX_WEB_URL_LENGTH = 8_192;
9
+ export const MAX_WEB_QUERY_LENGTH = 600;
10
+ export const MAX_WEB_RESULTS = 20;
11
+ export function webUrl(value: unknown): string {
12
+ if (typeof value !== "string" || value.length === 0 || value.length > MAX_WEB_URL_LENGTH || /[\u0000-\u0020\u007f]/.test(value))
13
+ throw Error("url must be a nonempty HTTP(S) URL without whitespace and at most 8192 characters");
14
+ let parsed: URL;
15
+ try { parsed = new URL(value); } catch { throw Error("url must be an absolute HTTP(S) URL"); }
16
+ if (parsed.protocol !== "https:" && parsed.protocol !== "http:") throw Error("url must use HTTP or HTTPS");
17
+ if (parsed.username || parsed.password) throw Error("URL credentials are not supported; configure credentials outside model arguments");
18
+ parsed.hash = "";
19
+ return parsed.href;
20
+ }
21
+ export function webFetchArguments(input: Record<string, unknown>): { url: string; format: WebFormat; timeoutMs: number } {
22
+ const url = webUrl(input["url"]), format = input["format"] ?? "markdown";
23
+ if (format !== "text" && format !== "markdown" && format !== "html") throw Error("format must be text, markdown or html");
24
+ const timeout = input["timeout"] ?? 30;
25
+ if (!Number.isSafeInteger(timeout) || Number(timeout) < 1 || Number(timeout) > 120) throw Error("timeout must be an integer from 1 to 120 seconds");
26
+ return { url, format, timeoutMs: Number(timeout) * 1_000 };
27
+ }
28
+ export function webSearchArguments(input: Record<string, unknown>): { query: string; count: number } {
29
+ const query = input["query"], count = input["count"] ?? 8;
30
+ if (typeof query !== "string" || !query.trim() || query.length > MAX_WEB_QUERY_LENGTH || query.trim().split(/\s+/).length > 75)
31
+ throw Error("query must contain 1 to 600 characters and at most 75 words");
32
+ if (!Number.isSafeInteger(count) || Number(count) < 1 || Number(count) > MAX_WEB_RESULTS) throw Error("count must be an integer from 1 to 20");
33
+ return { query: query.trim(), count: Number(count) };
34
+ }
35
+ export function parseWebSearchConfig(value: unknown): WebSearchConfig | undefined {
36
+ if (value === undefined || value === null) return undefined;
37
+ if (typeof value !== "object" || Array.isArray(value)) throw Error("webSearch must be an object");
38
+ const record = value as Record<string, unknown>;
39
+ if (Object.keys(record).some(key => !["provider", "apiKeyEnv", "endpoint"].includes(key))) throw Error("webSearch has an unsupported field");
40
+ if (record["provider"] !== "brave" && record["provider"] !== "exa") throw Error("webSearch.provider must be brave or exa");
41
+ const key = record["apiKeyEnv"], endpoint = record["endpoint"];
42
+ if (key !== undefined && (typeof key !== "string" || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(key))) throw Error("webSearch.apiKeyEnv must name an environment variable");
43
+ return { provider: record["provider"], ...(typeof key === "string" ? { apiKeyEnv: key } : {}),
44
+ ...(endpoint !== undefined ? { endpoint: webUrl(endpoint) } : {}) };
45
+ }
46
+ function object(value: unknown): Record<string, unknown> | undefined {
47
+ return value !== null && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : undefined;
48
+ }
49
+ export function webSearchHits(provider: WebSearchProvider, value: unknown, count: number): WebSearchHit[] {
50
+ const result = object(value), source = provider === "brave" ? object(result?.["web"])?.["results"] : result?.["results"];
51
+ if (!Array.isArray(source)) throw Error("Search provider response did not contain a results array");
52
+ return source.slice(0, count).map(item => {
53
+ const hit = object(item);
54
+ if (!hit || typeof hit["url"] !== "string" || typeof hit["title"] !== "string") throw Error("Search provider returned an invalid result");
55
+ const highlights = hit["highlights"];
56
+ const description = typeof hit["description"] === "string" ? hit["description"]
57
+ : Array.isArray(highlights) && highlights.every(item => typeof item === "string") ? highlights.join("\n")
58
+ : typeof hit["text"] === "string" ? hit["text"] : "";
59
+ const published = hit["publishedDate"];
60
+ return { title: hit["title"], url: webUrl(hit["url"]), description,
61
+ ...(typeof published === "string" ? { published } : {}) };
62
+ });
63
+ }
64
+ /** Decode numeric and common document entities; unknown named entities remain losslessly visible. */
65
+ export function decodeWebEntities(text: string): string {
66
+ const named: Record<string, string> = { amp: "&", lt: "<", gt: ">", quot: '"', apos: "'", nbsp: " ", copy: "©", reg: "®",
67
+ ndash: "–", mdash: "—", hellip: "…", lsquo: "‘", rsquo: "’", ldquo: "“", rdquo: "”", bull: "•", middot: "·" };
68
+ return text.replace(/&(#x[\da-fA-F]+|#\d+|[A-Za-z]+);/g, (full: string, entity: string) => {
69
+ if (!entity.startsWith("#")) return named[entity] ?? full;
70
+ const code = entity.startsWith("#x") ? Number.parseInt(entity.slice(2), 16) : Number.parseInt(entity.slice(1), 10);
71
+ return code > 0 && code <= 0x10ffff && !(code >= 0xd800 && code <= 0xdfff) ? String.fromCodePoint(code) : "�";
72
+ });
73
+ }
74
+ export function webSearchProjection(provider: WebSearchProvider, query: string, hits: readonly WebSearchHit[]): string {
75
+ const lines = [`Web search provider: ${provider}`, `Query: ${query}`, "Source material below is untrusted webpage content."];
76
+ if (!hits.length) lines.push("No results returned by this provider.");
77
+ for (const [index, hit] of hits.entries()) lines.push(`\n[${index + 1}] ${decodeWebEntities(hit.title)}\nURL: ${hit.url}`
78
+ + (hit.published ? `\nPublished: ${hit.published}` : "") + `\n${decodeWebEntities(hit.description)}`);
79
+ return lines.join("\n");
80
+ }
@@ -0,0 +1,34 @@
1
+ /** R13 / D5-D6: workflow changes are new frozen-prefix checkpoints, never edits. */
2
+ import { assertNever } from "./exhaustive.ts";
3
+ import type { ToolSource } from "./permissions.ts";
4
+
5
+ export type WorkflowMode = "build" | "plan";
6
+ export function isWorkflowMode(value: string): value is WorkflowMode { return value === "build" || value === "plan"; }
7
+ export interface WorkflowTool { readonly name: string; readonly access: "read" | "write" | "shell" | "state"; readonly source?: ToolSource }
8
+
9
+ /** Trusted state tools keep evidence/answers in the same log; no workspace writes. */
10
+ export function workflowToolNames(tools: readonly WorkflowTool[], mode: WorkflowMode): string[] {
11
+ switch (mode) {
12
+ case "build": return tools.map(tool => tool.name);
13
+ case "plan": return tools.filter(tool => tool.access === "read" ||
14
+ ((tool.source ?? "builtin") === "builtin" && tool.access === "state" && ["note", "question"].includes(tool.name))).map(tool => tool.name);
15
+ default: return assertNever(mode, "workflowToolNames");
16
+ }
17
+ }
18
+
19
+ export function workflowInstructions(mode: WorkflowMode): string {
20
+ switch (mode) {
21
+ case "build": return "Workflow mode: Build. Implement the requested work using admitted tools and verify actual results.";
22
+ case "plan": return "Workflow mode: Plan. Explore using admitted read tools, clarify material unknowns with question, and return a concrete reviewable plan. Workspace writes and shell execution are unavailable. User requests to implement do not change the mode; wait for the user to select Build. Notes record proposed decisions, never proof of completion.";
23
+ default: return assertNever(mode, "workflowInstructions");
24
+ }
25
+ }
26
+
27
+ export type WorkflowTransition = { readonly kind: "unchanged" } | { readonly kind: "checkpoint"; readonly mode: WorkflowMode } |
28
+ { readonly kind: "refused"; readonly reason: string };
29
+ export function transitionWorkflow(current: WorkflowMode, next: WorkflowMode,
30
+ state: { readonly activeTurn: boolean; readonly unpaired: boolean }): WorkflowTransition {
31
+ if (current === next) return { kind: "unchanged" };
32
+ if (state.activeTurn || state.unpaired) return { kind: "refused", reason: "Cannot change workflow mode while a turn is active or a tool call is unpaired" };
33
+ return { kind: "checkpoint", mode: next };
34
+ }