portable-agent-layer 0.91.8 → 0.92.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -85,6 +85,7 @@ pal cli status # check your setup
85
85
  | `pal cli migrate` | Run pending data migrations (non-destructive) |
86
86
  | `pal cli analyze [--actionable]` | Learning analysis: rating trends, failure patterns, graduation candidates |
87
87
  | `pal cli usage` | Summarize token usage and estimated cost |
88
+ | `pal cli infer` | One model call for scripts and bots, on the active agent's route: prompt on stdin, reply on stdout. `--tier small\|medium`, `--system <file>`, `--schema <file>` for a JSON reply checked against it, `--timeout <seconds>`. The call loads no PAL hooks or context; exits 1 with the reason when no route answers |
88
89
  | `pal cli actor [label <name>]` | Show or rename the actor — who caused a record. Travels with an export, so a shared memory can tell two people apart |
89
90
  | `pal cli machine [label <name>]` | Show or rename this install — where a record was written. Never leaves the machine |
90
91
  | `pal cli telos` | Which TELOS topics are answered, in interview order, and which one comes next |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "portable-agent-layer",
3
- "version": "0.91.8",
3
+ "version": "0.92.0",
4
4
  "description": "PAL — Portable Agent Layer: persistent personal context for AI coding assistants",
5
5
  "type": "module",
6
6
  "bin": {
@@ -37,7 +37,7 @@
37
37
  },
38
38
  "scripts": {
39
39
  "type-check": "tsc --noEmit",
40
- "test": "bun test --randomize",
40
+ "test": "bun test --randomize --isolate",
41
41
  "test:mutate": "stryker run",
42
42
  "test:mutate:diff": "bun .agents/scripts/mutate-diff.ts",
43
43
  "format": "biome format",
@@ -107,6 +107,7 @@
107
107
  "marked": "18.0.4",
108
108
  "pdf-lib": "1.17.1",
109
109
  "playwright": "^1.60.0",
110
- "unpdf": "^0.12.0"
110
+ "unpdf": "^0.12.0",
111
+ "zod": "^4.4.3"
111
112
  }
112
113
  }
