@giovannijecha/jecode 0.2.2 → 0.2.4

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/dist/accounts.js CHANGED
@@ -11,8 +11,18 @@ export function openAICodexAccount() {
11
11
  return account === undefined ? undefined : { ...account };
12
12
  }
13
13
  export function accountValues() {
14
- const account = store().accounts["openai-codex"];
15
- return account === undefined ? [] : [account.accessToken, account.refreshToken];
14
+ // Keep both snapshots. Another Jecode process may rotate the refresh token
15
+ // after this process populated its account cache; shell-output redaction
16
+ // must recognize the newly persisted values without forgetting the old ones.
17
+ const values = new Set();
18
+ for (const source of [store(), readStore(accountsPath())]) {
19
+ const account = source.accounts["openai-codex"];
20
+ if (account === undefined)
21
+ continue;
22
+ values.add(account.accessToken);
23
+ values.add(account.refreshToken);
24
+ }
25
+ return [...values];
16
26
  }
17
27
  export function accountsPath() {
18
28
  return userDataPath("accounts.json");
@@ -37,6 +37,7 @@ export async function runTurn(history, options, events, signal) {
37
37
  if (calls.length > MAX_TOOL_CALLS_PER_STEP) {
38
38
  throw new Error(`provider returned ${calls.length} tool calls in one step (maximum ${MAX_TOOL_CALLS_PER_STEP})`);
39
39
  }
40
+ assertToolCallIds(calls);
40
41
  history.push(assistant);
41
42
  if (calls.length === 0) {
42
43
  if (assistant.usage !== undefined)
@@ -95,6 +96,17 @@ export async function runTurn(history, options, events, signal) {
95
96
  }
96
97
  throw new Error(`gave up after ${options.maxSteps} steps without finishing (raise --max-steps)`);
97
98
  }
99
+ function assertToolCallIds(calls) {
100
+ const seen = new Set();
101
+ for (const call of calls) {
102
+ if (call.id.trim() === "")
103
+ throw new Error("provider returned a tool call without an id");
104
+ if (seen.has(call.id)) {
105
+ throw new Error("provider returned duplicate tool call ids in one step");
106
+ }
107
+ seen.add(call.id);
108
+ }
109
+ }
98
110
  async function settle(call, options, events, signal, preview) {
99
111
  const tool = findTool(options.tools, call.name);
100
112
  if (tool === undefined) {
@@ -31,8 +31,20 @@ export function credentialSource(name) {
31
31
  /** Values that must never survive in shell output, regardless of their source. */
32
32
  export function credentialValues() {
33
33
  const stored = fromDisk();
34
- const names = new Set([...held.keys(), ...Object.keys(stored)]);
35
- const values = new Set([...held.values(), ...Object.values(stored)]);
34
+ // A second process can replace the saved store after this session cached it.
35
+ // Redact both snapshots so neither a stale nor a newly persisted key can
36
+ // cross the shell boundary.
37
+ const current = readSavedStore();
38
+ const names = new Set([
39
+ ...held.keys(),
40
+ ...Object.keys(stored),
41
+ ...Object.keys(current),
42
+ ]);
43
+ const values = new Set([
44
+ ...held.values(),
45
+ ...Object.values(stored),
46
+ ...Object.values(current),
47
+ ]);
36
48
  for (const name of names) {
37
49
  const environment = use(process.env[name]);
38
50
  if (environment !== undefined)
@@ -94,10 +106,13 @@ export function reload() {
94
106
  function fromDisk() {
95
107
  if (saved !== undefined)
96
108
  return saved;
109
+ saved = readSavedStore();
110
+ return saved;
111
+ }
112
+ function readSavedStore() {
97
113
  const current = readStore(storePath());
98
114
  const legacy = legacyUserDataPath("credentials.json");
99
- saved = current ?? (legacy === undefined ? undefined : readStore(legacy)) ?? {};
100
- return saved;
115
+ return current ?? (legacy === undefined ? undefined : readStore(legacy)) ?? {};
101
116
  }
102
117
  function readStore(file) {
103
118
  try {
package/dist/prompt.js CHANGED
@@ -2,8 +2,6 @@
2
2
  // gets rewritten far more often than the loop that carries it.
3
3
  export function systemPrompt(config) {
4
4
  return [
5
- "You are jecode, a coding agent working in a terminal alongside the user.",
6
- "",
7
5
  `Workspace root: ${config.root}`,
8
6
  `Platform: ${process.platform}`,
9
7
  "",
@@ -8,6 +8,7 @@ export async function assembleOllama(events, onStream) {
8
8
  const calls = new Map();
9
9
  let toolArgumentChars = 0;
10
10
  let content = "";
11
+ let reasoning = "";
11
12
  let finishReason;
12
13
  let usage;
13
14
  for await (const raw of events) {
@@ -28,9 +29,12 @@ export async function assembleOllama(events, onStream) {
28
29
  continue;
29
30
  // Reasoning has no standardized field name across the models Ollama
30
31
  // serves, so both spellings in circulation are accepted.
31
- const reasoning = delta.reasoning ?? delta.reasoning_content;
32
- if (typeof reasoning === "string" && reasoning !== "") {
33
- onStream?.({ kind: "thinking", text: reasoning });
32
+ const reasoningDelta = delta.reasoning ?? delta.reasoning_content;
33
+ if (typeof reasoningDelta === "string" && reasoningDelta !== "") {
34
+ // Keep the authoritative copy as well as the display event. Ollama
35
+ // expects reasoning to accompany an assistant tool call on continuation.
36
+ reasoning += reasoningDelta;
37
+ onStream?.({ kind: "thinking", text: reasoningDelta });
34
38
  }
35
39
  if (typeof delta.content === "string" && delta.content !== "") {
36
40
  content += delta.content;
@@ -59,5 +63,5 @@ export async function assembleOllama(events, onStream) {
59
63
  // Not every server sends an id, and the loop needs one to pair the result
60
64
  // back to its call.
61
65
  .map(([index, call]) => (call.id === "" ? { ...call, id: `call_${index}` } : call));
62
- return { content, toolCalls, finishReason, usage };
66
+ return { content, reasoning, toolCalls, finishReason, usage };
63
67
  }
@@ -40,6 +40,9 @@ export function toWireMessages(system, messages) {
40
40
  if (texts.length === 0 && toolCalls.length === 0)
41
41
  continue;
42
42
  const turn = { role: "assistant", content: texts.join("\n") };
43
+ const reasoning = ollamaReasoning(message);
44
+ if (reasoning !== undefined)
45
+ turn["reasoning"] = reasoning;
43
46
  // An empty tool_calls array is not the same as no tool_calls to every
44
47
  // server, so the key is omitted rather than sent empty.
45
48
  if (toolCalls.length > 0)
@@ -59,9 +62,22 @@ export function fromWireReply(reply) {
59
62
  for (const call of reply.toolCalls) {
60
63
  content.push({ kind: "tool_call", id: call.id, name: call.name, input: parseArgs(call.args) });
61
64
  }
62
- // No `raw`: unlike the other two providers, nothing in this shape has to be
63
- // echoed back verbatim, so the normalized blocks are the whole message.
64
- return { role: "assistant", content, usage: normalizeUsage(reply) };
65
+ const raw = reply.reasoning === "" ? undefined : { reasoning: reply.reasoning };
66
+ return {
67
+ role: "assistant",
68
+ content,
69
+ ...(raw === undefined ? {} : { raw, rawFrom: "ollama" }),
70
+ usage: normalizeUsage(reply),
71
+ };
72
+ }
73
+ function ollamaReasoning(message) {
74
+ if (message.rawFrom !== "ollama" ||
75
+ typeof message.raw !== "object" ||
76
+ message.raw === null ||
77
+ Array.isArray(message.raw))
78
+ return undefined;
79
+ const reasoning = message.raw["reasoning"];
80
+ return typeof reasoning === "string" && reasoning !== "" ? reasoning : undefined;
65
81
  }
66
82
  function normalizeUsage(reply) {
67
83
  if (reply.usage === undefined)
package/dist/tools/fs.js CHANGED
@@ -1,5 +1,5 @@
1
1
  // Filesystem tools: read, list, write, edit.
2
- import { createReadStream } from "node:fs";
2
+ import { constants } from "node:fs";
3
3
  import * as fs from "node:fs/promises";
4
4
  import * as path from "node:path";
5
5
  import { optionalBool, optionalInt, requireString } from "./args.js";
@@ -9,9 +9,10 @@ import { atomicWrite } from "../atomic.js";
9
9
  const MAX_READ_CHARS = 60_000;
10
10
  const MAX_LIST_CHARS = 60_000;
11
11
  const MAX_LIST_ENTRIES = 2_000;
12
+ const READ_CHUNK_BYTES = 64 * 1024;
12
13
  export const readFile = {
13
14
  name: "read_file",
14
- description: "Read a UTF-8 text file inside the workspace. Optionally start at a line " +
15
+ description: "Read a regular UTF-8 text file inside the workspace. Optionally start at a line " +
15
16
  "(1-based) and cap how many lines come back. Large files are truncated.",
16
17
  dangerous: false,
17
18
  input: {
@@ -28,7 +29,7 @@ export const readFile = {
28
29
  const target = await resolveExistingInRoot(root, requireString(args, "path"));
29
30
  const offset = optionalInt(args, "offset");
30
31
  const limit = optionalInt(args, "limit");
31
- const { text, truncated } = await readRange(target, offset, limit);
32
+ const { text, truncated } = await readRange(target, offset, limit, ctx.signal);
32
33
  if (truncated) {
33
34
  return {
34
35
  output: `${text}\n\n[truncated at ${MAX_READ_CHARS} characters — read a narrower range]`,
@@ -169,13 +170,22 @@ export const editFile = {
169
170
  };
170
171
  },
171
172
  };
172
- async function readRange(target, offset, limit) {
173
+ async function readRange(target, offset, limit, signal) {
174
+ throwIfAborted(signal);
173
175
  const firstLine = Math.max(1, offset ?? 1);
174
176
  const lineCount = limit === undefined ? undefined : Math.max(0, limit);
175
177
  const endLine = lineCount === undefined ? Number.POSITIVE_INFINITY : firstLine + lineCount;
176
178
  if (lineCount === 0)
177
179
  return { text: "", truncated: false };
178
- const source = createReadStream(target);
180
+ if (!(await fs.lstat(target)).isFile())
181
+ throw new Error("path must be a regular file");
182
+ // O_NONBLOCK prevents a path swapped to a FIFO between lstat and open from
183
+ // waiting forever for a writer. The handle stat closes that race before any
184
+ // content is accepted.
185
+ const flags = process.platform === "win32"
186
+ ? "r"
187
+ : constants.O_RDONLY | (constants.O_NONBLOCK ?? 0);
188
+ const handle = await fs.open(target, flags);
179
189
  const decoder = new TextDecoder();
180
190
  let text = "";
181
191
  let line = 1;
@@ -216,19 +226,32 @@ async function readRange(target, offset, limit) {
216
226
  }
217
227
  };
218
228
  try {
219
- for await (const chunk of source) {
220
- consume(decoder.decode(chunk, { stream: true }));
221
- if (stopped)
229
+ if (!(await handle.stat()).isFile())
230
+ throw new Error("path must be a regular file");
231
+ let position = 0;
232
+ const buffer = Buffer.allocUnsafe(READ_CHUNK_BYTES);
233
+ while (!stopped) {
234
+ throwIfAborted(signal);
235
+ const { bytesRead } = await handle.read(buffer, 0, buffer.length, position);
236
+ throwIfAborted(signal);
237
+ if (bytesRead === 0)
222
238
  break;
239
+ position += bytesRead;
240
+ consume(decoder.decode(buffer.subarray(0, bytesRead), { stream: true }));
223
241
  }
224
242
  if (!stopped)
225
243
  consume(decoder.decode());
226
244
  }
227
245
  finally {
228
- source.destroy();
246
+ await handle.close();
229
247
  }
230
248
  return { text, truncated };
231
249
  }
250
+ function throwIfAborted(signal) {
251
+ if (signal?.aborted !== true)
252
+ return;
253
+ throw signal.reason instanceof Error ? signal.reason : new Error("interrupted");
254
+ }
232
255
  /**
233
256
  * The edit worked out against a given text — the one place its rules live.
234
257
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@giovannijecha/jecode",
3
- "version": "0.2.2",
3
+ "version": "0.2.4",
4
4
  "description": "An owned coding agent with zero external runtime dependencies.",
5
5
  "license": "MIT",
6
6
  "repository": {