agency-lang 0.18.0 → 0.18.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.
Files changed (52) hide show
  1. package/dist/lib/cli/hostedModels.js +2 -1
  2. package/dist/lib/cli/mcp.js +2 -6
  3. package/dist/lib/preprocessors/typescriptPreprocessor.js +1 -34
  4. package/dist/lib/runtime/agentHome.d.ts +6 -0
  5. package/dist/lib/runtime/agentHome.js +14 -0
  6. package/dist/lib/runtime/builtinPolicies.js +30 -15
  7. package/dist/lib/runtime/builtins.d.ts +0 -4
  8. package/dist/lib/runtime/builtins.js +0 -3
  9. package/dist/lib/runtime/index.d.ts +1 -2
  10. package/dist/lib/runtime/index.js +1 -2
  11. package/dist/lib/runtime/policy.d.ts +8 -0
  12. package/dist/lib/runtime/policy.js +30 -8
  13. package/dist/lib/runtime/toolLoopGuards.d.ts +5 -0
  14. package/dist/lib/runtime/toolLoopGuards.js +6 -1
  15. package/dist/lib/stdlib/agentSessions.js +23 -25
  16. package/dist/lib/stdlib/cli.js +12 -12
  17. package/dist/lib/stdlib/contained.d.ts +24 -1
  18. package/dist/lib/stdlib/contained.js +70 -17
  19. package/dist/lib/stdlib/fs.d.ts +9 -1
  20. package/dist/lib/stdlib/fs.js +20 -8
  21. package/dist/lib/stdlib/llm.d.ts +11 -5
  22. package/dist/lib/stdlib/llm.js +15 -8
  23. package/dist/lib/stdlib/localModelManifest.d.ts +5 -5
  24. package/dist/lib/stdlib/localModelManifest.js +10 -22
  25. package/dist/lib/stdlib/localModels.d.ts +3 -0
  26. package/dist/lib/stdlib/localModels.js +50 -28
  27. package/dist/lib/stdlib/mcp.js +6 -5
  28. package/dist/lib/stdlib/oauth.js +15 -20
  29. package/dist/lib/stdlib/shell.d.ts +2 -2
  30. package/dist/lib/stdlib/skills.d.ts +0 -18
  31. package/dist/lib/stdlib/skills.js +0 -22
  32. package/dist/lib/stdlib/speech.d.ts +5 -5
  33. package/dist/lib/stdlib/speech.js +44 -68
  34. package/package.json +1 -1
  35. package/stdlib/docs/guide/custom-providers.md +4 -1
  36. package/stdlib/docs/stdlib/fs.md +6 -5
  37. package/stdlib/docs/stdlib/llm.md +43 -12
  38. package/stdlib/docs/stdlib/shell.md +1 -1
  39. package/stdlib/docs/stdlib/skills.md +8 -8
  40. package/stdlib/docs/stdlib/toolbox.md +25 -12
  41. package/stdlib/fs.agency +9 -4
  42. package/stdlib/fs.js +54 -10
  43. package/stdlib/llm.agency +35 -3
  44. package/stdlib/llm.js +187 -10
  45. package/stdlib/shell.agency +3 -3
  46. package/stdlib/shell.js +4 -4
  47. package/stdlib/skills.agency +8 -5
  48. package/stdlib/skills.js +38 -10
  49. package/stdlib/toolbox.agency +21 -13
  50. package/stdlib/toolbox.js +59 -62
  51. package/dist/lib/runtime/builtinTools.d.ts +0 -9
  52. package/dist/lib/runtime/builtinTools.js +0 -16
@@ -1,4 +1,5 @@
1
1
  import { _listHostedModels, _fetchModelData, _loadModelData, } from "../stdlib/llm.js";
2
+ import { wholePath } from "../stdlib/contained.js";
2
3
  // Filter predicate mirrors the Agency `filterHostedModels` in
3
4
  // lib/agents/agency-agent/lib/modelFilters.agency (the agent can't call this TS
4
5
  // and vice-versa) — keep the two in sync; both are unit-tested.