@@ -0,0 +1,142 @@
1
+ /**
2
+ * pal cli infer — one inference call through PAL's own route, for programs
3
+ * outside an agent session. The route, its model for each tier, and the
4
+ * isolation from PAL's hooks are the same ones PAL's background calls use.
5
+ */
6
+
7
+ import { readFileSync } from "node:fs";
8
+ import { z } from "zod";
9
+ import { inference } from "../hooks/lib/inference";
10
+ import { INFERENCE_TIERS, type InferenceTier } from "../hooks/lib/models";
11
+ import { leaf } from "../tools/lib/command";
12
+
13
+ const DEFAULT_TIMEOUT_SECONDS = 60;
14
+
15
+ interface InferValues {
16
+ tier?: string;
17
+ system?: string;
18
+ schema?: string;
19
+ timeout?: string;
20
+ caller?: string;
21
+ }
22
+
23
+ type Infer = typeof inference;
24
+
25
+ class InferInputError extends Error {}
26
+
27
+ function tierOf(name = "small"): InferenceTier {
28
+ const tier = INFERENCE_TIERS.find((known) => known === name);
29
+ if (!tier) {
30
+ throw new InferInputError(
31
+ `Unknown tier "${name}". Use ${INFERENCE_TIERS.join(", ")}.`
32
+ );
33
+ }
34
+ return tier;
35
+ }
36
+
37
+ function timeoutMs(seconds = String(DEFAULT_TIMEOUT_SECONDS)): number {
38
+ const value = Number(seconds);
39
+ if (!Number.isFinite(value) || value <= 0) {
40
+ throw new InferInputError(`--timeout takes a number of seconds, not "${seconds}".`);
41
+ }
42
+ return value * 1000;
43
+ }
44
+
45
+ function promptOf(stdin: string): string {
46
+ const prompt = stdin.trim();
47
+ if (!prompt) throw new InferInputError("No prompt: pipe it in on stdin.");
48
+ return prompt;
49
+ }
50
+
51
+ function readSchema(path: string): Record<string, unknown> {
52
+ try {
53
+ return JSON.parse(readFileSync(path, "utf-8"));
54
+ } catch {
55
+ throw new InferInputError(`--schema ${path} is not a readable JSON file.`);
56
+ }
57
+ }
58
+
59
+ function assertCheckable(path: string, schema: Record<string, unknown>): void {
60
+ try {
61
+ z.fromJSONSchema(schema);
62
+ } catch (err) {
63
+ throw new InferInputError(
64
+ `--schema ${path} cannot be checked: ${err instanceof Error ? err.message : String(err)}`
65
+ );
66
+ }
67
+ }
68
+
69
+ function schemaFrom(path: string | undefined): Record<string, unknown> | undefined {
70
+ if (!path) return undefined;
71
+ const schema = readSchema(path);
72
+ assertCheckable(path, schema);
73
+ return schema;
74
+ }
75
+
76
+ function requestFrom(values: InferValues, stdin: string): Parameters<Infer>[0] {
77
+ return {
78
+ user: promptOf(stdin),
79
+ system: values.system ? readFileSync(values.system, "utf-8") : undefined,
80
+ jsonSchema: schemaFrom(values.schema),
81
+ tier: tierOf(values.tier),
82
+ timeout: timeoutMs(values.timeout),
83
+ caller: values.caller ?? "cli-infer",
84
+ };
85
+ }
86
+
87
+ function failureReason(result: Awaited<ReturnType<Infer>>, wantedJson: boolean): string {
88
+ if (result.error) return result.error;
89
+ if (wantedJson && result.output) return "the reply was not JSON";
90
+ return "no inference route answered (run `pal cli doctor`)";
91
+ }
92
+
93
+ export async function inferFromCli(
94
+ values: InferValues,
95
+ stdin: string,
96
+ infer: Infer = inference
97
+ ): Promise<number> {
98
+ let request: Parameters<Infer>[0];
99
+ try {
100
+ request = requestFrom(values, stdin);
101
+ } catch (err) {
102
+ console.error(err instanceof Error ? err.message : String(err));
103
+ return 1;
104
+ }
105
+ const result = await infer(request);
106
+ if (!result.success || !result.output) {
107
+ console.error(
108
+ `Inference failed: ${failureReason(result, Boolean(request.jsonSchema))}`
109
+ );
110
+ return 1;
111
+ }
112
+ console.log(result.output);
113
+ return 0;
114
+ }
115
+
116
+ export const inferCommand = leaf({
117
+ summary: "Run one inference call on the active agent's model; prompt on stdin",
118
+ options: {
119
+ tier: {
120
+ type: "string",
121
+ value: "<tier>",
122
+ description: `Model size: ${INFERENCE_TIERS.join(" or ")} (default small)`,
123
+ },
124
+ system: { type: "string", value: "<file>", description: "System prompt file" },
125
+ schema: {
126
+ type: "string",
127
+ value: "<file>",
128
+ description: "JSON schema file; a reply that doesn't match it fails",
129
+ },
130
+ timeout: {
131
+ type: "string",
132
+ value: "<seconds>",
133
+ description: `Give up after this long (default ${DEFAULT_TIMEOUT_SECONDS})`,
134
+ },
135
+ caller: {
136
+ type: "string",
137
+ value: "<label>",
138
+ description: "Label for PAL's debug log",
139
+ },
140
+ },
141
+ run: async ({ values }) => inferFromCli(values, await Bun.stdin.text()),
142
+ });
package/src/cli/tree.ts CHANGED
@@ -15,6 +15,7 @@ import {
15
15
  } from "../tools/lib/command";
16
16
  import { builtinTools, builtinToolVerbs } from "./builtin-tools";
17
17
  import { identityCommand } from "./identity";
18
+ import { inferCommand } from "./infer";
18
19
  import { knowledgeCommand } from "./knowledge";
19
20
  import { ledgerCommand } from "./ledger-command";
20
21
  import { runMigrate } from "./migrate";
