@giovannijecha/jecode 0.8.1 → 0.8.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.
Files changed (48) hide show
  1. package/README.md +19 -278
  2. package/dist/accounts.js +17 -13
  3. package/dist/batch.js +54 -6
  4. package/dist/context/budget.js +28 -4
  5. package/dist/context/compactor.js +5 -4
  6. package/dist/context/estimate.js +43 -1
  7. package/dist/context/manual.js +8 -4
  8. package/dist/context/policy.js +68 -18
  9. package/dist/controller-request.js +23 -15
  10. package/dist/controller.js +26 -5
  11. package/dist/conversation.js +94 -33
  12. package/dist/credential-safety.js +56 -9
  13. package/dist/credentials.js +32 -4
  14. package/dist/input-boundary.js +80 -0
  15. package/dist/main.js +4 -1
  16. package/dist/openai-oauth-callback.js +1 -1
  17. package/dist/openai-oauth.js +59 -15
  18. package/dist/process-shutdown.js +52 -0
  19. package/dist/providers/anthropic-stream.js +24 -20
  20. package/dist/providers/anthropic-wire.js +7 -2
  21. package/dist/providers/ollama-wire.js +7 -15
  22. package/dist/providers/ollama.js +27 -10
  23. package/dist/providers/openai-wire.js +2 -16
  24. package/dist/providers/tool-input.js +17 -0
  25. package/dist/sessions/codec.js +2 -1
  26. package/dist/sessions/lease.js +7 -0
  27. package/dist/sessions/runtime.js +11 -3
  28. package/dist/sessions/store.js +90 -31
  29. package/dist/settings.js +10 -5
  30. package/dist/start.js +12 -2
  31. package/dist/text-boundary.js +2 -0
  32. package/dist/tui/app-input.js +40 -5
  33. package/dist/tui/app-state.js +1 -0
  34. package/dist/tui/app-workflows.js +1 -0
  35. package/dist/tui/app.js +11 -3
  36. package/dist/tui/blocks.js +1 -3
  37. package/dist/tui/components/messages.js +9 -13
  38. package/dist/tui/components/tool.js +2 -4
  39. package/dist/tui/editor.js +2 -0
  40. package/dist/tui/keys.js +64 -5
  41. package/dist/tui/overlay.js +12 -4
  42. package/dist/tui/picker.js +2 -0
  43. package/dist/tui/screen.js +5 -17
  44. package/dist/tui/transcript-grammar.js +1 -1
  45. package/dist/ui/theme.js +18 -18
  46. package/dist/user-store.js +54 -0
  47. package/package.json +6 -3
  48. /package/{docs/assets/brand → assets}/jeco-256.png +0 -0
@@ -1,7 +1,7 @@
1
1
  import { trailingText } from "../../text-boundary.js";
2
- import { row } from "../../ui/render.js";
2
+ import { blank, row } from "../../ui/render.js";
3
3
  import { markdown } from "../../ui/markdown.js";
4
- import { transcriptLead, transcriptMark, transcriptWidth } from "../transcript-grammar.js";
4
+ import { transcriptLead, transcriptWidth } from "../transcript-grammar.js";
5
5
  export const REASONING_PREVIEW_ROWS = 3;
6
6
  const MIN_REASONING_PREVIEW_CHARS = 4_096;
7
7
  const REASONING_PREVIEW_OVERSCAN = 12;
@@ -10,12 +10,14 @@ export function renderUser(block, width, pal) {
10
10
  const content = markdown(block.text, inner, pal, inner);
11
11
  return [
12
12
  "",
13
+ blank(width, pal.surface.subtle),
13
14
  ...content.map((line, index) => row(width, [
14
15
  ...transcriptLead(width, index === 0
15
16
  ? { text: "❯", fg: pal.accent, bold: true }
16
17
  : undefined),
17
18
  ...line.segs,
18
- ])),
19
+ ], [], pal.surface.subtle)),
20
+ blank(width, pal.surface.subtle),
19
21
  ];
