portable-agent-layer 0.91.8 → 0.93.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.
@@ -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") {
@@ -552,8 +580,10 @@ async function inferenceViaAntigravitySpawn(
552
580
  * --disable-builtin-mcps → no MCP servers in the child (extra safety)
553
581
  * --no-auto-update → prevent CLI self-update from delaying the spawn
554
582
  * --no-color → clean stdout for capture
555
- * --allow-all-tools → REQUIRED for non-interactive mode (without it,
556
- * copilot prompts for tool-use confirmation)
583
+ * --available-tools=none → Copilot has no "no tools" flag; an allowlist
584
+ * naming an unknown tool hides every tool
585
+ * --deny-tool=… → backstop: deny rules win over any allow rule
586
+ * --silent → keeps the tool-filter notices out of stdout
557
587
  *
558
588
  * Copilot has no --system-prompt flag, so system + user + JSON-schema are
559
589
  * concatenated into one prompt delivered on stdin. `-p/--prompt` is deliberately
@@ -567,7 +597,11 @@ export function buildCopilotArgs(_opts: InferenceOptions): string[] {
567
597
  "--disable-builtin-mcps",
568
598
  "--no-auto-update",
569
599
  "--no-color",
570
- "--allow-all-tools",
600
+ "--silent",
601
+ "--available-tools=none",
602
+ "--deny-tool=shell",
603
+ "--deny-tool=write",
604
+ "--deny-tool=url",
571
605
  ];
572
606
  }
573
607
 
@@ -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
 
@@ -168,6 +168,7 @@ export async function runStopHandlers(
168
168
  // any in-hook budget. These spawn detached bun subprocesses that run the
169
169
  // inference and write results to disk; they don't block this hook.
170
170
  await detachSessionIntelligence(transcript, options.sessionId);
171
+ await detachEntityExtraction(transcript, options.sessionId);
171
172
  await detachFailurePrinciple(transcript);
172
173
  detachAgenda(options.sessionId);
173
174
  // Failure auto-graduation is intentionally NOT wired here: every pattern it
@@ -325,6 +326,24 @@ async function detachSessionIntelligence(
325
326
  }
326
327
  }
327
328
 
