pi-oracle 0.3.1 → 0.3.3

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/CHANGELOG.md CHANGED
@@ -1,5 +1,27 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.3 - 2026-04-11
4
+
5
+ ### Added
6
+ - `oracle_submit` now accepts canonical preset ids plus matching human-readable preset labels/common hyphen-space variants and normalizes them back to the canonical preset id at submit time
7
+ - lock sweeping now gives in-flight `.tmp-*` lock/state directories a dedicated grace window so concurrent sweep does not delete another process's atomic publish
8
+
9
+ ### Changed
10
+ - oracle submit metadata/docs now point preset discovery at the canonical registry/README while keeping execute-time normalization for flexible caller input
11
+
12
+ ### Fixed
13
+ - closed a concurrent stale-lock sweep race that could reclaim another process's in-flight lock publish before metadata landed
14
+ - oracle sanity coverage now verifies preset alias validation/normalization and the `.tmp-*` grace window behavior end-to-end
15
+
16
+ ## 0.3.2 - 2026-04-08
17
+
18
+ ### Changed
19
+ - README now lists the available oracle preset ids directly, so users can choose `defaults.preset` values without having to inspect source files
20
+
21
+ ### Fixed
22
+ - closed a README usability gap where preset-based configuration was documented without actually enumerating the shipped preset ids
23
+ - oracle sanity coverage now verifies that the README lists every preset from the canonical `ORACLE_SUBMIT_PRESETS` registry
24
+
3
25
  ## 0.3.1 - 2026-04-08
4
26
 
5
27
  ### Changed
package/README.md CHANGED
@@ -112,6 +112,21 @@ Notes:
112
112
  - If the packaged default is fine, you can omit `defaults.preset` entirely.
113
113
  - You usually do not need to set browser paths unless auto-detection fails.
114
114
 
115
+ ## Available presets
116
+
117
+ | Preset id | Description |
118
+ | --- | --- |
119
+ | `pro_standard` | Pro - Standard |
120
+ | `pro_extended` | Pro - Extended |
121
+ | `thinking_light` | Thinking - Light |
122
+ | `thinking_standard` | Thinking - Standard |
123
+ | `thinking_extended` | Thinking - Extended |
124
+ | `thinking_heavy` | Thinking - Heavy |
125
+ | `instant` | Instant |
126
+ | `instant_auto_switch` | Instant - Auto-switch to Thinking Enabled |
127
+
128
+ `oracle_submit` accepts either the canonical preset id or the matching human-readable preset label; common space/hyphen variants are normalized automatically at submit time. Keep `defaults.preset` in config on the canonical preset id.
129
+
115
130
  Other useful settings:
116
131
  - `browser.runMode`
117
132
  - `browser.args`
@@ -133,7 +133,7 @@ The authenticated seed profile remains the source of truth for future oracle run
133
133
 
134
134
  ### `oracle_submit`
135
135
 
136
- Agent-facing submissions use **`preset`**; the canonical registry is `ORACLE_SUBMIT_PRESETS` in `extensions/oracle/lib/config.ts`. **`preset` is the only model-selection parameter** on `oracle_submit`. There are no `modelFamily`, `effort`, or `autoSwitchToThinking` fields.
136
+ Agent-facing submissions use **`preset`**; the canonical registry is `ORACLE_SUBMIT_PRESETS` in `extensions/oracle/lib/config.ts`. **`preset` is the only model-selection parameter** on `oracle_submit`. There are no `modelFamily`, `effort`, or `autoSwitchToThinking` fields. Submit-time inputs accept canonical preset ids plus matching human-readable labels/common hyphen-space variants, and the tool normalizes them back to the canonical id before persisting job state.
137
137
 
138
138
  1. resolve the preset (submit-time or config default) into an execution snapshot
139
139
  2. resolve optional `followUpJobId` into a prior `chatUrl` and `conversationId`
@@ -29,6 +29,117 @@ export type OracleSubmitPresetId = keyof typeof ORACLE_SUBMIT_PRESETS;
29
29
 
30
30
  export type OracleSubmitPreset = typeof ORACLE_SUBMIT_PRESETS[OracleSubmitPresetId];
31
31
 