@@ -40,7 +41,7 @@ export function formatHostedCatalog(models) {
40
41
  // doesn't mistake a baked-only list for the merged one.
41
42
  export async function modelsList(opts, files = []) {
42
43
  for (const file of files) {
43
- const res = _loadModelData(file);
44
+ const res = _loadModelData(file, wholePath);
44
45
  if (!res.ok) {
45
46
  console.error(`Cannot load ${file}: ${res.error}`);
46
47
  process.exitCode = 1;
@@ -1,13 +1,9 @@
1
- import * as os from "os";
2
1
  import * as path from "path";
3
2
  import { isFailure } from "../runtime/index.js";
3
+ import { agentHomeDir } from "../runtime/agentHome.js";
4
4
  import { _addMcpServer, _removeMcpServer, _readMcpServersFromFile } from "../stdlib/mcp.js";
5
- function agentHome() {
6
- const override = process.env.AGENCY_AGENT_HOME;
7
- return override ? path.resolve(override) : path.join(os.homedir(), ".agency-agent");
8
- }
9
5
  const projectFile = () => path.resolve(process.cwd(), "agency.json");
10
- const globalFile = () => path.join(agentHome(), "settings.json");
6
+ const globalFile = () => path.join(agentHomeDir(), "settings.json");
11
7
  const scopeFile = (o) => (o.global ? globalFile() : projectFile());
12
8
  const scopeName = (o) => (o.global ? "global" : "project");
13
9
  function transportSummary(config) {
@@ -374,40 +374,7 @@ export class TypescriptPreprocessor {
374
374
  // TODO: Update collectSkillsInFunction to work with llm() as FunctionCall.
375
375
  // Skills are now passed in the config object (2nd arg to llm()), but skill
376
376
  // statements still need to be collected and merged with config skills.
377
- collectSkillsInFunction(body) {
378
- /* Original implementation (used PromptLiteral nodes, needs rewrite for FunctionCall):
379
- let skillsUsed: Skill[] = [];
380
-
381
- const setSkillsForPrompt = (promptNode: PromptLiteral) => {
382
- promptNode.skills = skillsUsed;
383
-
384
- if (skillsUsed.length > 0) {
385
- const hasReadSkillTool = promptNode.tools?.toolNames.some(
386
- (t) => t === "readSkill",
387
- );
388
- if (!hasReadSkillTool) {
389
- promptNode.tools = promptNode.tools || {
390
- type: "usesTool",
391
- toolNames: [],
392
- };
393
- promptNode.tools.toolNames.push("readSkill");
394
- }
395
- }
396
-
397
- skillsUsed = [];
398
- };
399
-
400
- for (const { node } of walkNodesArray(body)) {
401
- if (node.type === "skill") {
402
- skillsUsed.push(node);
403
- } else if (node.type === "prompt") {
404
- setSkillsForPrompt(node);
405
- } else if (node.type === "assignment" && node.value.type === "prompt") {
406
- setSkillsForPrompt(node.value);
407
- }
408
- }
409
- */
410
- }
377
+ collectSkillsInFunction(body) { }
411
378
  findChildren(body, type) {
412
379
  const children = [];
413
380
  for (const { node } of walkNodesArray(body)) {
@@ -0,0 +1,6 @@
1
+ /** The agent home directory: `AGENCY_AGENT_HOME`, or `~/.agency-agent`.
2
+ * An empty variable counts as unset, so a set-but-blank value never
3
+ * turns the home into the current directory. A relative override is
4
+ * resolved against the process cwd, the way the `--agent-home` launcher
5
+ * resolves it. */
6
+ export declare function agentHomeDir(): string;
@@ -0,0 +1,14 @@
1
+ import os from "os";
2
+ import path from "path";
3
+ /** The agent home directory: `AGENCY_AGENT_HOME`, or `~/.agency-agent`.
4
+ * An empty variable counts as unset, so a set-but-blank value never
5
+ * turns the home into the current directory. A relative override is
6
+ * resolved against the process cwd, the way the `--agent-home` launcher
7
+ * resolves it. */
8
+ export function agentHomeDir() {
9
+ const override = process.env.AGENCY_AGENT_HOME;
10
+ if (override !== undefined && override !== "") {
11
+ return path.resolve(override);
12
+ }
13
+ return path.join(os.homedir(), ".agency-agent");
14
+ }
@@ -1,4 +1,4 @@
1
- import { AGENCY_INSTALL_DIR_PLACEHOLDER as INSTALL, escapeGlob, } from "./policy.js";
1
+ import { AGENCY_INSTALL_DIR_PLACEHOLDER as INSTALL, AGENT_HOME_PLACEHOLDER as AGENT_HOME, escapeGlob, } from "./policy.js";
2
2
  // Read-only `agency` subcommands the code agent runs via its exec-based
3
3
  // `agencyCli` tool. Matched on command + subcommand (no shell chaining);
4
4
  // other subcommands (run, compile, ...) still prompt.
@@ -22,21 +22,35 @@ function agencyExecApproveRules() {
22
22
  }));
23
23
  }
24
24
  const approve = [{ action: "approve" }];
25
- // Where the read-only file tools may look without asking: the launch
26
- // directory and, because the agent's docs tools (`agencyGuide` and friends)
27
- // and bundled skills are plain reads of shipped files, the agency install
28
- // itself. Both are placeholders the matcher expands at match time (`.` to
29
- // the process cwd, `<agency>` to the package root; see
30
- // docs/dev/agents/approval-policies.md), so a saved copy of this policy keeps
31
- // meaning "wherever the agent runs, wherever agency is installed now".
32
- // Reads anywhere else fall through: a prompt in an interactive session, an
33
- // automatic rejection in a headless one.
25
+ // Where the read-only file tools may look without asking. The launch
26
+ // directory. The agency install itself, because the agent's docs tools
27
+ // (`agencyGuide` and friends) and bundled skills are plain reads of
28
+ // shipped files. The agent home's learned skills and tools, which only
29
+ // enter those directories through a review interrupt. All three are
30
+ // placeholders the matcher expands at match time (`.` to the process cwd,
31
+ // `<agency>` to the package root, `<agent-home>` to the agent home, see
32
+ // docs/dev/agents/approval-policies.md), so a saved copy of this policy
33
+ // keeps meaning "wherever the agent runs, wherever agency is installed
34
+ // now". Reads anywhere else fall through: a prompt in an interactive
35
+ // session, an automatic rejection in a headless one.
34
36
  export function readScopeRules() {
35
37
  return [
36
38
  { match: { dir: "{.,./**}" }, action: "approve" },
37
39
  { match: { dir: `{${INSTALL}/stdlib/**,${INSTALL}/dist/**}` }, action: "approve" },
40
+ {
41
+ match: {
42
+ dir: `{${AGENT_HOME}/skills,${AGENT_HOME}/skills/**,${AGENT_HOME}/tools,${AGENT_HOME}/tools/**}`,
43
+ },
44
+ action: "approve",
45
+ },
38
46
  ];
39
47
  }
48
+ // runTool's use count in a tool's meta.json. An effect of its own, never
49
+ // a std::write rule on the file; docs/dev/agents/approval-policies.md
50
+ // says why.
51
+ function toolboxRecordUseRules() {
52
+ return [{ match: { dir: `${AGENT_HOME}/tools/**` }, action: "approve" }];
53
+ }
40
54
  export const minimalAutoApprovePolicy = {
41
55
  "std::memory::remember": approve,
42
56
  "std::memory::forget": approve,
@@ -66,11 +80,12 @@ export const recommendedAutoApprovePolicy = {
66
80
  "std::weather": approve,
67
81
  "std::search": approve,
68
82
  "std::tavilySearch": approve,
69
- "std::skills::skillsDir": approve,
70
- "std::skills::commandsDir": approve,
71
- // A scan reads tool sources and meta.json under a directory, so it
72
- // takes the read scope.
83
+ // A scan reads every skill, command, or tool file under a directory,
84
+ // so it takes the read scope.
85
+ "std::skills::skillsDir": readScopeRules(),
86
+ "std::skills::commandsDir": readScopeRules(),
73
87
  "std::toolbox::scan": readScopeRules(),
88
+ "std::toolbox::recordUse": toolboxRecordUseRules(),
74
89
  "std::notify": approve,
75
90
  "std::clipboardCopy": approve,
76
91
  "std::git::status": approve,
@@ -126,7 +141,7 @@ export const approveAllPolicy = {
126
141
  export const BUILTIN_POLICIES = [
127
142
  {
128
143
  name: "recommended",
129
- description: "Auto-approve reads under the current directory (and the agency install's own docs and skills) and web/search; prompt for reads elsewhere, writes, shell, and git changes.",
144
+ description: "Auto-approve reads under the current directory, the agency install's own docs and skills, and the agent home's learned skills and tools (plus the toolbox use count there), and web/search; prompt for reads elsewhere, writes, shell, and git changes.",
130
145
  },
131
146
  {
132
147
  name: "minimal",
@@ -6,7 +6,3 @@ export declare function builtinRead(args: {
6
6
  dirname: string;
7
7
  }): string;
8
8
  export declare function builtinSleep(seconds: number): Promise<void>;
9
- export declare function readSkill(args: {
10
- filepath: string;
11
- dirname: string;
12
- }): string;
@@ -14,6 +14,3 @@ export function builtinSleep(seconds) {
14
14
  setTimeout(resolve, seconds * 1000);
15
15
  });
16
16
  }
17
- export function readSkill(args) {
18
- return builtinRead({ filename: args.filepath, dirname: args.dirname });
19
- }
@@ -36,8 +36,7 @@ export type { FuncParam, CallType, ToolDefinition, AgencyFunctionOpts } from "./
36
36
  export { __call, __callMethod } from "./call.js";
37
37
  export { callHook, registerGlobalHook } from "./hooks.js";
38
38
  export type { AgencyCallbacks, CallbackMap, CallbackReturn } from "./hooks.js";
39
- export { head, tail, empty, builtinRead, builtinSleep, readSkill } from "./builtins.js";
40
- export { readSkillTool, readSkillToolParams } from "./builtinTools.js";
39
+ export { head, tail, empty, builtinRead, builtinSleep } from "./builtins.js";
41
40
  export { interrupt, isInterrupt, hasInterrupts, reportUnhandledInterrupts, isDebugger, isRejected, isApproved, approve, reject, pass, interruptWithHandlers, respondToInterrupts, respondToInterruptsForServe, } from "./interrupts.js";
42
41
  export { checkPolicy, checkPolicyExplicit, validatePolicy } from "./policy.js";
43
42
  export { resolveCliInterrupts } from "./cliInterruptResolution.js";
@@ -25,8 +25,7 @@ export { functionRefReviver } from "./revivers/index.js";
25
25
  export { AgencyFunction, UNSET } from "./agencyFunction.js";
26
26
  export { __call, __callMethod } from "./call.js";
27
27
  export { callHook, registerGlobalHook } from "./hooks.js";
28
- export { head, tail, empty, builtinRead, builtinSleep, readSkill } from "./builtins.js";
29
- export { readSkillTool, readSkillToolParams } from "./builtinTools.js";
28
+ export { head, tail, empty, builtinRead, builtinSleep } from "./builtins.js";
30
29
  export { interrupt, isInterrupt, hasInterrupts, reportUnhandledInterrupts, isDebugger, isRejected, isApproved, approve, reject, pass, interruptWithHandlers, respondToInterrupts, respondToInterruptsForServe, } from "./interrupts.js";
31
30
  export { checkPolicy, checkPolicyExplicit, validatePolicy } from "./policy.js";
32
31
  export { resolveCliInterrupts } from "./cliInterruptResolution.js";
@@ -56,6 +56,14 @@ export declare function resolveDotDirPattern(pattern: string, cwd?: string): str
56
56
  * path of one machine or one version. */
57
57
  export declare const AGENCY_INSTALL_DIR_PLACEHOLDER = "<agency>";
58
58
  export declare function expandAgencyInstallDir(pattern: string, root?: () => string): string;
59
+ /** In a `dir` pattern, `<agent-home>` stands for the agent home directory
60
+ * (`AGENCY_AGENT_HOME`, or `~/.agency-agent`). The built-in read scope
61
+ * uses it for the learned skills and tools directories, so a saved policy
62
+ * keeps meaning "wherever the agent home is now". */
63
+ export declare const AGENT_HOME_PLACEHOLDER = "<agent-home>";
64
+ /** Expand `<agent-home>` at match time, like `<agency>`. The home is
65
+ * escaped so a path containing glob or brace characters stays literal. */
66
+ export declare function expandAgentHomeDir(pattern: string, home?: () => string): string;
59
67
  export declare function validatePolicy(policy: any): {
60
68
  success: boolean;
61
69
  error?: string;
@@ -2,6 +2,8 @@ import picomatch from "picomatch";
2
2
  import { realpathSync } from "fs";
3
3
  import { z } from "zod";
4
4
  import { getPackageRoot } from "../importPaths.js";
5
+ import { agentHomeDir } from "./agentHome.js";
6
+ import { root } from "../stdlib/contained.js";
5
7
  export const PolicyRuleSchema = z
6
8
  .object({
7
9
  match: z.record(z.string(), z.string()).optional(),
@@ -75,9 +77,6 @@ function stripDotSlash(s) {
75
77
  // whose name contains glob characters (say `v*1`) would widen the rule to
76
78
  // its siblings — a safety boundary, so the substituted prefix must match
77
79
  // itself only. Glob syntax stays live only in the user-written suffix.
78
- function escapeForGlob(s) {
79
- return s.replace(/[\\*?[\]{}()!+@|]/g, "\\$&");
80
- }
81
80
  // In a `dir` pattern, `.` also means "wherever the agent was launched".
82
81
  // Tools absolutize the dir they put in interrupt data, so a literal `.` in
83
82
  // a policy file could never match those; resolving it lets a static policy
@@ -103,7 +102,7 @@ export function resolveDotDirPattern(pattern, cwd = process.cwd()) {
103
102
  }
104
103
  // Callback, not a replacement string: a legal cwd containing `$&`/`$'`
105
104
  // would otherwise be interpreted as replacement-string syntax.
106
- return pattern.replace(/(^|\{|,)\.(?=$|\/|,|\})/g, (_match, prefix) => prefix + escapeForGlob(realCwd));
105
+ return pattern.replace(/(^|\{|,)\.(?=$|\/|,|\})/g, (_match, prefix) => prefix + escapeGlob(realCwd));
107
106
  }
108
107
  /** In a `dir` pattern, `<agency>` stands for the directory the agency
109
108
  * package is installed in. A rule approving reads of the shipped docs and
@@ -124,7 +123,30 @@ export function expandAgencyInstallDir(pattern, root = getPackageRoot) {
124
123
  catch {
125
124
  return pattern;
126
125
  }
127
- return pattern.split(AGENCY_INSTALL_DIR_PLACEHOLDER).join(escapeForGlob(resolved));
126
+ return pattern.split(AGENCY_INSTALL_DIR_PLACEHOLDER).join(escapeGlob(resolved));
127
+ }
128
+ /** In a `dir` pattern, `<agent-home>` stands for the agent home directory
129
+ * (`AGENCY_AGENT_HOME`, or `~/.agency-agent`). The built-in read scope
130
+ * uses it for the learned skills and tools directories, so a saved policy
131
+ * keeps meaning "wherever the agent home is now". */
132
+ export const AGENT_HOME_PLACEHOLDER = "<agent-home>";
133
+ /** Expand `<agent-home>` at match time, like `<agency>`. The home is
134
+ * escaped so a path containing glob or brace characters stays literal. */
135
+ export function expandAgentHomeDir(pattern, home = canonicalAgentHome) {
136
+ if (!pattern.includes(AGENT_HOME_PLACEHOLDER))
137
+ return pattern;
138
+ return pattern.split(AGENT_HOME_PLACEHOLDER).join(escapeGlob(home()));
139
+ }
140
+ /** The real spelling of the agent home, the spelling file effects put in
141
+ * their payloads. A home that does not exist yet keeps a lexical tail. */
142
+ function canonicalAgentHome() {
143
+ const home = agentHomeDir();
144
+ try {
145
+ return root(home).real;
146
+ }
147
+ catch {
148
+ return home;
149
+ }
128
150
  }
129
151
  function matchesRule(rule, interrupt) {
130
152
  if (!rule.match)
@@ -151,11 +173,11 @@ function matchesRule(rule, interrupt) {
151
173
  const viaDot = !raw &&
152
174
  key === "dir" &&
153
175
  picomatch.isMatch(stripDotSlash(value), stripDotSlash(resolveDotDirPattern(pattern)));
154
- const viaInstall = !raw &&
176
+ const viaPlaceholders = !raw &&
155
177
  !viaDot &&
156
178
  key === "dir" &&
157
- picomatch.isMatch(stripDotSlash(value), expandAgencyInstallDir(pattern));
158
- if (!raw && !viaDot && !viaInstall) {
179
+ picomatch.isMatch(stripDotSlash(value), expandAgentHomeDir(expandAgencyInstallDir(pattern)));
180
+ if (!raw && !viaDot && !viaPlaceholders) {
159
181
  return false;
160
182
  }
161
183
  }
@@ -20,6 +20,11 @@ export declare const DEFAULT_MAX_REPEATED_TOOL_CALLS = 3;
20
20
  * a transcript or XML tool may legitimately be given. */
21
21
  export declare function markupArgument(args: Record<string, unknown>, params: readonly FuncParam[]): string | null;
22
22
  export declare function markupArgumentMessage(toolName: string, argument: string): string;
23
+ /** The key is stored on the checkpoint, and checkpoints are kept as JSON
24
+ * in places that reject U+0000 (Postgres jsonb, for one), so the two parts
25
+ * are joined with a colon. A namespaced tool name has colons of its own,
26
+ * but the digest is always 64 hex characters, so two different calls can
27
+ * never produce the same key. */
23
28
  export declare function repeatKey(toolName: string, args: Record<string, unknown>): string;
24
29
  /** The current run of identical calls: one record, because only calls in a
25
30
  * row count. Any other call, a different result, or a refusal resets it. */
@@ -66,8 +66,13 @@ function canonicalJson(value) {
66
66
  function digest(text) {
67
67
  return createHash("sha256").update(text).digest("hex");
68
68
  }
69
+ /** The key is stored on the checkpoint, and checkpoints are kept as JSON
70
+ * in places that reject U+0000 (Postgres jsonb, for one), so the two parts
71
+ * are joined with a colon. A namespaced tool name has colons of its own,
72
+ * but the digest is always 64 hex characters, so two different calls can
73
+ * never produce the same key. */
69
74
  export function repeatKey(toolName, args) {
70
- return `${toolName}\u0000${digest(canonicalJson(args))}`;
75
+ return `${toolName}:${digest(canonicalJson(args))}`;
71
76
  }
72
77
  export function freshRepeatStreak() {
73
78
  return { key: "", result: "", count: 0 };
@@ -1,28 +1,22 @@
1
- import * as fs from "fs";
2
- import * as path from "path";
1
+ import { root, list, stat, mkdir, readText, writeText } from "./contained.js";
3
2
  import { __call } from "../runtime/call.js";
4
3
  import { checkpoint, getCheckpoint } from "../runtime/checkpoint.js";
5
4
  import { Checkpoint } from "../runtime/state/checkpointStore.js";
6
5
  import { _contentToString } from "./threads.js";
7
6
  const META_SUFFIX = ".meta.json";
8
- function checkpointFile(dir, id) {
9
- return path.join(dir, `${id}.json`);
7
+ function checkpointFile(id) {
8
+ return `${id}.json`;
10
9
  }
11
- function metaFile(dir, id) {
12
- return path.join(dir, `${id}${META_SUFFIX}`);
10
+ function metaFile(id) {
11
+ return `${id}${META_SUFFIX}`;
13
12
  }
14
- /** Write via a sibling temp file and rename, so a crash mid-write never
15
- * leaves a half-written file (the same pattern as the REPL history). */
16
- function writeAtomic(file, data) {
17
- const tmp = `${file}.tmp-${process.pid}`;
18
- fs.writeFileSync(tmp, data, "utf8");
19
- fs.renameSync(tmp, file);
20
- }
21
- function readJson(file) {
22
- if (!fs.existsSync(file))
23
- return null;
13
+ /** The parsed JSON of `name` under `dir`, or null when it is missing or
14
+ * malformed. */
15
+ function readJson(dir, name) {
24
16
  try {
25
- return JSON.parse(fs.readFileSync(file, "utf-8"));
17
+ if (stat(dir, name) === null)
18
+ return null;
19
+ return JSON.parse(readText(dir, name));
26
20
  }
27
21
  catch {
28
22
  return null;
@@ -45,13 +39,14 @@ function isRecord(value) {
45
39
  /** Every session in `dir`, most recently active first. A malformed
46
40
  * record file is skipped. */
47
41
  export function _listSessions(dir) {
48
- if (!fs.existsSync(dir))
42
+ const sessions = root(dir);
43
+ if (stat(sessions, ".") === null)
49
44
  return [];
50
45
  const records = [];
51
- for (const name of fs.readdirSync(dir)) {
52
- if (!name.endsWith(META_SUFFIX))
46
+ for (const entry of list(sessions, ".")) {
47
+ if (entry.type !== "file" || !entry.name.endsWith(META_SUFFIX))
53
48
  continue;
54
- const parsed = readJson(path.join(dir, name));
49
+ const parsed = readJson(sessions, entry.name);
55
50
  if (isRecord(parsed))
56
51
  records.push(parsed);
57
52
  }
@@ -62,9 +57,12 @@ export function _listSessions(dir) {
62
57
  * error message. */
63
58
  export function _saveSession(dir, record, checkpoint) {
64
59
  try {
65
- fs.mkdirSync(dir, { recursive: true });
66
- writeAtomic(checkpointFile(dir, record.id), JSON.stringify(checkpoint));
67
- writeAtomic(metaFile(dir, record.id), JSON.stringify(record));
60
+ const sessions = root(dir);
61
+ mkdir(sessions, ".");
62
+ // writeText renames a finished sibling over the target, so a crash
63
+ // mid-write never leaves a half-written file.
64
+ writeText(sessions, checkpointFile(record.id), JSON.stringify(checkpoint));
65
+ writeText(sessions, metaFile(record.id), JSON.stringify(record));
68
66
  return "";
69
67
  }
70
68
  catch (err) {
@@ -73,7 +71,7 @@ export function _saveSession(dir, record, checkpoint) {
73
71
  }
74
72
  /** The parsed checkpoint, or null when the file is missing or malformed. */
75
73
  export function _readCheckpointFile(dir, id) {
76
- return readJson(checkpointFile(dir, id));
74
+ return readJson(root(dir), checkpointFile(id));
77
75
  }
78
76
  /**
79
77
  * The user and assistant messages of a saved session's conversation
@@ -1,7 +1,6 @@
1
1
  import * as readline from "readline";
2
2
  import process from "process";
3
- import { readFileSync, writeFileSync, existsSync, mkdirSync, renameSync } from "fs";
4
- import { dirname } from "path";
3
+ import { wholePath, stat, readText, writeText, mkdir } from "./contained.js";
5
4
  import { __call } from "../runtime/call.js";
6
5
  import { getRuntimeContext } from "../runtime/asyncContext.js";
7
6
  import { RESET, styles } from "../utils/termcolors.js";
@@ -53,10 +52,13 @@ function summarizeMultiline(text) {
53
52
  * startup. */
54
53
  function loadHistory(file, max) {
55
54
  const empty = { entries: [], expansions: {} };
56
- if (!file || !existsSync(file))
55
+ if (!file)
57
56
  return empty;
58
57
  try {
59
- const parsed = JSON.parse(readFileSync(file, "utf8"));
58
+ const located = wholePath(file);
59
+ if (stat(located.root, located.target) === null)
60
+ return empty;
61
+ const parsed = JSON.parse(readText(located.root, located.target));
60
62
  if (!Array.isArray(parsed))
61
63
  return empty;
62
64
  const entries = [];
@@ -87,19 +89,17 @@ function saveHistory(file, history, max, expansions = {}) {
87
89
  if (!file)
88
90
  return;
89
91
  try {
90
- mkdirSync(dirname(file), { recursive: true });
92
+ const located = wholePath(file);
93
+ mkdir(located.root, ".");
91
94
  const oldestFirst = history.slice(0, max).slice().reverse();
92
95
  const records = oldestFirst.map((entry) => Object.prototype.hasOwnProperty.call(expansions, entry)
93
96
  ? { preview: entry, text: expansions[entry] }
94
97
  : entry);
95
98
  const data = JSON.stringify(records, null, 2) + "\n";
96
- // Write to a sibling temp file, then rename into place. The rename is an
97
- // atomic swap (POSIX, and Windows via libuv's REPLACE_EXISTING), so a crash
98
- // mid-write can't leave a half-written file which, as JSON, would parse to
99
- // empty and silently wipe the user's history on the next load.
100
- const tmp = `${file}.tmp-${process.pid}`;
101
- writeFileSync(tmp, data, "utf8");
102
- renameSync(tmp, file);
99
+ // writeText replaces the file by renaming a finished sibling over it, so
100
+ // a crash mid-write cannot leave half-written JSON that would parse to
101
+ // empty and wipe the user's history on the next load.
102
+ writeText(located.root, located.target, data);
103
103
  }
104
104
  catch {
105
105
  // Ignore — best-effort persistence.
@@ -1,3 +1,22 @@
1
+ /**
2
+ * Every file operation the standard library performs on a path an Agency
3
+ * program chose goes through this module. The rule it enforces: under an
4
+ * approval that names directory D, no byte is read from or written to any
5
+ * path outside D. A symlink in the caller's own spelling of D (a linked
6
+ * /tmp, a linked home) resolves normally, once, in `root`. A symlink at
7
+ * any component below D is refused, because the approver never named
8
+ * where it points.
9
+ *
10
+ * How strong that rule is depends on the operation. Reads and writes,
11
+ * `readStream`, and `copy`, which is built on them, move bytes only through a
12
+ * descriptor that is validated after it is open, so a directory swapped
13
+ * to a link while the operation runs cannot redirect them. `list`,
14
+ * `stat`, `mkdir`, `remove`, and `move` act on a pathname that was
15
+ * checked a moment earlier. Node has no openat, so a swap between that
16
+ * check and the action is not closed here. Process containment is the
17
+ * answer for that window. See docs/dev/stdlib/contained-files.md.
18
+ */
19
+ import fs from "fs";
1
20
  import type { Stats } from "fs";
2
21
  /** A directory an approval named, realpathed once. Every operation in this
3
22
  * module takes one. Nothing takes a bare string root. */
@@ -57,6 +76,10 @@ export declare function readText(root: Root, target: string, seams?: Seams): str
57
76
  * inside the root, and require the same (dev, ino) as the descriptor so a
58
77
  * swap undone after the open is caught too. */
59
78
  export declare function readBytes(root: Root, target: string, seams?: Seams): Buffer;
79
+ /** A read stream over a validated descriptor, for a file too large to
80
+ * buffer, such as a downloaded model being hashed. The stream owns the
81
+ * descriptor and closes it when it ends. */
82
+ export declare function readStream(root: Root, target: string, seams?: Seams): fs.ReadStream;
60
83
  export declare function writeText(root: Root, target: string, content: string, options?: WriteOptions): void;
61
84
  export declare function writeBytes(root: Root, target: string, data: Buffer, options?: WriteOptions): void;
62
85
  export type Entry = {
@@ -83,6 +106,6 @@ export declare function copy(from: Located, to: Located): void;
83
106
  export declare function move(from: Located, to: Located): void;
84
107
  /** Every operation this module performs. The symlink battery runs each
85
108
  * one; adding an operation without a row here fails the registry test. */
86
- export declare const PRIMITIVES: readonly ["readText", "readBytes", "writeText", "writeBytes", "list", "stat", "mkdir", "remove", "copy", "move"];
109
+ export declare const PRIMITIVES: readonly ["readText", "readBytes", "readStream", "writeText", "writeBytes", "list", "stat", "mkdir", "remove", "copy", "move"];
87
110
  /** Exports that are not operations: they resolve, they do not touch bytes. */
88
111
  export declare const HELPERS: readonly ["root", "fixedRoot", "resolveUnder", "wholePath", "fixedPath", "isContained", "_realDir", "_realTarget"];