20
22
  }
21
23
  export function renderAnswer(block, width, pal) {
@@ -25,23 +27,17 @@ export function renderAnswer(block, width, pal) {
25
27
  ];
26
28
  }
27
29
  export function renderReasoning(block, width, pal, context = {}) {
28
- const inner = transcriptWidth(width);
29
30
  // Expanding a live stream is deferred until it is sealed. Re-parsing an
30
31
  // ever-growing full thought on every token makes the whole TUI stall.
31
32
  const expanded = block.expanded === true && block.live !== true;
32
33
  const source = !expanded
33
- ? reasoningPreviewSource(block.text, inner)
34
+ ? reasoningPreviewSource(block.text, width)
34
35
  : { text: block.text, truncated: false };
35
- const content = markdown(source.text, inner, pal, inner);
36
+ const content = markdown(source.text, width, pal, width);
36
37
  const visible = expanded ? content : content.slice(-REASONING_PREVIEW_ROWS);
37
38
  return [
38
- ...(context.followsTool === true
39
- ? [row(width, transcriptMark(width, { text: "│", fg: pal.rule }))]
40
- : context.continues === true ? [] : [""]),
41
- ...visible.map((line) => row(width, [
42
- ...transcriptLead(width, { text: "│", fg: pal.rule }),
43
- ...line.segs.map((seg) => ({ ...seg, fg: pal.ink.dim, italic: true })),
44
- ])),
39
+ ...(context.continues === true ? [] : [""]),
40
+ ...visible.map((line) => row(width, line.segs.map((seg) => ({ ...seg, fg: pal.ink.dim, italic: true })))),
45
41
  ];
46
42
  }