329
+ async function detachEntityExtraction(
330
+ transcript: string,
331
+ sessionId?: string
332
+ ): Promise<void> {
333
+ if (!sessionId) return;
334
+ try {
335
+ const transcriptPath = await writeTranscriptTmp(transcript);
336
+ const scriptPath = resolve(assets.hooks(), "handlers", "entity-extraction.ts");
337
+ spawnDetachedInference(
338
+ scriptPath,
339
+ ["--run", sessionId, transcriptPath],
340
+ "entity-extraction"
341
+ );
342
+ } catch (err) {
343
+ logError("detachEntityExtraction", err);
344
+ }
345
+ }
346
+
328
347
  /**
329
348
  * If a pending-failure exists, rename it to a unique path (race-free claim),
330
349
  * write the transcript to tmp, spawn the failure-principle handler detached.
@@ -14,6 +14,7 @@ type TokenCaller =
14
14
  | "failure"
15
15
  | "session-name"
16
16
  | "session-intelligence"
17
+ | "entity-extraction"
17
18
  | "relationship"
18
19
  | "self-model"
19
20
  | "agenda-serves"
@@ -29,7 +29,14 @@ import {
29
29
 
30
30
  // --- Public input shape -----------------------------------------------------
31
31
 
32
- export interface PersonInput {
32
+ interface Addressing {
33
+ aliases?: string[] | null;
34
+ relation?: string | null;
35
+ /** The stored file to update, when its slug is not derived from the name or domain. */
36
+ slug?: string | null;
37
+ }
38
+
39
+ export interface PersonInput extends Addressing {
33
40
  name: string;
34
41
  role?: string | null;
35
42
  title?: string | null;
@@ -40,7 +47,7 @@ export interface PersonInput {
40
47
  [extra: string]: unknown;
41
48
  }
42
49
 
43
- export interface CompanyInput {
50
+ export interface CompanyInput extends Addressing {
44
51
  name: string;
45
52
  domain?: string | null;
46
53
  industry?: string | null;
@@ -201,7 +208,17 @@ function newPersonEntity(input: PersonInput, slug: string): Entity {
201
208
  if (Object.keys(socials).length > 0) {
202
209
  fm.socials = Object.entries(socials).map(([k, v]) => `${k}:${v}`);
203
210
  }
204
- return { domain: "People", slug, frontmatter: fm, body: "" };
211
+ return { domain: "People", slug, frontmatter: withAddressing(fm, input), body: "" };
212
+ }
213
+
214
+ /** How the user refers to an entity and stands towards it — both only ever grow. */
215
+ function withAddressing(fm: EntityFrontmatter, input: Addressing): EntityFrontmatter {
216
+ const prior = Array.isArray(fm.aliases) ? (fm.aliases as string[]) : undefined;
217
+ const aliases = mergeStringArray(prior, input.aliases ?? undefined);
218
+ if (aliases.length > 0) fm.aliases = aliases;
219
+ const relation = mergeScalar(fm.relation, input.relation);
220
+ if (relation) fm.relation = relation;
221
+ return fm;
205
222
  }
206
223
 
207
224
  function newCompanyEntity(input: CompanyInput, slug: string): Entity {
@@ -220,7 +237,7 @@ function newCompanyEntity(input: CompanyInput, slug: string): Entity {
220
237
  if (input.industry) fm.industry = input.industry;
221
238
  if (input.mentioned_as) fm.mentioned_as = input.mentioned_as;
222
239
  if (input.sentiment) fm.sentiment = input.sentiment;
223
- return { domain: "Companies", slug, frontmatter: fm, body: "" };
240
+ return { domain: "Companies", slug, frontmatter: withAddressing(fm, input), body: "" };
224
241
  }
225
242
 
226
243
  function mergePerson(prior: Entity, input: PersonInput): Entity {
@@ -248,7 +265,7 @@ function mergePerson(prior: Entity, input: PersonInput): Entity {
248
265
  if (Object.keys(socials).length > 0) {
249
266
  fm.socials = Object.entries(socials).map(([k, v]) => `${k}:${v}`);
250
267
  }
251
- return { ...prior, frontmatter: fm };
268
+ return { ...prior, frontmatter: withAddressing(fm, input) };
252
269
  }
253
270
 
254
271
  function mergeCompany(prior: Entity, input: CompanyInput): Entity {
@@ -260,7 +277,7 @@ function mergeCompany(prior: Entity, input: CompanyInput): Entity {
260
277
  if (input.industry) {
261
278
  fm.tags = mergeStringArray(fm.tags, industryToTopicTags(input.industry));
262
279
  }
263
- return { ...prior, frontmatter: fm };
280
+ return { ...prior, frontmatter: withAddressing(fm, input) };
264
281
  }
265
282
 
266
283
  function upsertPerson(
@@ -268,7 +285,7 @@ function upsertPerson(
268
285
  sourceId: string,
269
286
  rootDir?: string
270
287
  ): UpsertResult {
271
- const slug = slugify(input.name);
288
+ const slug = input.slug || slugify(input.name);
272
289
  if (!slug) throw new Error(`ingest: cannot slugify person name "${input.name}"`);
273
290
  const prior = load("People", slug, rootDir);
274
291
  const created = prior === null;
@@ -294,7 +311,7 @@ function upsertCompany(
294
311
  rootDir?: string
295
312
  ): UpsertResult {
296
313
  const baseKey = input.domain?.trim() ? input.domain : input.name;
297
- const slug = slugify(baseKey);
314
+ const slug = input.slug || slugify(baseKey);
298
315
  if (!slug) throw new Error(`ingest: cannot slugify company "${input.name}"`);
299
316
  const prior = load("Companies", slug, rootDir);
300
317
  const created = prior === null;