@mewbleh/purrx 1.0.8 → 1.0.10

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/bin/purrx.js CHANGED
@@ -173,6 +173,7 @@ async function cmdExec(args) {
173
173
  registry,
174
174
  approval,
175
175
  onChange: () => saveSession(session),
176
+ sessionId: session.id,
176
177
  });
177
178
  } finally {
178
179
  registry.shutdown();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mewbleh/purrx",
3
- "version": "1.0.8",
3
+ "version": "1.0.10",
4
4
  "description": "purrx, a lightweight AI coding agent for your terminal",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
package/src/api/client.js CHANGED
@@ -1,5 +1,11 @@
1
1
  import { API_BASE, CHATGPT_BASE } from "../config.js";
2
2
 
3
+ // Models that use the Responses API reasoning fields. gpt-5 family, o-series,
4
+ // and any *-codex variant are reasoning models.
5
+ function isReasoningModel(model) {
6
+ return /^(gpt-5|o\d|codex)/i.test(model) || /-codex\b/i.test(model);
7
+ }
8
+
3
9
  // Builds the request URL and headers for the resolved auth mode.
4
10
  /**
5
11
  * @param {import("../types.js").AuthInfo} authInfo
@@ -11,8 +17,9 @@ function buildRequest(authInfo) {
11
17
  const headers = {
12
18
  "Content-Type": "application/json",
13
19
  Authorization: `Bearer ${authInfo.accessToken}`,
14
- "OpenAI-Beta": "responses=experimental",
20
+ // Codex backend identifies the client via originator + session headers.
15
21
  originator: "codex_cli_rs",
22
+ "User-Agent": "purrx",
16
23
  };
17
24
  if (authInfo.accountId) {
18
25
  headers["chatgpt-account-id"] = authInfo.accountId;
@@ -40,10 +47,14 @@ function buildRequest(authInfo) {
40
47
  * @param {import("../types.js").HistoryItem[]} req.input
41
48
  * @param {import("../types.js").ToolDefinition[]} [req.tools]
42
49
  * @param {string} [req.instructions]
50
+ * @param {string} [req.sessionId] stable id used for prompt caching
43
51
  * @param {{ onText?: (delta: string) => void, onEvent?: (event: any) => void }} [handlers]
44
52
  * @returns {Promise<any>}
45
53
  */
46
- export async function streamResponse({ authInfo, model, input, tools, instructions }, handlers = {}) {
54
+ export async function streamResponse(
55
+ { authInfo, model, input, tools, instructions, sessionId },
56
+ handlers = {}
57
+ ) {
47
58
  const { url, headers } = buildRequest(authInfo);
48
59
 
49
60
  /** @type {Record<string, any>} */
@@ -51,12 +62,26 @@ export async function streamResponse({ authInfo, model, input, tools, instructio
51
62
  model,
52
63
  input,
53
64
  stream: true,
65
+ // The Codex backend requires store:false; responses are not persisted.
54
66
  store: false,
67
+ // Stable cache key improves latency/cost across turns (matches Codex).
68
+ prompt_cache_key: sessionId || "purrx",
55
69
  };
56
70
  if (instructions) payload.instructions = instructions;
71
+
72
+ // Reasoning models (gpt-5*, o-series, *-codex) require a reasoning block and
73
+ // must request the encrypted reasoning content back since we don't persist
74
+ // responses (store:false). This mirrors the official Codex client and is the
75
+ // shape the ChatGPT/Codex backend expects.
76
+ if (isReasoningModel(model)) {
77
+ payload.reasoning = { effort: "medium", summary: "auto" };
78
+ payload.include = ["reasoning.encrypted_content"];
79
+ }
80
+
57
81
  if (tools && tools.length) {
58
82
  payload.tools = tools;
59
83
  payload.tool_choice = "auto";
84
+ payload.parallel_tool_calls = true;
60
85
  }
61
86
 
62
87
  const resp = await fetch(url, {
package/src/api/models.js CHANGED
@@ -1,9 +1,9 @@
1
- import { API_BASE, CHATGPT_BASE, DEFAULT_MODEL } from "../config.js";
1
+ import { API_BASE, DEFAULT_MODEL } from "../config.js";
2
2
 
3
3
  // Fetches the list of model ids the current account can actually use.
4
4
  // - API-key mode: GET https://api.openai.com/v1/models
5
5
  // - ChatGPT mode: the codex backend does not expose a public models list the
6
- // same way, so we probe a known-good set and fall back to the default.
6
+ // same way, so we report a known-good set.
7
7
  export async function listModels(authInfo) {
8
8
  if (authInfo.mode === "apikey") {
9
9
  return listApiKeyModels(authInfo.apiKey);
@@ -24,18 +24,16 @@ async function listApiKeyModels(apiKey) {
24
24
  // Keep models usable for chat/agent work (gpt*, o*, codex).
25
25
  .filter((/** @type {string} */ id) => /^(gpt|o\d|codex|chatgpt)/i.test(id))
26
26
  .sort();
27
- return ids.length ? ids : fallbackModels();
27
+ return ids.length ? dedupeWithDefault(ids) : fallbackModels();
28
28
  } catch {
29
29
  return fallbackModels();
30
30
  }
31
31
  }
32
32
 
33
- // The ChatGPT/Codex backend exposes models tied to the plan. There is no
34
- // stable public list endpoint, so we report the commonly available Codex
35
- // models. DEFAULT_MODEL is always first.
33
+ // The ChatGPT/Codex backend exposes models tied to the plan. There is no stable
34
+ // public list endpoint, so we report the commonly available Codex models.
36
35
  function listChatGptModels() {
37
- const known = ["gpt-5-codex", "gpt-5", "gpt-5-mini", "o4-mini", "o3"];
38
- return dedupeWithDefault(known);
36
+ return dedupeWithDefault(["gpt-5-codex", "gpt-5", "gpt-5-mini", "o4-mini", "o3"]);
39
37
  }
40
38
 
41
39
  function fallbackModels() {
@@ -47,8 +45,13 @@ function dedupeWithDefault(list) {
47
45
  return [...set];
48
46
  }
49
47
 
50
- // Resolves the model to use: explicit override > config/session > a model the
51
- // account actually has > DEFAULT_MODEL.
48
+ // Resolves the model to use: explicit override > a model the account actually
49
+ // has > DEFAULT_MODEL.
50
+ /**
51
+ * @param {import("../types.js").AuthInfo} authInfo
52
+ * @param {string} [preferred]
53
+ * @returns {Promise<string>}
54
+ */
52
55
  export async function resolveModel(authInfo, preferred) {
53
56
  if (preferred) return preferred;
54
57
  const available = await listModels(authInfo).catch(() => []);
package/src/config.js CHANGED
@@ -13,8 +13,8 @@ export const OAUTH_SCOPE =
13
13
  export const API_BASE = "https://api.openai.com/v1";
14
14
  export const CHATGPT_BASE = "https://chatgpt.com/backend-api/codex";
15
15
 
16
- // Default model. Used as a fallback when discovery is unavailable.
17
- // gpt-5-codex is the default Codex coding model.
16
+ // Default model. gpt-5-codex is the default Codex coding model and works on
17
+ // both API-key and ChatGPT-account auth. Override with PURRX_MODEL.
18
18
  export const DEFAULT_MODEL = process.env.PURRX_MODEL || "gpt-5-codex";
19
19
 
20
20
  // Approximate context window (in tokens) used to decide when to auto-compact
package/src/core/agent.js CHANGED
@@ -45,6 +45,7 @@ Guidelines:
45
45
  * @param {import("../tools/registry.js").ToolRegistry} opts.registry
46
46
  * @param {{requestApproval: (name: string, label: string) => Promise<string>}} [opts.approval]
47
47
  * @param {() => void} [opts.onChange]
48
+ * @param {string} [opts.sessionId] stable id used for prompt caching
48
49
  * @returns {Promise<import("../types.js").HistoryItem[]>}
49
50
  */
50
51
  export async function runTurn({
@@ -56,6 +57,7 @@ export async function runTurn({
56
57
  registry,
57
58
  approval,
58
59
  onChange,
60
+ sessionId,
59
61
  }) {
60
62
  history.push({
61
63
  type: "message",
@@ -84,22 +86,28 @@ export async function runTurn({
84
86
 
85
87
  spinner.start();
86
88
 
87
- const finalResponse = await streamResponse(
88
- {
89
- authInfo,
90
- model,
91
- input: history,
92
- tools,
93
- instructions: systemInstructions(cwd, registry),
94
- },
95
- {
96
- onText: (delta) => {
97
- buffered += delta;
89
+ let finalResponse;
90
+ try {
91
+ finalResponse = await streamResponse(
92
+ {
93
+ authInfo,
94
+ model,
95
+ input: history,
96
+ tools,
97
+ instructions: systemInstructions(cwd, registry),
98
+ sessionId,
98
99
  },
99
- }
100
- );
101
-
102
- spinner.stop();
100
+ {
101
+ onText: (delta) => {
102
+ buffered += delta;
103
+ },
104
+ }
105
+ );
106
+ } finally {
107
+ // Always stop the spinner, even if the request throws, so it never
108
+ // keeps spinning under an error message.
109
+ spinner.stop();
110
+ }
103
111
 
104
112
  if (!finalResponse) {
105
113
  throw new Error("No response received from the model.");
package/src/ui/tui.js CHANGED
@@ -101,6 +101,7 @@ export async function startTui(authInfo, options = {}) {
101
101
  registry,
102
102
  approval: state.approval,
103
103
  onChange: persist,
104
+ sessionId: session.id,
104
105
  });
105
106
  } catch (err) {
106
107
  console.log(box(chalk.red(err.message), { title: "error", borderColor: "red" }));