47
43
  export function reasoningPreviewSource(text, width) {
@@ -4,7 +4,7 @@ import { fitSegs, hasColor, plainLen, row } from "../../ui/render.js";
4
4
  import { graphemeCeiling, graphemeFloor } from "../../text-boundary.js";
5
5
  import { toolDuration } from "../../duration.js";
6
6
  import { breathe, easeInOut, easeOut, interval, mix, TOOL_BIRTH_MS, TOOL_LEADER_MAX_MS, TOOL_ROW_ARRIVAL_MS, } from "../motion.js";
7
- import { transcriptLead, transcriptMark } from "../transcript-grammar.js";
7
+ import { transcriptLead } from "../transcript-grammar.js";
8
8
  const OUTPUT_ROWS = 8;
9
9
  const LIVE_OUTPUT_ROWS = 6;
10
10
  const DIFF_ROWS = 15;
@@ -25,9 +25,7 @@ export function renderTool(block, width, pal, context = {}) {
25
25
  const right = resultSegments(block, pal, context, now);
26
26
  const leader = movingLeader(width, left, right, pal, context, now);
27
27
  return [
28
- ...(context.followsReasoning === true
29
- ? [row(width, transcriptMark(width, { text: "│", fg: pal.rule }))]
30
- : context.continues === true ? [] : [""]),
28
+ ...(context.continues === true ? [] : [""]),
31
29
  row(width, leader === undefined ? left : [...left, leader], right),
32
30
  ...shown.map(({ detail, sourceIndex }) => renderDetail(detail, block.tone, width, pal, context.reducedMotion === true ? undefined : context.motion?.rowsAt[sourceIndex ?? -1], now)),
33
31
  ];
@@ -2,12 +2,14 @@
2
2
  //
3
3
  // Pure functions over an immutable state — no rendering, no I/O — so the whole
4
4
  // line editor is testable without a terminal.
5
+ import { assertPromptAppend } from "../input-boundary.js";
5
6
  import { graphemes } from "../ui/width.js";
6
7
  export const EMPTY = { text: "", cursor: 0 };
7
8
  export function of(text) {
8
9
  return { text, cursor: text.length };
9
10
  }
10
11
  export function insert(state, chunk) {
12
+ assertPromptAppend(state.text.length, chunk.length);
11
13
  return {
12
14
  text: state.text.slice(0, state.cursor) + chunk + state.text.slice(state.cursor),
13
15
  cursor: state.cursor + chunk.length,
package/dist/tui/keys.js CHANGED
@@ -4,6 +4,7 @@
4
4
  // sequence can be split across reads, so an incomplete one is held rather than
5
5
  // guessed at; and a bracketed paste arrives as a delimited run that must not be
6
6
  // interpreted key by key, or a pasted newline submits half the paste.
7
+ import { MAX_PROMPT_CODE_UNITS } from "../input-boundary.js";
7
8
  const ESC = String.fromCharCode(27);
8
9
  const BS = String.fromCharCode(8);
9
10
  const DEL = String.fromCharCode(127);
@@ -64,6 +65,17 @@ export function decoder(options = {}) {
64
65
  let held = "";
65
66
  let pasting = false;
66
67
  let pasted = "";
68
+ let pasteTooLong = false;
69
+ const appendPaste = (text) => {
70
+ if (pasteTooLong || text === "")
71
+ return;
72
+ if (text.length > MAX_PROMPT_CODE_UNITS - pasted.length) {
73
+ pasted = "";
74
+ pasteTooLong = true;
75
+ return;
76
+ }
77
+ pasted += text;
78
+ };
67
79
  const drain = (final) => {
68
80
  const keys = [];
69
81
  while (held !== "") {
@@ -77,22 +89,26 @@ export function decoder(options = {}) {
77
89
  // paste, then let the normal control-key path handle the byte.
78
90
  held = held.slice(interrupt);
79
91
  pasted = "";
92
+ pasteTooLong = false;
80
93
  pasting = false;
81
94
  continue;
82
95
  }
83
96
  // Hold back a possible partial terminator rather than pasting it.
84
97
  const safe = held.length - PASTE_END.length - 1;
85
98
  if (safe > 0) {
86
- pasted += held.slice(0, safe);
99
+ appendPaste(held.slice(0, safe));
87
100
  held = held.slice(safe);
88
101
  }
89
102
  break;
90
103
  }
91
- pasted += held.slice(0, end);
104
+ appendPaste(held.slice(0, end));
92
105
  held = held.slice(end + PASTE_END.length + 1);
93
106
  pasting = false;
94
- keys.push({ name: "paste", text: pasted, ctrl: false });
107
+ keys.push(pasteTooLong
108
+ ? { name: "input_limit", text: "", ctrl: false }
109
+ : { name: "paste", text: pasted, ctrl: false });
95
110
  pasted = "";
111
+ pasteTooLong = false;
96
112
  continue;
97
113
  }
98
114
  const ch = held[0];
@@ -103,6 +119,7 @@ export function decoder(options = {}) {
103
119
  if (rest.startsWith(PASTE_START)) {
104
120
  held = rest.slice(PASTE_START.length);
105
121
  pasting = true;
122
+ pasteTooLong = false;
106
123
  continue;
107
124
  }
108
125
  const mouse = MOUSE.exec(rest);
@@ -161,14 +178,56 @@ export function decoder(options = {}) {
161
178
  };
162
179
  return {
163
180
  push(chunk) {
164
- held += chunk;
165
- return drain(false);
181
+ // The usual printable run needs no protocol buffering. This also rejects
182
+ // one unbracketed paste atomically before copying it into `held`.
183
+ if (held === "" && !pasting && printable(chunk)) {
184
+ return [chunk.length > MAX_PROMPT_CODE_UNITS
185
+ ? { name: "input_limit", text: "", ctrl: false }
186
+ : { name: "char", text: chunk, ctrl: false }];
187
+ }
188
+ const keys = [];
189
+ const chunkSize = 64 * 1_024;
190
+ for (let from = 0; from < chunk.length;) {
191
+ let to = Math.min(from + chunkSize, chunk.length);
192
+ if (to < chunk.length &&
193
+ isHighSurrogate(chunk.charCodeAt(to - 1)) &&
194
+ isLowSurrogate(chunk.charCodeAt(to))) {
195
+ to++;
196
+ }
197
+ const part = chunk.slice(from, to);
198
+ from = to;
199
+ if (part.length > MAX_PROMPT_CODE_UNITS - held.length) {
200
+ held = "";
201
+ pasted = "";
202
+ pasting = false;
203
+ pasteTooLong = false;
204
+ keys.push({ name: "input_limit", text: "", ctrl: false });
205
+ break;
206
+ }
207
+ held += part;
208
+ keys.push(...drain(false));
209
+ }
210
+ return keys;
166
211
  },
167
212
  flush() {
168
213
  return drain(true);
169
214
  },
170
215
  };
171
216
  }
217
+ function isHighSurrogate(code) {
218
+ return code >= 0xd800 && code <= 0xdbff;
219
+ }
220
+ function isLowSurrogate(code) {
221
+ return code >= 0xdc00 && code <= 0xdfff;
222
+ }
223
+ function printable(text) {
224
+ for (let index = 0; index < text.length; index++) {
225
+ const char = text[index];
226
+ if ((char.codePointAt(0) ?? 0) < 0x20 || char === ESC || char === DEL)
227
+ return false;
228
+ }
229
+ return text !== "";
230
+ }
172
231
  function firstInterrupt(text) {
173
232
  const ctrlC = text.indexOf(String.fromCharCode(3));
174
233
  const ctrlD = text.indexOf(String.fromCharCode(4));
@@ -2,6 +2,7 @@
2
2
  import * as picker from "./picker.js";
3
3
  import { oneLine } from "./field.js";
4
4
  import { applyKey } from "./input.js";
5
+ import { PromptLimitError } from "../input-boundary.js";
5
6
  export function shown(open) {
6
7
  if (open === undefined)
7
8
  return undefined;
@@ -33,10 +34,17 @@ export function handle(open, key) {
33
34
  cancel(open);
34
35
  return {};
35
36
  }
36
- if ("picker" in open)
37
- return handlePicker(open, key);
38
- if ("field" in open)
39
- return handleField(open, key);
37
+ try {
38
+ if ("picker" in open)
39
+ return handlePicker(open, key);
40
+ if ("field" in open)
41
+ return handleField(open, key);
42
+ }
43
+ catch (error) {
44
+ if (error instanceof PromptLimitError)
45
+ return { open, inputLimit: true };
46
+ throw error;
47
+ }
40
48
  return { open };
41
49
  }
42
50
  function handlePicker(open, key) {
@@ -1,6 +1,7 @@
1
1
  // One interaction model for every terminal selector.
2
2
  import { row } from "../ui/render.js";
3
3
  import { elide, graphemes } from "../ui/width.js";
4
+ import { assertPromptAppend } from "../input-boundary.js";
4
5
  import { menuWindow, renderMenuRows } from "./components/menu.js";
5
6
  import { promptCursor, promptLine } from "./components/prompt.js";
6
7
  const WINDOW = 6;
@@ -28,6 +29,7 @@ export function adjust(picker, step) {
28
29
  export function type(picker, text) {
29
30
  if (picker.searchable !== true || text === "")
30
31
  return picker;
32
+ assertPromptAppend((picker.query ?? "").length, text.length);
31
33
  return withQuery(picker, `${picker.query ?? ""}${text}`);
32
34
  }
33
35
  export function backspace(picker) {
@@ -37,7 +37,7 @@ const CURSOR_RESET = `${CSI}0 q`;
37
37
  const SYNC_BEGIN = `${CSI}?2026h`;
38
38
  const SYNC_END = `${CSI}?2026l`;
39
39
  let active = false;
40
- let handlersRegistered = false;
40
+ let restoreHandlersRegistered = false;
41
41
  export function interactive() {
42
42
  return process.stdin.isTTY === true && process.stdout.isTTY === true;
43
43
  }
@@ -54,7 +54,7 @@ export function enter(reducedMotion = false) {
54
54
  if (active)
55
55
  return;
56
56
  active = true;
57
- registerProcessHandlers();
57
+ registerRestoreHandlers();
58
58
  write(ALT_ON + WRAP_OFF + CURSOR_HIDE + (reducedMotion ? CURSOR_STEADY : CURSOR_BLOCK) + PASTE_ON + MOUSE_ON);
59
59
  process.stdin.setRawMode(true);
60
60
  process.stdin.setEncoding("utf8");
@@ -73,24 +73,12 @@ export function setReducedMotion(reducedMotion) {
73
73
  if (active)
74
74
  write(reducedMotion ? CURSOR_STEADY : CURSOR_BLOCK);
75
75
  }
76
- function registerProcessHandlers() {
77
- if (handlersRegistered)
76
+ function registerRestoreHandlers() {
77
+ if (restoreHandlersRegistered)
78
78
  return;
79
- handlersRegistered = true;
79
+ restoreHandlersRegistered = true;
80
80
  process.on("exit", leave);
81
81
  process.on("uncaughtExceptionMonitor", leave);
82
- process.on("SIGTERM", onSigterm);
83
- process.on("SIGHUP", onSighup);
84
- }
85
- function onSigterm() {
86
- onFatalSignal(15);
87
- }
88
- function onSighup() {
89
- onFatalSignal(1);
90
- }
91
- function onFatalSignal(number) {
92
- leave();
93
- process.exit(128 + number);
94
82
  }
95
83
  export function onResize(handler) {
96
84
  process.stdout.on("resize", handler);
@@ -1,4 +1,4 @@
1
- // One semantic margin shared by every transcript block.
1
+ // One semantic margin shared by the user cue and executable tool evidence.
2
2
  const GUTTER = 2;
3
3
  export function transcriptGutter(_width) {
4
4
  return GUTTER;
package/dist/ui/theme.js CHANGED
@@ -1,27 +1,27 @@
1
1
  // Jecode's fixed terminal identity. Components depend on semantic tokens, not
2
2
  // literal colours, while the product exposes one deliberate dark Slate look.
3
- // Jecode's fixed dark Slate baseline. The exported name stays stable while
4
- // cooler, quieter values leave the transcript bright enough to read and let
5
- // live state carry the colour.
3
+ // Jecode's fixed dark Slate baseline. Clear text contrast and luminous
4
+ // structural colours keep long terminal transcripts readable without making
5
+ // live state decorative.
6
6
  // Components depend on these roles rather than embedding presentation values.
7
7
  export const STEEL = {
8
- accent: [124, 164, 222],
9
- technical: [126, 186, 208],
10
- focus: [124, 164, 222],
11
- rule: [44, 60, 78],
8
+ accent: [102, 155, 210],
9
+ technical: [78, 201, 232],
10
+ focus: [102, 155, 210],
11
+ rule: [53, 80, 110],
12
12
  ink: {
13
- fg: [214, 219, 226],
14
- bright: [241, 244, 248],
15
- muted: [149, 160, 174],
16
- dim: [99, 111, 125],
17
- attention: [226, 188, 112],
18
- added: [138, 190, 150],
19
- removed: [223, 120, 120],
13
+ fg: [220, 224, 229],
14
+ bright: [235, 239, 244],
15
+ muted: [156, 169, 183],
16
+ dim: [112, 124, 137],
17
+ attention: [230, 191, 95],
18
+ added: [134, 203, 146],
19
+ removed: [232, 112, 112],
20
20
  },
21
21
  surface: {
22
- subtle: [23, 29, 37],
23
- added: [21, 52, 33],
24
- removed: [58, 24, 27],
25
- attention: [58, 47, 20],
22
+ subtle: [31, 38, 47],
23
+ added: [22, 55, 34],
24
+ removed: [62, 24, 27],
25
+ attention: [62, 50, 19],
26
26
  },
27
27
  };
@@ -0,0 +1,54 @@
1
+ // Bounded synchronous reads for the tiny JSON stores under ~/.jecode.
2
+ import { Buffer } from "node:buffer";
3
+ import { closeSync, constants, fstatSync, openSync, readSync } from "node:fs";
4
+ export const USER_STORE_LIMITS = Object.freeze({
5
+ settingsBytes: 64 * 1_024,
6
+ credentialsBytes: 256 * 1_024,
7
+ accountsBytes: 128 * 1_024,
8
+ credentialEntries: 64,
9
+ credentialName: 256,
10
+ credentialValue: 16_384,
11
+ model: 512,
12
+ endpoint: 2_048,
13
+ accountToken: 32_768,
14
+ accountLabel: 1_024,
15
+ });
16
+ export function readBoundedJsonSync(file, maxBytes) {
17
+ // Non-blocking open keeps a replaced FIFO or device from stalling startup;
18
+ // fstat below then accepts only regular files before any content is read.
19
+ const flags = process.platform === "win32"
20
+ ? "r"
21
+ : constants.O_RDONLY | (constants.O_NONBLOCK ?? 0);
22
+ const descriptor = openSync(file, flags);
23
+ try {
24
+ const details = fstatSync(descriptor);
25
+ if (!details.isFile())
26
+ throw new Error("user store must be a regular file");
27
+ if (!Number.isSafeInteger(details.size) || details.size > maxBytes) {
28
+ throw new Error(`user store exceeds ${maxBytes} bytes`);
29
+ }
30
+ // One extra byte detects an in-place growth after fstat without allowing
31
+ // the read allocation to follow the file's new size.
32
+ const capacity = Math.min(maxBytes + 1, Math.max(1, details.size + 1));
33
+ const bytes = Buffer.allocUnsafe(capacity);
34
+ let offset = 0;
35
+ while (offset < capacity) {
36
+ const count = readSync(descriptor, bytes, offset, capacity - offset, null);
37
+ if (count === 0)
38
+ break;
39
+ offset += count;
40
+ }
41
+ if (offset > maxBytes || offset > details.size) {
42
+ throw new Error("user store changed while it was being read");
43
+ }
44
+ return JSON.parse(bytes.toString("utf8", 0, offset));
45
+ }
46
+ finally {
47
+ closeSync(descriptor);
48
+ }
49
+ }
50
+ export function assertStoreText(text, maxBytes) {
51
+ if (Buffer.byteLength(text, "utf8") > maxBytes) {
52
+ throw new Error(`user store exceeds ${maxBytes} bytes`);
53
+ }
54
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@giovannijecha/jecode",
3
- "version": "0.8.1",
3
+ "version": "0.8.3",
4
4
  "description": "An owned coding agent with zero external runtime dependencies.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -25,7 +25,7 @@
25
25
  "files": [
26
26
  "bin/",
27
27
  "dist/",
28
- "docs/assets/brand/jeco-256.png",
28
+ "assets/jeco-256.png",
29
29
  "LICENSE",
30
30
  "README.md"
31
31
  ],
@@ -44,8 +44,11 @@
44
44
  "pack:release": "npm run build:release && npm pack --ignore-scripts",
45
45
  "start": "node src/main.ts",
46
46
  "tui:lab": "node dev/tui-lab.ts",
47
- "bench:transcript": "node dev/benchmark-transcript.ts",
47
+ "bench:context": "node dev/benchmark-context.ts",
48
+ "bench:redaction": "node dev/benchmark-redaction.ts",
48
49
  "bench:search": "node dev/benchmark-search.ts",
50
+ "bench:session": "node dev/benchmark-session.ts",
51
+ "bench:transcript": "node dev/benchmark-transcript.ts",
49
52
  "typecheck": "tsc --noEmit",
50
53
  "test": "npm run build:release && node --test",
51
54
  "coverage": "npm run build:release && node --test --experimental-test-coverage --test-coverage-include=\"src/**/*.ts\" --test-coverage-lines=80 --test-coverage-branches=75 --test-coverage-functions=75",
File without changes