@zhushanwen/pi-structured-output 5.1.5 → 5.1.6

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhushanwen/pi-structured-output",
3
- "version": "5.1.5",
3
+ "version": "5.1.6",
4
4
  "description": "Structured output tool for Pi — workflow schema enforcement via pi's parameter layer; interactive mode validates self-reported schemas with Ajv",
5
5
  "type": "module",
6
6
  "main": "index.ts",
package/src/execute.ts CHANGED
@@ -20,12 +20,13 @@
20
20
 
21
21
  import type { ValidateFunction } from "ajv";
22
22
 
23
+ import { isRecord } from "@zhushanwen/pi-ext-guards";
24
+
23
25
  import { getOrCompileValidator } from "./ajv-validator.js";
24
26
  import {
25
27
  CORRECT_USAGE_HINT,
26
28
  echo,
27
29
  hasSchemaKeyword,
28
- isPlainObject,
29
30
  tryParseJson,
30
31
  } from "./schema-guards.js";
31
32
 
@@ -47,7 +48,7 @@ import {
47
48
  * 其 ASP 文案按本判定同源条件化——改动本函数判定逻辑必须同步该副本。
48
49
  */
49
50
  export function isObjectRootSchema(schema: unknown): schema is Record<string, unknown> {
50
- if (!isPlainObject(schema)) return false;
51
+ if (!isRecord(schema)) return false;
51
52
  if (schema.type === "object") return true;
52
53
  if (Array.isArray(schema.type) && schema.type.includes("object")) return true;
53
54
  const OBJECT_ONLY_KEYS = [
@@ -71,7 +72,7 @@ export function isObjectRootSchema(schema: unknown): schema is Record<string, un
71
72
  * 不在 execute 层制造第二道校验。
72
73
  */
73
74
  function unwrapValueField(data: unknown): unknown {
74
- if (isPlainObject(data) && "value" in data) {
75
+ if (isRecord(data) && "value" in data) {
75
76
  return data.value;
76
77
  }
77
78
  return data;
@@ -88,7 +89,7 @@ export function validateAgainstSelfReported(schema: unknown, data: unknown): boo
88
89
  // 1. 互换检测:schema 像数据(对象无 keyword)且 data 像 schema(对象有 keyword)。
89
90
  // 这是最严重的静默腐败路径——若放行,ajv 会把"数据形态的 schema"编译成接受一切,
90
91
  // 真正的 schema(此时在 data 里)被丢弃,校验通过并存入垃圾。
91
- if (isPlainObject(schema) && !hasSchemaKeyword(schema) && isPlainObject(data) && hasSchemaKeyword(data)) {
92
+ if (isRecord(schema) && !hasSchemaKeyword(schema) && isRecord(data) && hasSchemaKeyword(data)) {
92
93
  throw new Error(
93
94
  "Likely swapped: schema looks like data and data looks like a schema. "
94
95
  + CORRECT_USAGE_HINT
@@ -98,7 +99,7 @@ export function validateAgainstSelfReported(schema: unknown, data: unknown): boo
98
99
 
99
100
  // 2. keyword-less schema 拒绝:治静默腐败的根。{} / {a:1} 这类对象会被
100
101
  // ajv strict:false 编译成"接受一切"的 validator,模型把答案塞进 schema 时会静默通过。
101
- if (isPlainObject(schema) && !hasSchemaKeyword(schema)) {
102
+ if (isRecord(schema) && !hasSchemaKeyword(schema)) {
102
103
  throw new Error(
103
104
  "Invalid JSON Schema: schema has no recognized keyword "
104
105
  + "(type/properties/items/enum/...). If you passed the answer value as schema, "
@@ -113,7 +114,7 @@ export function validateAgainstSelfReported(schema: unknown, data: unknown): boo
113
114
  // object|boolean,消除原先的 `as Record<string,unknown>` 不安全 cast。
114
115
  let validate: ValidateFunction;
115
116
  try {
116
- if (isPlainObject(schema) || typeof schema === "boolean") {
117
+ if (isRecord(schema) || typeof schema === "boolean") {
117
118
  validate = getOrCompileValidator(schema);
118
119
  } else {
119
120
  throw new Error(`schema must be a JSON Schema object or boolean, got ${typeof schema}`);
package/src/loop-gate.ts CHANGED
@@ -41,9 +41,9 @@
41
41
  */
42
42
 
43
43
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
44
- import { guardStaleCtx, toErrorMessage } from "@zhushanwen/pi-ext-guards";
44
+ import { guardStaleCtx, isRecord, toErrorMessage } from "@zhushanwen/pi-ext-guards";
45
45
 
46
- import { isPlainObject, isToolExecutionEndEvent } from "./schema-guards.js";
46
+ import { isToolExecutionEndEvent } from "./schema-guards.js";
47
47
  import {
48
48
  extractToolErrorText,
49
49
  SIGNATURE_MAX_CHARS,
@@ -248,10 +248,10 @@ function keysAtPath(args: Record<string, unknown>, path: string): string[] {
248
248
  current = current[index];
249
249
  continue;
250
250
  }
251
- if (!isPlainObject(current) || !(segment in current)) return [];
251
+ if (!isRecord(current) || !(segment in current)) return [];
252
252
  current = current[segment];
253
253
  }
254
- return isPlainObject(current) ? Object.keys(current) : [];
254
+ return isRecord(current) ? Object.keys(current) : [];
255
255
  }
256
256
 
257
257
  /**
@@ -283,7 +283,7 @@ function parseArgsEchoObject(errorText: string, markerIdx: number): Record<strin
283
283
  if (!echoSection) return undefined;
284
284
  try {
285
285
  const parsed: unknown = JSON.parse(echoSection);
286
- return isPlainObject(parsed) ? parsed : undefined;
286
+ return isRecord(parsed) ? parsed : undefined;
287
287
  } catch {
288
288
  return undefined;
289
289
  }
@@ -392,36 +392,9 @@ export class LoopGate {
392
392
  /** terminal 日志的 appendEntry customType(session.jsonl 持久化,不进 LLM 上下文)。 */
393
393
  export const GATE_ENTRY_TYPE = "structured-output:gate";
394
394
 
395
- /**
396
- * setTimeout delay 安全域校验。[同源锚定] @zhushanwen/pi-subagent-workflow 的
397
- * shared/timer-delay.ts assertSafeTimerDelay 本地副本——两包独立 npm 不能直接
398
- * import(isObjectRootSchema 本地副本同例:跨包相对 import 在发布产物里悬空),
399
- * 本包仅此一个 timer 入口,取最小面副本。语义同源:非有限值 / 超 2^31-1 的 delay
400
- * 会被 Node 塌缩为 1ms 立即触发(语义反转:兜底窗口变成立即硬杀),fail-fast 不
401
- * 静默 clamp(clamp 把配置错误变成静默语义漂移)。
402
- */
403
- const MAX_TIMER_DELAY_MS = 2_147_483_647;
404
-
405
395
  /** 每秒毫秒数(teardown 日志 ms→s 换算用,no-magic-numbers)。 */
406
396
  const MS_PER_SECOND = 1000;
407
397
 
408
- export function assertSafeTimerDelay(ms: number, source: string): void {
409
- if (!Number.isFinite(ms)) {
410
- throw new Error(
411
- `[structured-output] ${source} = ${ms} is not a finite number (NaN/±Infinity). `
412
- + "Non-finite delays collapse to 1ms in Node setTimeout and fire immediately. "
413
- + "Recovery: fix the constant/computation feeding this timer and retry.",
414
- );
415
- }
416
- if (ms > MAX_TIMER_DELAY_MS) {
417
- throw new Error(
418
- `[structured-output] ${source} = ${ms} exceeds the Node setTimeout limit `
419
- + `(${MAX_TIMER_DELAY_MS} ms = 2^31-1); larger delays silently collapse to 1ms and fire immediately. `
420
- + `Recovery: clamp the value to <= ${MAX_TIMER_DELAY_MS} and retry.`,
421
- );
422
- }
423
- }
424
-
425
398
  /**
426
399
  * terminal 后 bounded teardown 的兜底硬退窗口(ms)。
427
400
  *
@@ -435,6 +408,10 @@ export function assertSafeTimerDelay(ms: number, source: string): void {
435
408
  * 后正常退出秒级完成),兜底只覆盖「pi 挂死不 settle」的异常态——此时宁可硬退
436
409
  *(父进程 SW 侧走「子进程结束未产出 structured-output」失败路径,stderr 已留原因)
437
410
  * 也不无限烧 token。
411
+ *
412
+ * 安全域:15_000 为字面量常量,处于 Node setTimeout 安全域内(非有限/超 2^31-1
413
+ * 会塌缩为 1ms 立即触发——若未来 delay 来源动态化,需引入安全域校验,参照
414
+ * packages/subagent-core/src/shared/timer-delay.ts 的 assertSafeTimerDelay)。
438
415
  */
439
416
  export const TEARDOWN_FORCE_EXIT_MS = 15_000;
440
417
 
@@ -444,24 +421,10 @@ export const TEARDOWN_FORCE_EXIT_MS = 15_000;
444
421
  */
445
422
  const TEARDOWN_EXIT_CODE = 1;
446
423
 
447
- /** 已武装的兜底硬退 timer:globalThis[Symbol.for] slot 持有(C-ext-06,照 notify-ledger
448
- * 先例)——jiti 路径分裂加载多份模块时裸模块级 let 双实例各持 timer(失效模式良性:
449
- * 至多双 timer 各自 process.exit,进程级幂等);slot 化后单介质同源,幂等再清语义不变。 */
450
- const TEARDOWN_TIMER_SLOT_KEY = Symbol.for("@zhushanwen/pi-structured-output.loopGate.teardownTimer");
451
-
452
- type TeardownTimerSlot = { current: ReturnType<typeof setTimeout> | undefined };
453
-
454
- function getTeardownTimerSlot(): TeardownTimerSlot {
455
- // globalThis 无 symbol 索引签名,但运行时支持 symbol 键——用 Reflect 安全读写
456
- //(notify-ledger getNotifyLedgerSlot 同款)。TeardownTimerSlot 是运行时保证的
457
- // 固定形状(本文件唯一写入点)。
458
- let slot = Reflect.get(globalThis, TEARDOWN_TIMER_SLOT_KEY) as TeardownTimerSlot | undefined;
459
- if (!slot) {
460
- slot = { current: undefined };
461
- Reflect.set(globalThis, TEARDOWN_TIMER_SLOT_KEY, slot);
462
- }
463
- return slot;
464
- }
424
+ // one-shot timer 不属 C-ext-06 §7.5 的「跨 session 存活进程级单例」范畴:
425
+ // terminal 一次性武装、随 process.exit 消亡;jiti 双实例下双 timer 各自
426
+ // process.exit 进程级幂等,模块级 let 即可。
427
+ let teardownTimer: ReturnType<typeof setTimeout> | undefined;
465
428
 
466
429
  /**
467
430
  * 武装 terminal 后的 bounded teardown 兜底:TEARDOWN_FORCE_EXIT_MS 后进程仍未退出
@@ -475,14 +438,12 @@ function getTeardownTimerSlot(): TeardownTimerSlot {
475
438
  * 能做的最大硬杀就是对自身 process.exit。故采用任务预设的兜底形态:abort+shutdown
476
439
  * 优雅退出为主,定时 process.exit 兜底。
477
440
  *
478
- * timer 卫生:assertSafeTimerDelay 包裹(防未来常量演化溢出塌缩为 1ms 立即硬杀)+
441
+ * timer 卫生:delay 为字面量常量、处 setTimeout 安全域(见 TEARDOWN_FORCE_EXIT_MS 注释)+
479
442
  * unref(不阻止 pi 在窗口内自然退出;自然退出时本 timer 随进程消亡不再开火)+
480
443
  * terminal 路径幂等 clearTimeout(重复武装不叠加多个兜底 timer)。
481
444
  */
482
445
  export function armForceExitTeardown(): void {
483
- const slot = getTeardownTimerSlot();
484
- if (slot.current !== undefined) clearTimeout(slot.current);
485
- assertSafeTimerDelay(TEARDOWN_FORCE_EXIT_MS, "structured-output gate teardown");
446
+ if (teardownTimer !== undefined) clearTimeout(teardownTimer);
486
447
  const timer = setTimeout(() => {
487
448
  process.stderr.write(
488
449
  `[structured-output gate] graceful shutdown did not complete within ${TEARDOWN_FORCE_EXIT_MS / MS_PER_SECOND}s; `
@@ -492,7 +453,7 @@ export function armForceExitTeardown(): void {
492
453
  }, TEARDOWN_FORCE_EXIT_MS);
493
454
  // unref:窗口内 pi 自然退出时不被本 timer 拖住(timer 随进程消亡,不再开火)
494
455
  timer.unref();
495
- slot.current = timer;
456
+ teardownTimer = timer;
496
457
  }
497
458
 
498
459
  /**
@@ -523,7 +484,7 @@ function writeTerminatedLog(pi: PiAPI, gate: LoopGate): void {
523
484
  } catch (err) {
524
485
  // appendEntry 失败不阻断 shutdown——stderr 通道已落,此处补诊断(同 cache-probe 惯例)
525
486
  process.stderr.write(
526
- `[structured-output gate] appendEntry failed: ${err instanceof Error ? err.message : String(err)}\n`,
487
+ `[structured-output gate] appendEntry failed: ${toErrorMessage(err)}\n`,
527
488
  );
528
489
  }
529
490
  }
@@ -10,6 +10,8 @@
10
10
  * keyword-less schema({} / {a:1} 这种会被 ajv 静默放行)。
11
11
  */
12
12
 
13
+ import { isRecord } from "@zhushanwen/pi-ext-guards";
14
+
13
15
  /** JSON Schema draft-07 识别 keyword。只要 schema 含其一就认为是"真 schema"。 */
14
16
  export const SCHEMA_KEYWORDS = [
15
17
  // 核心类型
@@ -35,9 +37,12 @@ export const SCHEMA_KEYWORDS = [
35
37
  "minLength", "maxLength", "pattern", "format",
36
38
  ] as const;
37
39
 
38
- export function isPlainObject(value: unknown): value is Record<string, unknown> {
39
- return typeof value === "object" && value !== null && !Array.isArray(value);
40
- }
40
+ /**
41
+ * @deprecated 深路径公开名兼容别名(ext-simplify-18 D2);新代码直接用 ext-guards isRecord。
42
+ * 本包独立发布、无 exports 字段且 files 含 src/(深路径对外可达),公开名直接删除是
43
+ * breaking——保留一个发布周期的 re-export,删除时点登记在 ext-simplify index 18 号行。
44
+ */
45
+ export const isPlainObject = isRecord;
41
46
 
42
47
  export function hasSchemaKeyword(obj: Record<string, unknown>): boolean {
43
48
  return SCHEMA_KEYWORDS.some((keyword) => keyword in obj);
@@ -79,7 +84,7 @@ export function tryParseJson(raw: unknown): unknown {
79
84
  * 避免在守卫块外直接用 unknown。非合法形态抛清晰错误。
80
85
  */
81
86
  export function assertJsonSchemaRoot(value: unknown): asserts value is Record<string, unknown> | boolean {
82
- if (!(isPlainObject(value) || typeof value === "boolean")) {
87
+ if (!(isRecord(value) || typeof value === "boolean")) {
83
88
  throw new Error(`authoritative schema must be a JSON Schema object or boolean, got ${typeof value}`);
84
89
  }
85
90
  }
@@ -18,6 +18,7 @@
18
18
  * 两变体的描述文本均被 prompt-quality.test.ts 文本断言锁定。
19
19
  */
20
20
 
21
+ import { isRecord } from "@zhushanwen/pi-ext-guards";
21
22
  import { Type } from "typebox";
22
23
 
23
24
  import { executeStructuredOutput, isObjectRootSchema } from "./execute.js";
@@ -25,7 +26,6 @@ import {
25
26
  assertJsonSchemaRoot,
26
27
  echo,
27
28
  hasSchemaKeyword,
28
- isPlainObject,
29
29
  tryParseJson,
30
30
  } from "./schema-guards.js";
31
31
 
@@ -93,7 +93,7 @@ export function createWorkflowToolDefinition(envSchema: string) {
93
93
  );
94
94
  }
95
95
 
96
- if (isPlainObject(schema) && !hasSchemaKeyword(schema)) {
96
+ if (isRecord(schema) && !hasSchemaKeyword(schema)) {
97
97
  // ERR-3 上移:keyword-less 对象会被编译成 accept-all,workflow 声明的约束静默失效。
98
98
  throw new Error(
99
99
  "Authoritative schema (PI_WORKFLOW_SCHEMA) has no recognized keyword. "
@@ -9,6 +9,7 @@
9
9
  */
10
10
 
11
11
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
12
+ import { toErrorMessage } from "@zhushanwen/pi-ext-guards";
12
13
 
13
14
  // 截断原语与错误块预算来自 text-primitives(共享叶节点,导出复用勿复制)。原
14
15
  // 「反向依赖(环)」已破除:本模块不再 import loop-gate,依赖图单向
@@ -95,7 +96,7 @@ export const HOOK_ENTRY_TYPE = "structured-output:hook";
95
96
  * 下一个正常收尾的轮仍会重试 steer,不产生静默哑火。
96
97
  */
97
98
  function writeSteerFailedLog(pi: PiAPI, hookRetryCount: number, err: unknown): void {
98
- const message = err instanceof Error ? err.message : String(err);
99
+ const message = toErrorMessage(err);
99
100
  process.stderr.write(
100
101
  `[structured-output hook] steer send failed (retry budget NOT consumed, will retry at next turn end): ${message}\n`,
101
102
  );
@@ -110,7 +111,7 @@ function writeSteerFailedLog(pi: PiAPI, hookRetryCount: number, err: unknown): v
110
111
  } catch (appendErr) {
111
112
  // appendEntry 失败不阻断 hook——stderr 通道已落,此处补诊断(同 cache-probe 惯例)
112
113
  process.stderr.write(
113
- `[structured-output hook] appendEntry failed: ${appendErr instanceof Error ? appendErr.message : String(appendErr)}\n`,
114
+ `[structured-output hook] appendEntry failed: ${toErrorMessage(appendErr)}\n`,
114
115
  );
115
116
  }
116
117
  }