@giovannijecha/jecode 0.7.3 → 0.7.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/README.md CHANGED
@@ -53,10 +53,32 @@
53
53
 
54
54
  Jecode requires **Node.js 22.18+ on the 22.x line, or Node.js 24+**, and npm.
55
55
 
56
+ ### Install or update
57
+
58
+ Install the current stable release. Running the same command again updates an
59
+ existing installation:
60
+
61
+ ```console
62
+ npm install --global @giovannijecha/jecode@latest
63
+ ```
64
+
65
+ Confirm the installed version:
66
+
56
67
  ```console
57
- npm install --global @giovannijecha/jecode
58
68
  jecode --version
69
+ ```
70
+
71
+ ### Start Jecode
72
+
73
+ Open the project you want Jecode to work on:
74
+
75
+ ```console
59
76
  cd path/to/your/project
77
+ ```
78
+
79
+ Then start Jecode:
80
+
81
+ ```console
60
82
  jecode
61
83
  ```
62
84
 
@@ -71,33 +93,41 @@ change to improve startup performance.
71
93
  Use `jecode --root path/to/project` to select another workspace, or
72
94
  `jecode --ephemeral` when the conversation must stay memory-only.
73
95
 
74
- Resume a saved conversation for the current workspace with a searchable picker:
96
+ ### Resume a conversation
97
+
98
+ Open the searchable resume picker for the current workspace:
75
99
 
76
100
  ```console
77
101
  jecode resume
78
- jecode resume --latest
79
102
  ```
80
103
 
81
- Run `jecode --help` for every startup option. Windows, Ubuntu, and macOS are
82
- covered by the project test matrix.
104
+ Resume the most recently updated conversation directly:
83
105
 
84
- ### Prereleases
106
+ ```console
107
+ jecode resume --latest
108
+ ```
85
109
 
86
- The stable npm package is the supported installation artifact. To try an
87
- opt-in prerelease:
110
+ List every startup option:
88
111
 
89
112
  ```console
90
- npm install --global @giovannijecha/jecode@next
113
+ jecode --help
91
114
  ```
92
115
 
116
+ Windows, Ubuntu, and macOS are covered by the project test matrix.
117
+
118
+ ### Prereleases
119
+
120
+ Prereleases exist only during an announced release-candidate cycle. When one is
121
+ active, its GitHub release provides the exact installation command. Outside an
122
+ active cycle, the stable npm package is the only supported installation
123
+ artifact.
124
+
93
125
  Git URL installs are intentionally unsupported: the source tree contains no
94
126
  generated runtime and defines no install-time build hook.
95
127
 
96
- ### Update and uninstall
128
+ ### Uninstall
97
129
 
98
130
  ```console
99
- npm install --global @giovannijecha/jecode
100
- jecode --version
101
131
  npm uninstall --global @giovannijecha/jecode
102
132
  ```
103
133
 
@@ -105,12 +135,19 @@ Uninstalling the command preserves `~/.jecode`. Remove that directory only when
105
135
  you intentionally want to erase saved settings, credentials, accounts, and
106
136
  sessions.
107
137
 
108
- If an older GitHub installation still owns the `jecode` executable, remove the
109
- legacy unscoped package before installing the scoped package:
138
+ ### Replace a legacy installation
139
+
140
+ If an older GitHub installation still owns the `jecode` executable, first
141
+ remove the legacy unscoped package:
110
142
 
111
143
  ```console
112
144
  npm uninstall --global jecode
113
- npm install --global @giovannijecha/jecode
145
+ ```
146
+
147
+ Then install the current scoped package:
148
+
149
+ ```console
150
+ npm install --global @giovannijecha/jecode@latest
114
151
  ```
115
152
 
116
153
  Do not work around the resulting `EEXIST` error with `--force`.
@@ -119,14 +156,39 @@ Do not work around the resulting `EEXIST` error with `--force`.
119
156
 
120
157
  WSL uses its own Node.js installation and `PATH`; the Node.js version installed
121
158
  on Windows does not apply inside it. Keep user-installed npm commands in the
122
- Linux user path:
159
+ Linux user path. Set the user-level npm prefix:
123
160
 
124
161
  ```console
125
162
  npm config set prefix "$HOME/.local"
163
+ ```
164
+
165
+ Add it to the current shell's `PATH`:
166
+
167
+ ```console
126
168
  export PATH="$HOME/.local/bin:$PATH"
127
- npm install --global @giovannijecha/jecode
169
+ ```
170
+
171
+ Install or update Jecode:
172
+
173
+ ```console
174
+ npm install --global @giovannijecha/jecode@latest
175
+ ```
176
+
177
+ Refresh the command cache:
178
+
179
+ ```console
128
180
  hash -r
181
+ ```
182
+
183
+ Confirm which executable will run:
184
+
185
+ ```console
129
186
  command -v jecode
187
+ ```
188
+
189
+ Verify the installed version:
190
+
191
+ ```console
130
192
  jecode --version
131
193
  ```
132
194
 
@@ -347,7 +409,8 @@ fixtures. `npm run bench:transcript` and `npm run bench:search` provide manual
347
409
  probes for long-session rendering and workspace search. Architecture and
348
410
  security boundaries are documented in
349
411
  [docs/architecture.md](docs/architecture.md); brand assets and usage rules live
350
- in [docs/brand.md](docs/brand.md).
412
+ in [docs/brand.md](docs/brand.md). The maintainer release procedure lives in
413
+ [docs/releasing.md](docs/releasing.md).
351
414
 
352
415
  ## Community
353
416
 