32
+ export const ORACLE_SUBMIT_PRESET_IDS = Object.freeze(Object.keys(ORACLE_SUBMIT_PRESETS) as OracleSubmitPresetId[]);
33
+
34
+ function normalizeOracleSubmitPresetLookupKey(value: string): string {
35
+ return value
36
+ .trim()
37
+ .toLowerCase()
38
+ .replace(/[_-]+/g, " ")
39
+ .replace(/[^\p{L}\p{N}\s]/gu, " ")
40
+ .replace(/\s+/g, " ");
41
+ }
42
+
43
+ function splitOracleSubmitPresetWords(value: string): string[] {
44
+ return value
45
+ .trim()
46
+ .replace(/[_-]+/g, " ")
47
+ .replace(/[^\p{L}\p{N}\s]/gu, " ")
48
+ .split(/\s+/)
49
+ .filter(Boolean);
50
+ }
51
+
52
+ function lowercaseWords(words: readonly string[]): string[] {
53
+ return words.map((word) => word.toLowerCase());
54
+ }
55
+
56
+ function titleCaseWords(words: readonly string[]): string[] {
57
+ return words.map((word) => (word ? `${word[0]?.toUpperCase() ?? ""}${word.slice(1)}` : word));
58
+ }
59
+
60
+ function buildOracleSubmitPresetSeparatorVariants(words: readonly string[]): string[] {
61
+ const normalizedWords = words.map((word) => word.trim()).filter(Boolean);
62
+ if (normalizedWords.length === 0) return [];
63
+
64
+ const variants = new Set<string>();
65
+ const build = (index: number, current: string): void => {
66
+ if (index >= normalizedWords.length) {
67
+ variants.add(current);
68
+ return;
69
+ }
70
+ for (const separator of [" ", "-"] as const) {
71
+ build(index + 1, `${current}${separator}${normalizedWords[index]}`);
72
+ }
73
+ };
74
+
75
+ build(1, normalizedWords[0]!);
76
+ return [...variants];
77
+ }
78
+
79
+ function buildOracleSubmitPresetJoinVariants(words: readonly string[]): string[] {
80
+ const normalizedWords = words.map((word) => word.trim()).filter(Boolean);
81
+ if (normalizedWords.length === 0) return [];
82
+
83
+ const lowercase = lowercaseWords(normalizedWords);
84
+ const titleWords = titleCaseWords(lowercase);
85
+ return [
86
+ ...buildOracleSubmitPresetSeparatorVariants(normalizedWords),
87
+ ...buildOracleSubmitPresetSeparatorVariants(lowercase),
88
+ ...buildOracleSubmitPresetSeparatorVariants(titleWords),
89
+ ];
90
+ }
91
+
92
+ function buildOracleSubmitPresetAliases(id: OracleSubmitPresetId, preset: OracleSubmitPreset): string[] {
93
+ const idWords = splitOracleSubmitPresetWords(id);
94
+ const labelWords = splitOracleSubmitPresetWords(preset.label);
95
+ return [
96
+ id,
97
+ ...buildOracleSubmitPresetJoinVariants(idWords),
98
+ preset.label,
99
+ preset.label.toLowerCase(),
100
+ ...buildOracleSubmitPresetJoinVariants(labelWords),
101
+ ].filter(Boolean);
102
+ }
103
+
104
+ function buildOracleSubmitPresetLookupArtifacts(): {
105
+ acceptedInputs: readonly string[];
106
+ lookup: ReadonlyMap<string, OracleSubmitPresetId>;
107
+ } {
108
+ const lookup = new Map<string, OracleSubmitPresetId>();
109
+ const aliases = new Set<string>();
110
+
111
+ for (const [id, preset] of Object.entries(ORACLE_SUBMIT_PRESETS) as [OracleSubmitPresetId, OracleSubmitPreset][]) {
112
+ for (const alias of buildOracleSubmitPresetAliases(id, preset)) {
113
+ const normalized = normalizeOracleSubmitPresetLookupKey(alias);
114
+ if (!normalized) continue;
115
+ const existing = lookup.get(normalized);
116
+ if (existing && existing !== id) {
117
+ throw new Error(`Conflicting oracle_submit preset alias: ${alias} matches both ${existing} and ${id}`);
118
+ }
119
+ lookup.set(normalized, id);
120
+ if (alias !== id) aliases.add(alias);
121
+ }
122
+ }
123
+
124
+ return {
125
+ acceptedInputs: Object.freeze([...ORACLE_SUBMIT_PRESET_IDS, ...[...aliases].sort((left, right) => left.localeCompare(right))]),
126
+ lookup,
127
+ };
128
+ }
129
+
130
+ const ORACLE_SUBMIT_PRESET_LOOKUP_ARTIFACTS = buildOracleSubmitPresetLookupArtifacts();
131
+
132
+ export const ORACLE_SUBMIT_PRESET_ACCEPTED_INPUTS = ORACLE_SUBMIT_PRESET_LOOKUP_ARTIFACTS.acceptedInputs;
133
+
134
+ export function coerceOracleSubmitPresetId(value: string): OracleSubmitPresetId {
135
+ const normalized = normalizeOracleSubmitPresetLookupKey(value);
136
+ const presetId = ORACLE_SUBMIT_PRESET_LOOKUP_ARTIFACTS.lookup.get(normalized);
137
+ if (presetId) return presetId;
138
+ throw new Error(
139
+ `Unknown oracle_submit preset: ${value}. Use one of the canonical ids (${ORACLE_SUBMIT_PRESET_IDS.join(", ")}) or a matching preset label.`,
140
+ );
141
+ }
142
+
32
143
  export function getOracleSubmitPresetById(id: OracleSubmitPresetId): OracleSubmitPreset {
33
144
  const found = ORACLE_SUBMIT_PRESETS[id];
34
145
  if (!found) {
@@ -336,7 +447,7 @@ function normalizeLegacyBrowserConfig(root: Record<string, unknown>): Record<str
336
447
  return root;
337
448
  }
338
449
 
339
- const PRESET_IDS = Object.keys(ORACLE_SUBMIT_PRESETS) as unknown as readonly OracleSubmitPresetId[];
450
+ const PRESET_IDS = ORACLE_SUBMIT_PRESET_IDS;
340
451
 
341
452
  function validateOracleConfig(value: unknown): OracleConfig {
342
453
  const root = normalizeLegacyBrowserConfig(expectObject(value, "root"));
@@ -12,6 +12,8 @@ const LEASES_DIR = join(ORACLE_STATE_DIR, "leases");
12
12
  const DEFAULT_WAIT_MS = 30_000;
13
13
  const POLL_MS = 200;
14
14
  export const ORACLE_METADATA_WRITE_GRACE_MS = 1_000;
15
+ /** Incomplete `.tmp-*` dirs are in-flight atomic creates; a 1s grace is too short under multi-process sweep + slow FS. */
16
+ export const ORACLE_TMP_STATE_DIR_GRACE_MS = 60_000;
15
17
 
16
18
  export interface OracleLockHandle {
17
19
  path: string;
@@ -90,7 +92,8 @@ function isIncompleteStateDirStale(path: string, now = Date.now()): boolean {
90
92
  try {
91
93
  const stats = statSync(path);
92
94
  const baselineMs = Math.max(stats.mtimeMs, stats.ctimeMs);
93
- return now - baselineMs >= ORACLE_METADATA_WRITE_GRACE_MS;
95
+ const graceMs = basename(path).startsWith(".tmp-") ? ORACLE_TMP_STATE_DIR_GRACE_MS : ORACLE_METADATA_WRITE_GRACE_MS;
96
+ return now - baselineMs >= graceMs;
94
97
  } catch {
95
98
  return false;
96
99
  }
@@ -138,13 +141,13 @@ async function maybeReclaimStaleLock(path: string, now = Date.now()): Promise<bo
138
141
  return true;
139
142
  }
140
143
 
141
- export async function sweepStaleLocks(): Promise<string[]> {
144
+ export async function sweepStaleLocks(now = Date.now()): Promise<string[]> {
142
145
  const dir = getLocksDir();
143
146
  const removed: string[] = [];
144
147
 
145
148
  for (const name of readdirSync(dir)) {
146
149
  const path = join(dir, name);
147
- if (await maybeReclaimStaleLock(path)) {
150
+ if (await maybeReclaimStaleLock(path, now)) {
148
151
  removed.push(path);
149
152
  }
150
153
  }
@@ -1,3 +1,8 @@
1
+ // Purpose: Register oracle extension tools and implement submit/read/cancel behavior.
2
+ // Responsibilities: Validate tool parameters, create archives, enqueue or dispatch jobs, and surface job state.
3
+ // Scope: Tool-facing orchestration only; durable job storage, locks, runtime leases, and config live in sibling modules.
4
+ // Usage: Imported by the oracle extension entrypoint and sanity tests to register tools against the pi API.
5
+ // Invariants/Assumptions: The pi runtime validates TypeBox schemas before execute, while execute owns semantic normalization.
1
6
  import { randomUUID } from "node:crypto";
2
7
  import { lstat, mkdtemp, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
3
8
  import { tmpdir } from "node:os";
@@ -6,10 +11,9 @@ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
6
11
  import { Type } from "@sinclair/typebox";
7
12
  import { isLockTimeoutError, withGlobalReconcileLock, withLock } from "./locks.js";
8
13
  import {
14
+ coerceOracleSubmitPresetId,
9
15
  loadOracleConfig,
10
- ORACLE_SUBMIT_PRESETS,
11
16
  resolveOracleSubmitPreset,
12
- type OracleSubmitPresetId,
13
17
  } from "./config.js";
14
18
  import {
15
19
  appendCleanupWarnings,
@@ -48,10 +52,6 @@ import {
48
52
  tryAcquireRuntimeLease,
49
53
  } from "./runtime.js";
50
54
 
51
- function stringEnum(values: readonly string[], description: string) {
52
- return Type.Union(values.map((value) => Type.Literal(value)), { description });
53
- }
54
-
55
55
  const ORACLE_SUBMIT_PARAMS = Type.Object({
56
56
  prompt: Type.String({ description: "Prompt text to send to ChatGPT web." }),
57
57
  files: Type.Array(Type.String({ description: "Project-relative file or directory path to include in the archive." }), {
@@ -59,10 +59,10 @@ const ORACLE_SUBMIT_PARAMS = Type.Object({
59
59
  minItems: 1,
60
60
  }),
61
61
  preset: Type.Optional(
62
- stringEnum(
63
- [...Object.keys(ORACLE_SUBMIT_PRESETS)] as const,
64
- "ChatGPT model preset. Omit to use the configured default preset.",
65
- ),
62
+ Type.String({
63
+ description:
64
+ "ChatGPT model preset. Omit to use the configured default preset. Canonical ids are preferred; matching human-readable preset labels and common hyphen/space variants are normalized automatically.",
65
+ }),
66
66
  ),
67
67
  followUpJobId: Type.Optional(Type.String({ description: "Earlier oracle job id whose chat thread should be continued." })),
68
68
  });
@@ -579,7 +579,7 @@ export function registerOracleTools(pi: ExtensionAPI, workerPath: string): void
579
579
  label: "Oracle Submit",
580
580
  description:
581
581
  "Dispatch a background ChatGPT web oracle job after gathering context. Always pass a prompt and exact project-relative archive inputs. " +
582
- "Optional ChatGPT model: set parameter `preset`, or omit it for configured defaults (see `preset` field for allowed ids).",
582
+ "Optional ChatGPT model: set parameter `preset`, or omit it for configured defaults; canonical preset ids are listed in the README and ORACLE_SUBMIT_PRESETS registry, and matching labels are normalized at submit time.",
583
583
  promptSnippet: "Dispatch a background ChatGPT web oracle job after gathering repo context.",
584
584
  promptGuidelines: [
585
585
  "Gather context before calling oracle_submit.",
@@ -591,7 +591,7 @@ export function registerOracleTools(pi: ExtensionAPI, workerPath: string): void
591
591
  "If oracle_submit itself fails because the local archive still exceeds the upload limit after default exclusions and automatic generic generated-output-dir pruning, or for any other submit-time error, stop and report the error instead of retrying automatically.",
592
592
  "If oracle_submit returns a queued job instead of an immediately dispatched one, treat that as success and stop exactly the same way.",
593
593
  "Stop after dispatching oracle_submit; do not continue the task while the oracle job is running.",
594
- "Use `preset` as the only model-selection parameter on oracle_submit. Allowed values come from the tool schema enum. Omit preset to use the configured default.",
594
+ "Use `preset` as the only model-selection parameter on oracle_submit. Canonical ids are preferred, and matching human-readable preset labels are normalized automatically. Omit preset to use the configured default.",
595
595
  ],
596
596
  parameters: ORACLE_SUBMIT_PARAMS,
597
597
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
@@ -599,7 +599,7 @@ export function registerOracleTools(pi: ExtensionAPI, workerPath: string): void
599
599
  const originSessionFile = requirePersistedSessionFile(getSessionFile(ctx), "submit oracle jobs");
600
600
  const projectId = getProjectId(ctx.cwd);
601
601
  const sessionId = getSessionId(originSessionFile, projectId);
602
- const presetId = (params.preset as OracleSubmitPresetId | undefined) ?? config.defaults.preset;
602
+ const presetId = typeof params.preset === "string" ? coerceOracleSubmitPresetId(params.preset) : config.defaults.preset;
603
603
  const selection = resolveOracleSubmitPreset(presetId);
604
604
  const followUp = resolveFollowUp(params.followUpJobId, ctx.cwd);
605
605
  try {
@@ -1,4 +1,5 @@
1
1
  export const ORACLE_METADATA_WRITE_GRACE_MS: number;
2
+ export const ORACLE_TMP_STATE_DIR_GRACE_MS: number;
2
3
 
3
4
  export function acquireLock(
4
5
  stateDir: string,
@@ -6,6 +6,7 @@ import { basename, join } from "node:path";
6
6
  const DEFAULT_WAIT_MS = 30_000;
7
7
  const POLL_MS = 200;
8
8
  export const ORACLE_METADATA_WRITE_GRACE_MS = 1_000;
9
+ export const ORACLE_TMP_STATE_DIR_GRACE_MS = 60_000;
9
10
 
10
11
  async function sleep(ms) {
11
12
  await new Promise((resolve) => setTimeout(resolve, ms));
@@ -76,7 +77,8 @@ function isIncompleteStateDirStale(path, now = Date.now()) {
76
77
  try {
77
78
  const stats = statSync(path);
78
79
  const baselineMs = Math.max(stats.mtimeMs, stats.ctimeMs);
79
- return now - baselineMs >= ORACLE_METADATA_WRITE_GRACE_MS;
80
+ const graceMs = basename(path).startsWith(".tmp-") ? ORACLE_TMP_STATE_DIR_GRACE_MS : ORACLE_METADATA_WRITE_GRACE_MS;
81
+ return now - baselineMs >= graceMs;
80
82
  } catch {
81
83
  return false;
82
84
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-oracle",
3
- "version": "0.3.1",
3
+ "version": "0.3.3",
4
4
  "description": "ChatGPT web-oracle extension for pi with isolated browser auth, async jobs, and project-context archives.",
5
5
  "private": false,
6
6
  "license": "MIT",
package/prompts/oracle.md CHANGED
@@ -14,7 +14,8 @@ Required workflow:
14
14
  6. Stop immediately after dispatching the oracle job.
15
15
 
16
16
  Oracle model (`oracle_submit`):
17
- - To choose a specific ChatGPT model, pass **`preset`** with one of the allowed ids from the tool schema enum / canonical preset registry.
17
+ - To choose a specific ChatGPT model, pass **`preset`** with one of the allowed ids from the canonical preset registry.
18
+ - Matching human-readable preset labels and common hyphen/space variants are also accepted and normalized automatically, but prefer canonical ids when readily available.
18
19
  - **Or** omit **`preset`** entirely to use the configured default model (from oracle config).
19
20
  - **`preset`** is the only model-selection parameter on `oracle_submit`. Do not pass `modelFamily`, `effort`, or `autoSwitchToThinking`.
20
21
  - If unsure which preset fits the task, ask the user.
@@ -27,7 +28,7 @@ Rules:
27
28
  - If the request depends on git state or pending changes (for example code review, ship readiness, or release approval), create a tracked diff bundle file inside the repo (for example under `.pi/`) containing `git status` plus `git diff` output, include that file in the archive, and tell the oracle to use it because the `.git` directory is not included in oracle exports.
28
29
  - When `files=["."]` and the post-exclusion archive is still too large, submit automatically prunes the largest nested directories matching generic generated-output names like `build/`, `dist/`, `out/`, `coverage/`, and `tmp/` outside obvious source roots like `src/` and `lib/` until the archive fits or no candidate remains. Successful submissions report what was pruned.
29
30
  - If a submitted oracle job later fails because upload is rejected, retry with a smaller archive in this order: (1) remove the largest obviously irrelevant/generated content, (2) if still too large, include modified files plus adjacent files plus directly relevant subtrees, (3) if still too large, explain the cut or ask the user.
30
- - Prefer the configured default (omit **`preset`**) unless the task clearly needs a different model; then choose a **`preset`** id from the tool schema enum.
31
+ - Prefer the configured default (omit **`preset`**) unless the task clearly needs a different model; then choose a canonical **`preset`** id.
31
32
  - If `oracle_submit` itself fails because the local archive still exceeds the upload limit after default exclusions and automatic generic generated-output-dir pruning, or for any other submit-time error, stop and report the error. Do not retry automatically.
32
33
  - If `oracle_submit` returns a queued job instead of an immediately dispatched one, treat that as success and end your turn exactly the same way.
33
34
  - After oracle_submit returns, end your turn. Do not keep working while the oracle runs.