@@ -150,6 +151,7 @@ export function cliTree(admin: AdminHandlers): Group {
150
151
  commands: {
151
152
  ...adminCommands(admin),
152
153
  usage: usageCommand,
154
+ infer: inferCommand,
153
155
  actor: identityCommand("actor"),
154
156
  machine: identityCommand("machine"),
155
157
  telos: telosCommand,
@@ -23,6 +23,7 @@
23
23
  import { mkdtemp, rm, writeFile } from "node:fs/promises";
24
24
  import { tmpdir } from "node:os";
25
25
  import { basename, join } from "node:path";
26
+ import { z } from "zod";
26
27
  import {
27
28
  getActiveAgent,
28
29
  isAntigravity,
@@ -151,7 +152,7 @@ interface InferenceOptions {
151
152
  tier?: InferenceTier;
152
153
  maxTokens?: number;
153
154
  timeout?: number;
154
- /** JSON schema for structured output — guarantees valid JSON matching the schema */
155
+ /** JSON schema the reply must match; a reply that doesn't fails with the reason */
155
156
  jsonSchema?: Record<string, unknown>;
156
157
  /** Opaque label identifying the calling handler — appears in debug logs as caller=X */
157
158
  caller?: string;
@@ -167,7 +168,34 @@ interface InferenceResult {
167
168
  usage?: { inputTokens: number; outputTokens: number };
168
169
  }
169
170
 
171
+ function schemaProblem(
172
+ output: string,
173
+ schema: Record<string, unknown>
174
+ ): string | undefined {
175
+ const checked = z.fromJSONSchema(schema).safeParse(parseJsonFromOutput(output));
176
+ if (checked.success) return undefined;
177
+ return checked.error.issues
178
+ .map((issue) => `${issue.path.join(".") || "reply"}: ${issue.message}`)
179
+ .join("; ");
180
+ }
181
+
170
182
  export async function inference(opts: InferenceOptions): Promise<InferenceResult> {
183
+ const result = await routeInference(opts);
184
+ if (!opts.jsonSchema || !result.success || !result.output) return result;
185
+ const problem = schemaProblem(result.output, opts.jsonSchema);
186
+ if (!problem) return result;
187
+ logError(
188
+ "inference",
189
+ `caller=${opts.caller ?? "anonymous"} schema mismatch: ${problem}`
190
+ );
191
+ return {
192
+ ...result,
193
+ success: false,
194
+ error: `the reply does not match the schema: ${problem}`,
195
+ };
196
+ }
197
+
198
+ async function routeInference(opts: InferenceOptions): Promise<InferenceResult> {
171
199
  // Hard kill-switch — set by the test suite to guarantee no real inference
172
200
  // ever fires from tests (no spawn, no API call). Production code never sets it.
173
201
  if (process.env.PAL_INFERENCE_DISABLED === "1") {
@@ -9,7 +9,8 @@ export const HAIKU_5_5_MODEL = "claude-haiku-5-5";
9
9
  export const SONNET_MODEL = "claude-sonnet-5";
10
10
  export const FABLE_MODEL = "claude-fable-5";
11
11
 
12
- export type InferenceTier = "small" | "medium";
12
+ export const INFERENCE_TIERS = ["small", "medium"] as const;
13
+ export type InferenceTier = (typeof INFERENCE_TIERS)[number];
13
14
 
14
15
  export type FixedModelRoute =
15
16
  | "claude-spawn"
@@ -1,5 +1,5 @@
1
1
  import { existsSync, mkdirSync } from "node:fs";
2
- import { homedir, tmpdir } from "node:os";
2
+ import { homedir } from "node:os";
3
3
  import { isAbsolute, relative, resolve } from "node:path";
4
4
 
5
5
  /**
@@ -62,8 +62,12 @@ export function palPkg(): string {
62
62
  * Always resolves to ~/.pal/ regardless of where the package lives.
63
63
  * Power users who want memory/telos versioned in a repo can override via PAL_HOME.
64
64
  */
65
+ function sandboxPalHome(): string {
66
+ return resolve(import.meta.dir, "..", "..", "..", ".test", "pal-home");
67
+ }
68
+
65
69
  function defaultPalHome(): string {
66
- if (process.env.PAL_TEST_SANDBOX) return resolve(tmpdir(), "pal-test-home");
70
+ if (process.env.PAL_TEST_SANDBOX) return sandboxPalHome();
67
71
  return resolve(homedir(), ".pal");
68
72
  }
69
73