package/dist/atomic.js CHANGED
@@ -8,20 +8,29 @@ export async function atomicWrite(file, content, options = {}) {
8
8
  let handle;
9
9
  let identity;
10
10
  try {
11
+ throwIfAborted(options.signal);
11
12
  await options.validate?.("before-open");
13
+ throwIfAborted(options.signal);
12
14
  const permissions = options.mode ?? (await existingMode(file));
15
+ throwIfAborted(options.signal);
13
16
  handle = await open(temporary, "wx", permissions);
14
17
  identity = fileIdentity(await handle.stat());
18
+ throwIfAborted(options.signal);
15
19
  await options.validate?.("before-write");
20
+ throwIfAborted(options.signal);
16
21
  await assertNamedFile(temporary, identity);
17
22
  await handle.writeFile(content, "utf8");
23
+ throwIfAborted(options.signal);
18
24
  if (permissions !== undefined && process.platform !== "win32") {
19
25
  await handle.chmod(permissions);
20
26
  }
21
27
  await handle.sync();
28
+ throwIfAborted(options.signal);
22
29
  identity = fileIdentity(await handle.stat());
23
30
  await options.validate?.("before-rename");
31
+ throwIfAborted(options.signal);
24
32
  await assertNamedFile(temporary, identity);
33
+ throwIfAborted(options.signal);
25
34
  await rename(temporary, file);
26
35
  const completed = handle;
27
36
  handle = undefined;
@@ -38,6 +47,11 @@ export async function atomicWrite(file, content, options = {}) {
38
47
  throw error;
39
48
  }
40
49
  }
50
+ function throwIfAborted(signal) {
51
+ if (signal?.aborted !== true)
52
+ return;
53
+ throw signal.reason instanceof Error ? signal.reason : new Error("interrupted");
54
+ }
41
55
  async function assertNamedFile(file, expected) {
42
56
  const details = await lstat(file);
43
57
  if (details.isSymbolicLink() || !sameFile(expected, fileIdentity(details))) {
package/dist/batch.js CHANGED
@@ -13,7 +13,7 @@ import { handleCommand } from "./commands.js";
13
13
  import { renderBatch } from "./batch-view.js";
14
14
  import { columns } from "./ui/render.js";
15
15
  import { terminalText } from "./ui/terminal-text.js";
16
- import { recordAuxiliaryUsage, recordUsage } from "./usage.js";
16
+ import { recordAuxiliaryUsage, recordRequestInput, recordUsage } from "./usage.js";
17
17
  export async function runBatch(session, environment = {}) {
18
18
  const rl = environment.lines === undefined ? readline.createInterface({ input: stdin }) : undefined;
19
19
  const lines = environment.lines ?? rl;
@@ -167,5 +167,8 @@ function events(emit, session) {
167
167
  onUsage(usage) {
168
168
  recordUsage(session.usage, usage);
169
169
  },
170
+ onRequestInput(inputTokens) {
171
+ recordRequestInput(session.usage, inputTokens);
172
+ },
170
173
  };
171
174
  }
@@ -42,16 +42,16 @@ export function planCompaction(context, turn, coveredMessages, lastInputTokens,
42
42
  };
43
43
  }
44
44
  export function policyForContextWindow(context, compactionPercent) {
45
- const windowTokens = validWindow(context?.tokens)
46
- ? context.tokens
47
- : FALLBACK_CONTEXT_WINDOW_TOKENS;
45
+ const windowTokens = context === undefined
46
+ ? FALLBACK_CONTEXT_WINDOW_TOKENS
47
+ : requireWindow(context.tokens);
48
48
  const percent = validPercent(compactionPercent)
49
49
  ? compactionPercent
50
50
  : DEFAULT_COMPACTION_PERCENT;
51
51
  const percentageLimit = Math.floor(windowTokens * percent / 100);
52
- const providerLimit = validWindow(context?.compactAtTokens)
53
- ? context.compactAtTokens
54
- : windowTokens;
52
+ const providerLimit = context?.compactAtTokens === undefined
53
+ ? windowTokens
54
+ : requireWindow(context.compactAtTokens);
55
55
  const requestLimitTokens = Math.floor(Math.min(providerLimit, windowTokens) * (100 - REQUEST_ESTIMATE_HEADROOM_PERCENT) / 100);
56
56
  const triggerTokens = Math.min(percentageLimit, requestLimitTokens - MIN_REQUEST_OUTPUT_TOKENS);
57
57
  const targetTokens = Math.max(512, Math.min(Math.floor(windowTokens / 4), Math.floor(triggerTokens / 2)));
@@ -114,7 +114,14 @@ function validPolicy(policy) {
114
114
  policy.targetTokens < policy.triggerTokens && policy.recentTokens < policy.triggerTokens;
115
115
  }
116
116
  function validWindow(value) {
117
- return value !== undefined && Number.isSafeInteger(value) && value >= 4_096 && value <= 10_000_000;
117
+ // Adapters validate raw capacities at 4K before applying their own usable
118
+ // headroom. The resulting safe capacity can therefore be slightly smaller.
119
+ return value !== undefined && Number.isSafeInteger(value) && value >= 1_024 && value <= 10_000_000;
120
+ }
121
+ function requireWindow(value) {
122
+ if (validWindow(value))
123
+ return value;
124
+ throw new Error(`provider reported an invalid usable context window: ${value}`);
118
125
  }
119
126
  function validPercent(value) {
120
127
  return Number.isSafeInteger(value) &&
@@ -24,7 +24,7 @@ export async function requestAssistant(history, current, specs, options, events,
24
24
  onStream: (event) => events.onStream(event),
25
25
  onStatus: (status) => events.onStatus?.(status),
26
26
  });
27
- return { message, context };
27
+ return { message, context, inputTokens: budget.inputTokens };
28
28
  }
29
29
  catch (error) {
30
30
  if (recovered)
@@ -45,10 +45,13 @@ export async function runTurn(history, options, events, signal, modelHistory = h
45
45
  throw new Error(`provider returned ${calls.length} tool calls in one step (maximum ${MAX_TOOL_CALLS_PER_STEP})`);
46
46
  }
47
47
  assertToolCallIds(calls);
48
+ if (assistant.usage !== undefined)
49
+ events.onUsage?.(assistant.usage);
50
+ events.onRequestInput?.(assistant.usage !== undefined && assistant.usage.inputTokens > 0
51
+ ? assistant.usage.inputTokens
52
+ : response.inputTokens);
48
53
  append(assistant);
49
54
  if (calls.length === 0) {
50
- if (assistant.usage !== undefined)
51
- events.onUsage?.(assistant.usage);
52
55
  await checkpoint("completed");
53
56
  return; // the model is done — hand back to the user
54
57
  }
@@ -58,8 +61,6 @@ export async function runTurn(history, options, events, signal, modelHistory = h
58
61
  const results = [];
59
62
  const announced = new Set();
60
63
  try {
61
- if (assistant.usage !== undefined)
62
- events.onUsage?.(assistant.usage);
63
64
  while (results.length < calls.length) {
64
65
  const start = results.length;
65
66
  const batch = nextBatch(calls, start, options.tools);
@@ -2,6 +2,7 @@
2
2
  //
3
3
  // These requests never redirect, never retry, and never retain an unbounded
4
4
  // response. Authorization codes and refresh tokens must not leak into errors.
5
+ import { leadingText } from "./text-boundary.js";
5
6
  const AUTH_ORIGIN = "https://auth.openai.com";
6
7
  const TIMEOUT_MS = 15_000;
7
8
  const MAX_BODY_CHARS = 64_000;
@@ -96,7 +97,7 @@ function errorDetail(value, secrets) {
96
97
  let safe = detail.replace(/[\r\n]+/g, " ");
97
98
  for (const secret of secrets)
98
99
  safe = safe.replaceAll(secret, "[credential redacted]");
99
- return ` · ${safe.slice(0, 300)}`;
100
+ return ` · ${leadingText(safe, 300)}`;
100
101
  }
101
102
  function bodySecrets(body) {
102
103
  const values = body.contentType === "application/x-www-form-urlencoded"
@@ -2,6 +2,7 @@
2
2
  import { timingSafeEqual } from "node:crypto";
3
3
  import { readFileSync } from "node:fs";
4
4
  import { createServer } from "node:http";
5
+ import { leadingText } from "./text-boundary.js";
5
6
  const CALLBACK_PORTS = [1455, 1457];
6
7
  export const OPENAI_CALLBACK_PATH = "/auth/callback";
7
8
  export async function openAICallback(state) {
@@ -42,7 +43,7 @@ export async function openAICallback(state) {
42
43
  const authError = incoming.searchParams.get("error_description") ?? incoming.searchParams.get("error");
43
44
  const authorizationCode = incoming.searchParams.get("code");
44
45
  if (authError !== null) {
45
- rejectCode(new Error(`ChatGPT sign-in was rejected · ${authError.slice(0, 300)}`));
46
+ rejectCode(new Error(`ChatGPT sign-in was rejected · ${leadingText(authError, 300)}`));
46
47
  }
47
48
  else if (authorizationCode === null || authorizationCode === "") {
48
49
  rejectCode(new Error("ChatGPT sign-in returned no authorization code"));
@@ -1,6 +1,7 @@
1
1
  // The entire HTTP layer: one bounded request, then either a JSON body or an
2
2
  // event stream. Only idempotent reads retry. Once a POST starts or response
3
3
  // bytes flow, a failure is surfaced rather than silently replayed.
4
+ import { leadingText } from "../text-boundary.js";
4
5
  import { readSseJson } from "./sse.js";
5
6
  import { sseStreamCharacterLimit } from "./stream-limits.js";
6
7
  const RETRYABLE = new Set([408, 409, 429, 500, 502, 503, 504]);
@@ -31,7 +32,7 @@ async function asJson(url, res) {
31
32
  return JSON.parse(text);
32
33
  }
33
34
  catch {
34
- throw httpError(`${url} returned non-JSON`, res.status, text.slice(0, 500));
35
+ throw httpError(`${url} returned non-JSON`, res.status, leadingText(text, 500));
35
36
  }
36
37
  }
37
38
  export async function postSse(url, headers, body, maxOutputTokens, signal, onStatus) {
@@ -107,13 +108,13 @@ async function boundedText(url, res, max) {
107
108
  if (done) {
108
109
  text += decoder.decode();
109
110
  return text.length > max
110
- ? { text: text.slice(0, max), truncated: true }
111
+ ? { text: leadingText(text, max), truncated: true }
111
112
  : { text, truncated: false };
112
113
  }
113
114
  text += decoder.decode(value, { stream: true });
114
115
  if (text.length > max) {
115
116
  await reader.cancel().catch(() => undefined);
116
- return { text: text.slice(0, max), truncated: true };
117
+ return { text: leadingText(text, max), truncated: true };
117
118
  }
118
119
  }
119
120
  }
@@ -152,8 +152,6 @@ function modelContextWindow(entry) {
152
152
  return undefined;
153
153
  const percent = percentage(entry["effective_context_window_percent"]) ?? 95;
154
154
  const tokens = Math.floor(resolved * percent / 100);
155
- if (!validTokenCount(tokens))
156
- return undefined;
157
155
  const automatic = Math.floor(resolved * 9 / 10);
158
156
  const advertised = tokenCount(entry["auto_compact_token_limit"]);
159
157
  return Object.freeze({
@@ -7,37 +7,26 @@ import { addBounded, MAX_SSE_EVENT_CHARS, } from "./stream-limits.js";
7
7
  export async function* readSseJson(body, maximumChars) {
8
8
  const reader = body.getReader();
9
9
  const decoder = new TextDecoder();
10
- let buffer = "";
10
+ const parser = new SseEventParser();
11
11
  let finished = false;
12
12
  let total = 0;
13
- const append = (text) => {
14
- total = addBounded(total, text.length, maximumChars, "SSE stream");
15
- buffer += text;
16
- };
17
13
  try {
18
14
  for (;;) {
19
15
  const { done, value } = await reader.read();
20
16
  if (done)
21
17
  break;
22
- append(decoder.decode(value, { stream: true }));
23
- for (;;) {
24
- const boundary = findBoundary(buffer);
25
- if (boundary === undefined)
26
- break;
27
- assertEventSize(boundary.start);
28
- const chunk = buffer.slice(0, boundary.start);
29
- buffer = buffer.slice(boundary.end);
30
- const payload = parseData(chunk);
31
- if (payload !== undefined)
32
- yield payload;
33
- }
34
- assertEventSize(buffer.length);
18
+ const text = decoder.decode(value, { stream: true });
19
+ total = addBounded(total, text.length, maximumChars, "SSE stream");
20
+ for (const payload of parser.push(text))
21
+ yield payload;
35
22
  }
36
23
  // A stream that ends without a trailing blank line still owes us its last
37
24
  // event.
38
- append(decoder.decode());
39
- assertEventSize(buffer.length);
40
- const payload = parseData(buffer);
25
+ const text = decoder.decode();
26
+ total = addBounded(total, text.length, maximumChars, "SSE stream");
27
+ for (const payload of parser.push(text))
28
+ yield payload;
29
+ const payload = parser.finish();
41
30
  if (payload !== undefined)
42
31
  yield payload;
43
32
  finished = true;
@@ -48,28 +37,116 @@ export async function* readSseJson(body, maximumChars) {
48
37
  reader.releaseLock();
49
38
  }
50
39
  }
40
+ // Keep fragments in bounded groups. A provider may split one SSE line into
41
+ // hundreds of thousands of tiny chunks; repeatedly flattening the growing line
42
+ // would make parsing quadratic even if boundary scanning itself were linear.
43
+ class TextParts {
44
+ #groups = [];
45
+ #pieces = [];
46
+ #lastCodeUnit = "";
47
+ length = 0;
48
+ append(text) {
49
+ if (text === "")
50
+ return;
51
+ this.#pieces.push(text);
52
+ this.#lastCodeUnit = text.at(-1);
53
+ this.length += text.length;
54
+ if (this.#pieces.length >= 256)
55
+ this.#flush();
56
+ }
57
+ take() {
58
+ this.#flush();
59
+ const text = this.#groups.length === 1 ? this.#groups[0] : this.#groups.join("");
60
+ this.#groups.length = 0;
61
+ this.#lastCodeUnit = "";
62
+ this.length = 0;
63
+ return text;
64
+ }
65
+ endsWithCarriageReturn() {
66
+ return this.#lastCodeUnit === "\r";
67
+ }
68
+ #flush() {
69
+ if (this.#pieces.length === 0)
70
+ return;
71
+ this.#groups.push(this.#pieces.length === 1 ? this.#pieces[0] : this.#pieces.join(""));
72
+ this.#pieces = [];
73
+ }
74
+ }
75
+ export class SseEventParser {
76
+ #line = new TextParts();
77
+ #data = [];
78
+ #eventLength = 0;
79
+ #hasLine = false;
80
+ #pendingTerminatorLength = 0;
81
+ *push(text) {
82
+ let start = 0;
83
+ for (;;) {
84
+ const newline = text.indexOf("\n", start);
85
+ if (newline === -1) {
86
+ this.#line.append(text.slice(start));
87
+ this.#assertPendingLineSize();
88
+ return;
89
+ }
90
+ this.#line.append(text.slice(start, newline));
91
+ const rawLine = this.#line.take();
92
+ const crlf = rawLine.endsWith("\r");
93
+ const line = crlf ? rawLine.slice(0, -1) : rawLine;
94
+ if (line === "") {
95
+ const payload = this.#finishEvent();
96
+ if (payload !== undefined)
97
+ yield payload;
98
+ }
99
+ else {
100
+ this.#appendLine(line, crlf ? 2 : 1);
101
+ }
102
+ start = newline + 1;
103
+ }
104
+ }
105
+ finish() {
106
+ const line = this.#line.take();
107
+ if (line !== "") {
108
+ this.#appendLine(line, 0);
109
+ }
110
+ else if (this.#hasLine) {
111
+ // A single trailing line terminator is part of an unterminated event.
112
+ assertEventSize(this.#eventLength + this.#pendingTerminatorLength);
113
+ }
114
+ return this.#finishEvent();
115
+ }
116
+ #appendLine(line, terminatorLength) {
117
+ const separatorLength = this.#hasLine ? this.#pendingTerminatorLength : 0;
118
+ assertEventSize(this.#eventLength + separatorLength + line.length);
119
+ this.#eventLength += separatorLength + line.length;
120
+ this.#hasLine = true;
121
+ this.#pendingTerminatorLength = terminatorLength;
122
+ if (line.startsWith("data:")) {
123
+ this.#data.push(line.slice("data:".length).trimStart());
124
+ }
125
+ }
126
+ #assertPendingLineSize() {
127
+ const separatorLength = this.#hasLine ? this.#pendingTerminatorLength : 0;
128
+ const remaining = MAX_SSE_EVENT_CHARS - this.#eventLength - separatorLength;
129
+ // One trailing CR may turn out to be part of a split CRLF terminator.
130
+ const splitCrlf = this.#line.length === remaining + 1 && this.#line.endsWithCarriageReturn();
131
+ if (this.#line.length > remaining && !splitCrlf) {
132
+ assertEventSize(MAX_SSE_EVENT_CHARS + 1);
133
+ }
134
+ }
135
+ #finishEvent() {
136
+ const data = this.#data.join("\n");
137
+ this.#data = [];
138
+ this.#eventLength = 0;
139
+ this.#hasLine = false;
140
+ this.#pendingTerminatorLength = 0;
141
+ return parseData(data);
142
+ }
143
+ }
51
144
  function assertEventSize(length) {
52
145
  if (length > MAX_SSE_EVENT_CHARS) {
53
146
  throw new Error(`SSE event exceeded ${MAX_SSE_EVENT_CHARS} characters`);
54
147
  }
55
148
  }
56
- // Handles both LF and CRLF framing without normalising the buffer first — a
57
- // normalising pass would have to cope with a \r\n split across two chunks.
58
- function findBoundary(buffer) {
59
- const lf = buffer.indexOf("\n\n");
60
- const crlf = buffer.indexOf("\r\n\r\n");
61
- if (lf === -1 && crlf === -1)
62
- return undefined;
63
- if (crlf !== -1 && (lf === -1 || crlf < lf))
64
- return { start: crlf, end: crlf + 4 };
65
- return { start: lf, end: lf + 2 };
66
- }
67
- function parseData(chunk) {
68
- const data = chunk
69
- .split(/\r?\n/)
70
- .filter((line) => line.startsWith("data:"))
71
- .map((line) => line.slice("data:".length).trimStart())
72
- .join("\n");
149
+ function parseData(data) {
73
150
  if (data === "" || data === "[DONE]")
74
151
  return undefined;
75
152
  try {
@@ -8,6 +8,7 @@ import { chmod, lstat, mkdir, open, opendir, readFile, readdir, realpath, rename
8
8
  import * as path from "node:path";
9
9
  import { atomicWrite } from "../atomic.js";
10
10
  import { CONVERSATION_LIMITS, ConversationTree } from "../conversation.js";
11
+ import { leadingText } from "../text-boundary.js";
11
12
  import { userDataPath } from "../user-data.js";
12
13
  import { decodeHead, decodeMeta, decodeNode, encodeHead, encodeMeta, encodeNode, SESSION_FILE_LIMITS, SESSION_SCHEMA, } from "./codec.js";
13
14
  import { leaseOwner, leaseToken, pidIsAlive, removeLease, sessionLease, } from "./lease.js";
@@ -367,7 +368,7 @@ function firstUserText(conversation) {
367
368
  const text = message.content.find((block) => block.kind === "text")?.text
368
369
  .replace(/\s+/gu, " ").trim();
369
370
  if (text !== undefined && text !== "")
370
- return text.slice(0, 160);
371
+ return leadingText(text, 160);
371
372
  }
372
373
  return "Untitled session";
373
374
  }
@@ -0,0 +1,47 @@
1
+ // Shared grapheme boundaries for every projection of user-visible text.
2
+ const SEGMENTER = new Intl.Segmenter(undefined, { granularity: "grapheme" });
3
+ export function segmentGraphemes(text) {
4
+ return SEGMENTER.segment(text);
5
+ }
6
+ export function graphemes(text) {
7
+ const out = [];
8
+ for (const { segment } of segmentGraphemes(text))
9
+ out.push(segment);
10
+ return out;
11
+ }
12
+ /** Largest complete grapheme boundary no greater than a UTF-16 offset. */
13
+ export function graphemeFloor(text, offset) {
14
+ const target = Math.max(0, Math.min(text.length, offset));
15
+ if (target === 0 || target === text.length)
16
+ return target;
17
+ const containing = segmentGraphemes(text).containing(target);
18
+ if (containing === undefined || containing.index === target)
19
+ return target;
20
+ return containing.index;
21
+ }
22
+ /** Smallest complete grapheme boundary no less than a UTF-16 offset. */
23
+ export function graphemeCeiling(text, offset) {
24
+ const target = Math.max(0, Math.min(text.length, offset));
25
+ if (target === 0 || target === text.length)
26
+ return target;
27
+ const containing = segmentGraphemes(text).containing(target);
28
+ if (containing === undefined || containing.index === target)
29
+ return target;
30
+ return containing.index + containing.segment.length;
31
+ }
32
+ /** Keep a bounded prefix without returning part of a user-perceived character. */
33
+ export function leadingText(text, maxCodeUnits) {
34
+ if (maxCodeUnits <= 0)
35
+ return "";
36
+ if (text.length <= maxCodeUnits)
37
+ return text;
38
+ return text.slice(0, graphemeFloor(text, maxCodeUnits));
39
+ }
40
+ /** Keep a bounded suffix without returning part of a user-perceived character. */
41
+ export function trailingText(text, maxCodeUnits) {
42
+ if (maxCodeUnits <= 0)
43
+ return "";
44
+ if (text.length <= maxCodeUnits)
45
+ return text;
46
+ return text.slice(graphemeCeiling(text, text.length - maxCodeUnits));
47
+ }
package/dist/timeline.js CHANGED
@@ -3,6 +3,7 @@
3
3
  // Selecting a node changes only the in-memory path. The next real user turn
4
4
  // is what persists a branch, so opening or cancelling this control plane can
5
5
  // never create empty history.
6
+ import { leadingText } from "./text-boundary.js";
6
7
  import { usageFromHistory } from "./usage.js";
7
8
  import { heading } from "./tui/picker.js";
8
9
  export function timelinePicker(conversation, palette) {
@@ -83,7 +84,7 @@ function preview(node) {
83
84
  const text = message.content.find((block) => block.kind === "text")?.text
84
85
  .replace(/\s+/gu, " ").trim();
85
86
  if (text !== undefined && text !== "")
86
- return text.slice(0, 160);
87
+ return leadingText(text, 160);
87
88
  }
88
89
  return "Untitled turn";
89
90
  }
package/dist/tools/fs.js CHANGED
@@ -120,15 +120,18 @@ export const writeFile = {
120
120
  },
121
121
  };
122
122
  export async function runWriteFile(args, ctx, dependencies = DEFAULT_MUTATION_DEPENDENCIES) {
123
+ throwIfAborted(ctx.signal);
123
124
  const root = await resolveExistingInRoot(ctx.root, ".");
124
125
  const target = await resolveDirectWritableInRoot(root, requireString(args, "path"));
125
126
  const content = requireString(args, "content", true);
126
127
  assertEditableText(content);
127
128
  await fs.mkdir(path.dirname(target), { recursive: true });
129
+ throwIfAborted(ctx.signal);
128
130
  await assertDirectWritableInRoot(root, target);
129
131
  const before = await current(target);
130
132
  assertApproved(before, ctx.preview, "write");
131
133
  await dependencies.atomicWrite(target, content, {
134
+ signal: ctx.signal,
132
135
  async validate(phase) {
133
136
  await assertDirectWritableInRoot(root, target);
134
137
  if (phase === "before-rename") {
@@ -181,12 +184,14 @@ export const editFile = {
181
184
  },
182
185
  };
183
186
  export async function runEditFile(args, ctx, dependencies = DEFAULT_MUTATION_DEPENDENCIES) {
187
+ throwIfAborted(ctx.signal);
184
188
  const root = await resolveExistingInRoot(ctx.root, ".");
185
189
  const target = await resolveDirectWritableInRoot(root, requireString(args, "path"), true);
186
190
  const before = await current(target, true);
187
191
  assertApproved(before, ctx.preview, "edit");
188
192
  const { after, made } = applied(before.text, args);
189
193
  await dependencies.atomicWrite(target, after, {
194
+ signal: ctx.signal,
190
195
  async validate(phase) {
191
196
  await assertDirectWritableInRoot(root, target, true);
192
197
  if (phase === "before-rename") {
@@ -1,41 +1,11 @@
1
1
  // Shared size and truncation boundaries for text handled by workspace tools.
2
2
  import { constants } from "node:fs";
3
3
  import { lstat, open } from "node:fs/promises";
4
+ export { leadingText, trailingText } from "../text-boundary.js";
4
5
  export const MAX_EDITABLE_BYTES = 4_000_000;
5
6
  export const MAX_EDITABLE_CHARS = 1_000_000;
6
7
  export const MAX_EDITABLE_LINES = 20_000;
7
8
  const READ_CHUNK_BYTES = 64 * 1024;
8
- const GRAPHEME_SEGMENTER = new Intl.Segmenter(undefined, { granularity: "grapheme" });
9
- /** Keep a bounded prefix without returning part of a user-perceived character. */
10
- export function leadingText(text, maxCodeUnits) {
11
- if (maxCodeUnits <= 0)
12
- return "";
13
- if (text.length <= maxCodeUnits)
14
- return text;
15
- let end = 0;
16
- for (const { index, segment } of GRAPHEME_SEGMENTER.segment(text)) {
17
- const next = index + segment.length;
18
- if (next > maxCodeUnits)
19
- break;
20
- end = next;
21
- }
22
- return text.slice(0, end);
23
- }
24
- /** Keep a bounded suffix without returning part of a user-perceived character. */
25
- export function trailingText(text, maxCodeUnits) {
26
- if (maxCodeUnits <= 0)
27
- return "";
28
- if (text.length <= maxCodeUnits)
29
- return text;
30
- let start = text.length;
31
- for (const { index } of GRAPHEME_SEGMENTER.segment(text)) {
32
- if (text.length - index <= maxCodeUnits) {
33
- start = index;
34
- break;
35
- }
36
- }
37
- return text.slice(start);
38
- }
39
9
  /** Read a regular UTF-8 file without allowing an unbounded allocation. */
40
10
  export async function readEditableText(file, options = {}) {
41
11
  const label = options.label ?? "file";
@@ -7,7 +7,7 @@ import { compactSession } from "../context/manual.js";
7
7
  import { isContextOverflow } from "../context/policy.js";
8
8
  import { updateSettings } from "../settings.js";
9
9
  import { saveTranscript } from "../transcript-export.js";
10
- import { recordAuxiliaryUsage, recordUsage } from "../usage.js";
10
+ import { recordAuxiliaryUsage, recordRequestInput, recordUsage } from "../usage.js";
11
11
  import { selectTimeline } from "../timeline.js";
12
12
  import { answerAt } from "./approve.js";
13
13
  import * as edit from "./editor.js";
@@ -160,6 +160,7 @@ export function appWorkflows(options) {
160
160
  state.status = text;
161
161
  },
162
162
  usage: (usage) => recordUsage(session.usage, usage),
163
+ requestInput: (inputTokens) => recordRequestInput(session.usage, inputTokens),
163
164
  });
164
165
  const persist = async (checkpoint, settlement, failure) => {
165
166
  const next = session.conversation.commit({
@@ -1,3 +1,4 @@
1
+ import { trailingText } from "../../text-boundary.js";
1
2
  import { blank, row } from "../../ui/render.js";
2
3
  import { markdown } from "../../ui/markdown.js";
3
4
  const PAD = 1;
@@ -42,9 +43,5 @@ export function reasoningPreviewSource(text, width) {
42
43
  return { text, truncated: false };
43
44
  // A compact view only needs its visible tail. The complete text remains on
44
45
  // the block for expansion after the reasoning stream is sealed.
45
- let start = text.length - limit;
46
- const code = text.charCodeAt(start);
47
- if (code >= 0xdc00 && code <= 0xdfff)
48
- start--;
49
- return { text: text.slice(start), truncated: true };
46
+ return { text: trailingText(text, limit), truncated: true };
50
47
  }
@@ -1,5 +1,6 @@
1
1
  // A compact execution trace: state and identity on one rail, evidence below.
2
2
  import { hasColor, row } from "../../ui/render.js";
3
+ import { graphemeCeiling, graphemeFloor } from "../../text-boundary.js";
3
4
  const OUTPUT_ROWS = 8;
4
5
  const LIVE_OUTPUT_ROWS = 6;
5
6
  const DIFF_ROWS = 15;
@@ -73,8 +74,12 @@ function renderDetail(detail, tone, width, pal) {
73
74
  function emphasized(text, emphasis, fg) {
74
75
  if (emphasis === undefined || emphasis.length <= 0)
75
76
  return [{ text, fg }];
76
- const start = Math.max(0, Math.min(text.length, emphasis.start));
77
- const end = Math.max(start, Math.min(text.length, start + emphasis.length));
77
+ const requestedStart = Math.max(0, Math.min(text.length, emphasis.start));
78
+ const requestedEnd = Math.max(requestedStart, Math.min(text.length, requestedStart + emphasis.length));
79
+ const start = graphemeFloor(text, requestedStart);
80
+ const end = graphemeCeiling(text, requestedEnd);
81
+ if (start === end)
82
+ return [{ text, fg }];
78
83
  return [
79
84
  ...(start === 0 ? [] : [{ text: text.slice(0, start), fg }]),
80
85
  { text: text.slice(start, end), fg, inverse: true },
package/dist/tui/turn.js CHANGED
@@ -3,6 +3,7 @@
3
3
  // The controller speaks in stream events and tool results; the screen speaks in
4
4
  // blocks. This is the whole of the translation, kept out of the shell so that
5
5
  // neither has to know how the other is built.
6
+ import { graphemes } from "../text-boundary.js";
6
7
  import { condense, diff } from "../ui/diff.js";
7
8
  import { promptFor } from "./approve.js";
8
9
  // Semantic activity labels feed the footer's compact state and timer while
@@ -57,6 +58,9 @@ export function transcribe(stage) {
57
58
  onUsage(usage) {
58
59
  stage.usage?.(usage);
59
60
  },
61
+ onRequestInput(inputTokens) {
62
+ stage.requestInput?.(inputTokens);
63
+ },
60
64
  onStatus(status) {
61
65
  stage.status(status);
62
66
  stage.render();
@@ -249,18 +253,35 @@ function emphasizePairs(rows) {
249
253
  continue;
250
254
  if (rows[index - 1]?.kind === "del" || rows[index + 2]?.kind === "add")
251
255
  continue;
256
+ const removedClusters = graphemes(removed.text);
257
+ const addedClusters = graphemes(added.text);
258
+ let prefix = 0;
252
259
  let start = 0;
253
- while (start < removed.text.length && start < added.text.length && removed.text[start] === added.text[start])
254
- start++;
260
+ while (prefix < removedClusters.length &&
261
+ prefix < addedClusters.length &&
262
+ removedClusters[prefix] === addedClusters[prefix]) {
263
+ start += removedClusters[prefix].length;
264
+ prefix++;
265
+ }
255
266
  let suffix = 0;
256
- while (suffix < removed.text.length - start &&
257
- suffix < added.text.length - start &&
258
- removed.text[removed.text.length - 1 - suffix] === added.text[added.text.length - 1 - suffix])
267
+ let removedSuffix = 0;
268
+ let addedSuffix = 0;
269
+ while (suffix < removedClusters.length - prefix &&
270
+ suffix < addedClusters.length - prefix &&
271
+ removedClusters[removedClusters.length - 1 - suffix] ===
272
+ addedClusters[addedClusters.length - 1 - suffix]) {
273
+ removedSuffix += removedClusters[removedClusters.length - 1 - suffix].length;
274
+ addedSuffix += addedClusters[addedClusters.length - 1 - suffix].length;
259
275
  suffix++;
260
- while (suffix > 0 && (!wordBoundary(removed.text, suffix) || !wordBoundary(added.text, suffix)))
276
+ }
277
+ while (suffix > 0 &&
278
+ (!wordBoundary(removed.text, removedSuffix) || !wordBoundary(added.text, addedSuffix))) {
279
+ removedSuffix -= removedClusters[removedClusters.length - suffix].length;
280
+ addedSuffix -= addedClusters[addedClusters.length - suffix].length;
261
281
  suffix--;
262
- const removedLength = removed.text.length - start - suffix;
263
- const addedLength = added.text.length - start - suffix;
282
+ }
283
+ const removedLength = removed.text.length - start - removedSuffix;
284
+ const addedLength = added.text.length - start - addedSuffix;
264
285
  if (removedLength > 0)
265
286
  removed.emphasis = { start, length: removedLength };
266
287
  if (addedLength > 0)
package/dist/ui/diff.js CHANGED
@@ -8,24 +8,37 @@
8
8
  * Above this many cells the table stops being worth building.
9
9
  *
10
10
  * The diff is quadratic in the two line counts, and it is drawn between two
11
- * keystrokes. Past the ceiling the honest answer is the coarse one — all of
12
- * the old, then all of the new — rather than a frame the user waits for.
11
+ * keystrokes. Common edges are removed before this limit is applied, so a
12
+ * local edit in a large file stays local. If the changed middle itself crosses
13
+ * the ceiling, the honest answer is the coarse one rather than a frame the
14
+ * user waits for.
13
15
  */
14
16
  const CEILING = 250_000;
15
17
  export function diff(before, after) {
16
18
  const a = lines(before);
17
19
  const b = lines(after);
18
- if (a.length * b.length > CEILING) {
19
- return [...a.map(del), ...b.map(add)];
20
+ const prefix = commonPrefix(a, b);
21
+ const suffix = commonSuffix(a, b, prefix);
22
+ const aEnd = a.length - suffix;
23
+ const bEnd = b.length - suffix;
24
+ const middleA = a.slice(prefix, aEnd);
25
+ const middleB = b.slice(prefix, bEnd);
26
+ const rows = a.slice(0, prefix).map(keep);
27
+ if (middleA.length * middleB.length > CEILING) {
28
+ for (const line of middleA)
29
+ rows.push(del(line));
30
+ for (const line of middleB)
31
+ rows.push(add(line));
32
+ appendSuffix(rows, a, aEnd);
33
+ return rows;
20
34
  }
21
- const table = common(a, b);
22
- const rows = [];
35
+ const table = common(middleA, middleB);
23
36
  let i = 0;
24
37
  let j = 0;
25
- const width = b.length + 1;
26
- while (i < a.length && j < b.length) {
27
- if (a[i] === b[j]) {
28
- rows.push({ kind: "keep", text: a[i] });
38
+ const width = middleB.length + 1;
39
+ while (i < middleA.length && j < middleB.length) {
40
+ if (middleA[i] === middleB[j]) {
41
+ rows.push(keep(middleA[i]));
29
42
  i++;
30
43
  j++;
31
44
  continue;
@@ -33,20 +46,39 @@ export function diff(before, after) {
33
46
  // Deletions first on a tie, so a replaced line reads old-then-new — the
34
47
  // order the eye expects, and the order every other diff prints.
35
48
  if (table[(i + 1) * width + j] >= table[i * width + j + 1]) {
36
- rows.push(del(a[i]));
49
+ rows.push(del(middleA[i]));
37
50
  i++;
38
51
  }
39
52
  else {
40
- rows.push(add(b[j]));
53
+ rows.push(add(middleB[j]));
41
54
  j++;
42
55
  }
43
56
  }
44
- while (i < a.length)
45
- rows.push(del(a[i++]));
46
- while (j < b.length)
47
- rows.push(add(b[j++]));
57
+ while (i < middleA.length)
58
+ rows.push(del(middleA[i++]));
59
+ while (j < middleB.length)
60
+ rows.push(add(middleB[j++]));
61
+ appendSuffix(rows, a, aEnd);
48
62
  return rows;
49
63
  }
64
+ function commonPrefix(a, b) {
65
+ const limit = Math.min(a.length, b.length);
66
+ let length = 0;
67
+ while (length < limit && a[length] === b[length])
68
+ length++;
69
+ return length;
70
+ }
71
+ function commonSuffix(a, b, prefix) {
72
+ const limit = Math.min(a.length, b.length) - prefix;
73
+ let length = 0;
74
+ while (length < limit && a[a.length - length - 1] === b[b.length - length - 1])
75
+ length++;
76
+ return length;
77
+ }
78
+ function appendSuffix(rows, source, start) {
79
+ for (let index = start; index < source.length; index++)
80
+ rows.push(keep(source[index]));
81
+ }
50
82
  /**
51
83
  * Drop the unchanged stretches, keeping `context` rows either side.
52
84
  *
@@ -109,3 +141,6 @@ function add(text) {
109
141
  function del(text) {
110
142
  return { kind: "del", text };
111
143
  }
144
+ function keep(text) {
145
+ return { kind: "keep", text };
146
+ }
package/dist/ui/render.js CHANGED
@@ -1,7 +1,7 @@
1
1
  // Composing a terminal row out of styled segments. Pure: every function here
2
2
  // returns a string or rows of segments, none of them write anywhere.
3
3
  import { terminalText } from "./terminal-text.js";
4
- import { elide, textWidth, wrapText } from "./width.js";
4
+ import { elide, splitByCells, textWidth, wrapText } from "./width.js";
5
5
  // Built rather than written literally: a raw escape byte in the source is
6
6
  // invisible in a diff and in a code review, which is how they survive.
7
7
  const ESC = String.fromCharCode(27);
@@ -166,13 +166,13 @@ export function flow(segs, max, continuation = []) {
166
166
  let used = 0;
167
167
  let pending;
168
168
  const room = () => Math.max(1, max - (rows.length === 0 ? 0 : lead));
169
- const add = (tok) => {
169
+ const add = (tok, width = textWidth(tok.text)) => {
170
170
  const last = current[current.length - 1];
171
171
  if (last !== undefined && sameStyle(last, tok.seg))
172
172
  last.text += tok.text;
173
173
  else
174
174
  current.push({ ...tok.seg, text: tok.text });
175
- used += textWidth(tok.text);
175
+ used += width;
176
176
  };
177
177
  const flush = () => {
178
178
  rows.push(rows.length === 0 ? current : [...continuation.map(copy), ...current]);
@@ -191,39 +191,31 @@ export function flow(segs, max, continuation = []) {
191
191
  continue;
192
192
  }
193
193
  const space = pending === undefined ? 0 : textWidth(pending.text);
194
- let word = tok.text;
195
- if (used + space + textWidth(word) > room() && current.length > 0)
194
+ const wordWidth = textWidth(tok.text);
195
+ if (used + space + wordWidth > room() && current.length > 0)
196
196
  flush();
197
197
  else if (pending !== undefined) {
198
- add(pending);
198
+ add(pending, space);
199
199
  pending = undefined;
200
200
  }
201
201
  // A word wider than any row is spent across rows: with autowrap off, what
202
202
  // overflows is not ugly, it is gone.
203
- while (textWidth(word) > room() - used) {
204
- const head = clipTo(word, room() - used);
205
- if (head === "")
206
- break;
207
- add({ ...tok, text: head });
208
- word = word.slice(head.length);
209
- flush();
203
+ if (wordWidth > room() - used) {
204
+ const chunks = splitByCells(tok.text, room() - used, Math.max(1, max - lead));
205
+ for (const [index, chunk] of chunks.entries()) {
206
+ add({ ...tok, text: chunk.text }, chunk.width);
207
+ if (index + 1 < chunks.length)
208
+ flush();
209
+ }
210
+ continue;
210
211
  }
211
- if (word !== "")
212
- add({ ...tok, text: word });
212
+ if (tok.text !== "")
213
+ add(tok, wordWidth);
213
214
  }
214
215
  if (current.length > 0 || rows.length === 0)
215
216
  flush();
216
217
  return rows;
217
218
  }
218
- function clipTo(text, cols) {
219
- let out = "";
220
- for (const char of text) {
221
- if (textWidth(out + char) > cols)
222
- break;
223
- out += char;
224
- }
225
- return out;
226
- }
227
219
  function copy(seg) {
228
220
  return { ...seg };
229
221
  }
package/dist/ui/width.js CHANGED
@@ -6,6 +6,8 @@
6
6
  // cell at all. Every alignment in the UI depends on this file being right:
7
7
  // the right-hand column, a ground band, the cursor. Nothing measures with
8
8
  // `.length`.
9
+ import { graphemes, segmentGraphemes } from "../text-boundary.js";
10
+ export { graphemes } from "../text-boundary.js";
9
11
  /** Ranges the terminal draws two cells wide (East Asian Wide and Fullwidth). */
10
12
  const WIDE = [
11
13
  [0x1100, 0x115f],
@@ -93,16 +95,6 @@ function inRanges(code, ranges) {
93
95
  // in source is a byte nobody reviews.
94
96
  const VS15 = String.fromCodePoint(0xfe0e);
95
97
  const VS16 = String.fromCodePoint(0xfe0f);
96
- // Grapheme segmentation is in the standard library, so a family emoji built
97
- // out of five code points and three joiners counts as the one thing the
98
- // terminal actually draws.
99
- const SEGMENTER = new Intl.Segmenter(undefined, { granularity: "grapheme" });
100
- export function graphemes(text) {
101
- const out = [];
102
- for (const { segment } of SEGMENTER.segment(text))
103
- out.push(segment);
104
- return out;
105
- }
106
98
  /**
107
99
  * Cells taken by one grapheme cluster.
108
100
  *
@@ -126,17 +118,38 @@ export function charWidth(cluster) {
126
118
  }
127
119
  export function textWidth(text) {
128
120
  let total = 0;
129
- for (const cluster of SEGMENTER.segment(text))
121
+ for (const cluster of segmentGraphemes(text))
130
122
  total += charWidth(cluster.segment);
131
123
  return total;
132
124
  }
125
+ /** Split text into grapheme-safe chunks without rescanning any suffix. */
126
+ export function splitByCells(text, firstCols, followingCols = firstCols) {
127
+ if (text === "")
128
+ return [{ text, width: 0 }];
129
+ const chunks = [];
130
+ let start = 0;
131
+ let used = 0;
132
+ let room = Math.max(1, firstCols);
133
+ for (const { index, segment } of segmentGraphemes(text)) {
134
+ const width = charWidth(segment);
135
+ if (index > start && used + width > room) {
136
+ chunks.push({ text: text.slice(start, index), width: used });
137
+ start = index;
138
+ used = 0;
139
+ room = Math.max(1, followingCols);
140
+ }
141
+ used += width;
142
+ }
143
+ chunks.push({ text: text.slice(start), width: used });
144
+ return chunks;
145
+ }
133
146
  /** The longest prefix of `text` that fits in `cols` cells. */
134
147
  export function clip(text, cols) {
135
148
  if (cols <= 0)
136
149
  return "";
137
150
  let out = "";
138
151
  let used = 0;
139
- for (const { segment } of SEGMENTER.segment(text)) {
152
+ for (const { segment } of segmentGraphemes(text)) {
140
153
  const w = charWidth(segment);
141
154
  if (used + w > cols)
142
155
  break;
@@ -214,17 +227,13 @@ export function wrapText(text, max, continuation = "") {
214
227
  flush();
215
228
  if (w > room()) {
216
229
  // Too long for any row: spend whole rows on it until it fits.
217
- let rest = word;
218
- while (textWidth(rest) > room()) {
219
- const head = clip(rest, room() - (line === "" ? 0 : width + 1));
220
- if (head === "")
221
- break;
222
- line = line === "" ? head : `${line} ${head}`;
223
- rest = rest.slice(head.length);
224
- flush();
230
+ const chunks = splitByCells(word, room(), Math.max(1, max - lead));
231
+ for (const [index, chunk] of chunks.entries()) {
232
+ line = chunk.text;
233
+ width = chunk.width;
234
+ if (index + 1 < chunks.length)
235
+ flush();
225
236
  }
226
- line = rest;
227
- width = textWidth(rest);
228
237
  continue;
229
238
  }
230
239
  line = line === "" ? word : `${line} ${word}`;
package/dist/usage.js CHANGED
@@ -12,9 +12,12 @@ export function emptyUsage() {
12
12
  }
13
13
  export function recordUsage(total, next) {
14
14
  total.requests += 1;
15
- total.lastInputTokens = next.inputTokens;
16
15
  addUsage(total, next);
17
16
  }
17
+ /** Replace context pressure without inventing provider-reported usage. */
18
+ export function recordRequestInput(total, inputTokens) {
19
+ total.lastInputTokens = inputTokens;
20
+ }
18
21
  /** Account for an internal request without replacing the main context signal. */
19
22
  export function recordAuxiliaryUsage(total, next) {
20
23
  total.requests += 1;
@@ -32,6 +35,7 @@ export function usageFromHistory(messages) {
32
35
  for (const message of messages) {
33
36
  if (message.role === "assistant" && message.usage !== undefined) {
34
37
  recordUsage(total, message.usage);
38
+ recordRequestInput(total, message.usage.inputTokens);
35
39
  }
36
40
  }
37
41
  return total;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@giovannijecha/jecode",
3
- "version": "0.7.3",
3
+ "version": "0.7.4",
4
4
  "description": "An owned coding agent with zero external runtime dependencies.",
5
5
  "license": "MIT",
6
6
  "repository": {