@giovannijecha/jecode 0.6.0 → 0.7.0

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
@@ -25,7 +25,7 @@
25
25
  <a href="https://github.com/giovannijecha/jecode/releases">Releases</a>
26
26
  </p>
27
27
 
28
- > Jecode is an early 0.6.x release. The core loop is usable today; commands and
28
+ > Jecode is an early 0.7.x release. The core loop is usable today; commands and
29
29
  > terminal interactions may still evolve before 1.0.
30
30
 
31
31
  ## Why Jecode
@@ -201,6 +201,8 @@ Type **/** to open searchable command completion inside the composer.
201
201
  | /models | Search models across every available provider and select one |
202
202
  | /permissions | Change session tool access inline and review remembered approvals |
203
203
  | /new | Close the current conversation, start clean, and reset tool permissions |
204
+ | /timeline | Navigate completed turns and select where the next branch starts |
205
+ | /compact | Compact the active model context immediately |
204
206
  | /export | Save a timestamped Markdown transcript in the launch directory |
205
207
  | /help | Open a temporary keyboard reference in the composer dock |
206
208
  | /exit | Restore the terminal and exit |
@@ -264,10 +266,15 @@ files use owner-only modes on POSIX; Windows relies on the user-profile ACL.
264
266
  `jecode resume` keeps the same durable session identity and advances that
265
267
  session's conversation tree, so reopening and continuing a conversation does
266
268
  not create duplicate picker entries. `/new` or a fresh launch starts another
267
- logical session. Resume never executes an old tool call. If a crash left the
268
- newest turn inside a tool loop, the same session resumes from its latest
269
- completed ancestor and the next turn becomes a branch inside its tree because
270
- provider-only continuation data is intentionally not stored.
269
+ logical session. **/timeline** shows the completed turns in that tree. Selecting
270
+ an earlier turn changes only the visible path; it creates and persists a branch
271
+ only when the next real message is sent. Cancelling the picker or exiting first
272
+ leaves the durable head untouched, and resume returns to the last branch with a
273
+ persisted turn. Historical tools are displayed but never executed. If a crash
274
+ left the newest turn inside a tool loop, the same session resumes from its
275
+ latest completed ancestor and the next turn becomes a branch because
276
+ provider-only continuation data is intentionally not stored. **/export** writes
277
+ only the currently selected path.
271
278
 
272
279
  When the model-facing context approaches the selected model's usable capacity,
273
280
  Jecode asks the provider for one bounded summary of its older prefix and keeps
@@ -281,7 +288,11 @@ session, so resume reuses it instead of summarizing the same prefix again. A
281
288
  failed or cancelled optional summary leaves the original context intact; a
282
289
  definite provider context-limit rejection may trigger one compacted retry.
283
290
  Internal summary requests count toward provider usage but never appear in the
284
- transcript or Markdown export.
291
+ transcript or Markdown export. **/compact** requests the same model-aware,
292
+ branch-local compaction immediately, even below the automatic trigger. Very
293
+ small contexts are left unchanged. After selecting a historical branch point,
294
+ send its first new message before compacting so shared history is never
295
+ rewritten.
285
296
 
286
297
  Jecode has one interface theme: dark Steel. **NO_COLOR** is supported for
287
298
  terminals and pipelines that disable colour.
package/dist/batch.js CHANGED
@@ -7,6 +7,7 @@ import { stdin, stdout } from "node:process";
7
7
  import { runTurn } from "./controller.js";
8
8
  import { resolveContextPolicy } from "./context/capacity.js";
9
9
  import { compactContext } from "./context/compactor.js";
10
+ import { compactSession } from "./context/manual.js";
10
11
  import { isContextOverflow, shouldResolveContextPolicy } from "./context/policy.js";
11
12
  import { handleCommand } from "./commands.js";
12
13
  import { renderBatch } from "./batch-view.js";
@@ -28,7 +29,10 @@ export async function runBatch(session, environment = {}) {
28
29
  if (line === "")
29
30
  continue;
30
31
  if (line.startsWith("/")) {
31
- if ((await handleCommand(line, session, { emit })) === "exit")
32
+ if ((await handleCommand(line, session, {
33
+ emit,
34
+ compact: () => compactSession(session),
35
+ })) === "exit")
32
36
  break;
33
37
  continue;
34
38
  }
package/dist/commands.js CHANGED
@@ -1,9 +1,8 @@
1
1
  // Slash-command registry and dispatcher.
2
2
  //
3
- // One of them does reach the network a menu of models cannot be built
4
- // without asking the provider what it has but none of them ever sends a
5
- // message. Provider access and model selection stay in their own commands;
6
- // this file keeps discovery, dispatch, and local session operations.
3
+ // Model discovery and explicit context compaction can reach a provider, but
4
+ // slash commands never become user messages. This file keeps discovery,
5
+ // dispatch, and session operations outside the canonical transcript.
7
6
  import { ConversationTree } from "./conversation.js";
8
7
  import { modelsCommand } from "./model-command.js";
9
8
  import { providersCommand } from "./provider-commands.js";
@@ -22,6 +21,8 @@ export const COMMANDS = [
22
21
  { name: "exit", blurb: "exit and restore the terminal" },
23
22
  { name: "new", blurb: "start clean and reset tool permissions" },
24
23
  { name: "export", blurb: "save this transcript as Markdown" },
24
+ { name: "timeline", blurb: "navigate this conversation tree" },
25
+ { name: "compact", blurb: "compact the active context now" },
25
26
  { name: "permissions", blurb: "manage session tool access" },
26
27
  { name: "settings", blurb: "change and save jecode defaults" },
27
28
  { name: "effort", blurb: "set the reasoning effort" },
@@ -51,6 +52,39 @@ export async function handleCommand(line, session, host) {
51
52
  session.usage = emptyUsage();
52
53
  host.emit({ kind: "notice", text: "new session", tone: "info" });
53
54
  return "handled";
55
+ case "timeline": {
56
+ if (host.timeline === undefined) {
57
+ host.emit({ kind: "notice", text: "timeline needs the interactive screen", tone: "warn" });
58
+ return "handled";
59
+ }
60
+ const result = await host.timeline();
61
+ if (result === "selected") {
62
+ host.emit({
63
+ kind: "notice",
64
+ text: "branch point selected · send a message to continue",
65
+ tone: "info",
66
+ });
67
+ }
68
+ return "handled";
69
+ }
70
+ case "compact": {
71
+ if (host.compact === undefined) {
72
+ host.emit({ kind: "notice", text: "compact is unavailable here", tone: "warn" });
73
+ return "handled";
74
+ }
75
+ const result = await host.compact();
76
+ if (result === "compacted") {
77
+ host.emit({ kind: "notice", text: "context compacted", tone: "info" });
78
+ }
79
+ else if (result === "branch-pending") {
80
+ host.emit({
81
+ kind: "notice",
82
+ text: "send a message on this branch before compacting",
83
+ tone: "warn",
84
+ });
85
+ }
86
+ return "handled";
87
+ }
54
88
  case "export":
55
89
  if (host.exportTranscript === undefined) {
56
90
  host.emit({ kind: "notice", text: "export needs the interactive screen", tone: "warn" });
@@ -84,9 +118,3 @@ export async function handleCommand(line, session, host) {
84
118
  return "handled";
85
119
  }
86
120
  }
87
- function chooser(host) {
88
- if (host.choose === undefined) {
89
- host.emit({ kind: "notice", text: "that command needs the screen", tone: "warn" });
90
- }
91
- return host.choose;
92
- }
@@ -45,7 +45,9 @@ export async function compactContext(options) {
45
45
  ...(response.usage === undefined ? {} : { usage: response.usage }),
46
46
  };
47
47
  }
48
- catch {
48
+ catch (error) {
49
+ if (options.failLoudly === true)
50
+ throw error;
49
51
  return undefined;
50
52
  }
51
53
  finally {
@@ -0,0 +1,69 @@
1
+ // Explicit compaction of the selected durable leaf.
2
+ //
3
+ // The canonical messages and transcript remain untouched. Only the active
4
+ // leaf receives a new branch-local context anchor, using the same provider
5
+ // policy and summarizer as automatic compaction.
6
+ import { recordAuxiliaryUsage } from "../usage.js";
7
+ import { resolveContextPolicy } from "./capacity.js";
8
+ import { compactContext } from "./compactor.js";
9
+ import { estimateTokens, planCompaction } from "./policy.js";
10
+ const MIN_PREFIX_TOKENS = 512;
11
+ export async function compactSession(session, options = {}) {
12
+ const active = session.conversation.activeNode;
13
+ if (active === undefined)
14
+ return "unchanged";
15
+ const context = session.conversation.contextHistory;
16
+ if (estimateTokens(context) < MIN_PREFIX_TOKENS)
17
+ return "unchanged";
18
+ if (session.conversation.nodes.some((node) => node.parentId === active.id)) {
19
+ throw new Error("continue this branch before compacting");
20
+ }
21
+ options.onStatus?.("Checking context");
22
+ const policy = await resolveContextPolicy({
23
+ provider: session.provider,
24
+ model: session.model,
25
+ compactionPercent: session.config.compactionPercent,
26
+ signal: options.signal,
27
+ onStatus: (status) => options.onStatus?.(status),
28
+ });
29
+ const coveredMessages = active.context?.throughNodeId === active.id
30
+ ? active.context.messageCount
31
+ : 0;
32
+ const plan = planCompaction(context, active.messages, coveredMessages, session.usage.lastInputTokens, true, policy);
33
+ if (plan === undefined || estimateTokens(plan.prefix) < MIN_PREFIX_TOKENS) {
34
+ options.onStatus?.();
35
+ return "unchanged";
36
+ }
37
+ const result = await compactContext({
38
+ provider: session.provider,
39
+ model: session.model,
40
+ effort: session.config.effort,
41
+ context,
42
+ turn: active.messages,
43
+ nodeId: active.id,
44
+ coveredMessages,
45
+ lastInputTokens: session.usage.lastInputTokens,
46
+ signal: options.signal,
47
+ force: true,
48
+ failLoudly: true,
49
+ policy,
50
+ onBegin: () => options.onStatus?.("Compacting"),
51
+ onEnd: () => options.onStatus?.(),
52
+ });
53
+ if (result === undefined)
54
+ throw new Error("context could not be compacted");
55
+ if (result.usage !== undefined)
56
+ recordAuxiliaryUsage(session.usage, result.usage);
57
+ const next = session.conversation.commit({
58
+ nodeId: active.id,
59
+ parentId: active.parentId,
60
+ createdAt: active.createdAt,
61
+ identity: active.identity,
62
+ messages: active.messages,
63
+ blocks: active.blocks,
64
+ context: result.anchor,
65
+ }, active.settlement);
66
+ await session.persistence?.checkpoint(next);
67
+ session.conversation = next;
68
+ return "compacted";
69
+ }
@@ -202,7 +202,7 @@ async function compactionSetting(session, host) {
202
202
  const label = "context compaction";
203
203
  const field = {
204
204
  title: heading(label, `${MIN_COMPACTION_PERCENT}-${MAX_COMPACTION_PERCENT} percent`, session.palette),
205
- right: "enter save · esc back",
205
+ right: "enter save · esc back",
206
206
  editor: of(String(session.config.compactionPercent)),
207
207
  secret: false,
208
208
  note: "Compacts when model context reaches this percentage.",
@@ -0,0 +1,90 @@
1
+ // A compact, read-only projection of the durable conversation tree.
2
+ //
3
+ // Selecting a node changes only the in-memory path. The next real user turn
4
+ // is what persists a branch, so opening or cancelling this control plane can
5
+ // never create empty history.
6
+ import { usageFromHistory } from "./usage.js";
7
+ import { heading } from "./tui/picker.js";
8
+ export function timelinePicker(conversation, palette) {
9
+ const entries = timelineEntries(conversation);
10
+ const selectedId = conversation.latestCompleted()?.activeNodeId ?? 0;
11
+ const index = Math.max(0, entries.findIndex((entry) => entry.node.id === selectedId));
12
+ return Object.freeze({
13
+ picker: {
14
+ title: heading("timeline", "conversation tree", palette),
15
+ searchable: true,
16
+ query: "",
17
+ visible: 8,
18
+ options: entries.map((entry) => ({
19
+ label: `${entry.prefix}${preview(entry.node)}`,
20
+ hint: stamp(entry.node.createdAt),
21
+ ...(entry.node.id === selectedId ? { value: "active" } : {}),
22
+ })),
23
+ index,
24
+ },
25
+ nodeIds: Object.freeze(entries.map((entry) => entry.node.id)),
26
+ });
27
+ }
28
+ export async function selectTimeline(session, choose) {
29
+ const timeline = timelinePicker(session.conversation, session.palette);
30
+ if (timeline.nodeIds.length === 0)
31
+ return false;
32
+ const index = await choose(timeline.picker);
33
+ const nodeId = index === undefined ? undefined : timeline.nodeIds[index];
34
+ if (nodeId === undefined || nodeId === session.conversation.activeNodeId)
35
+ return false;
36
+ session.conversation = session.conversation.select(nodeId);
37
+ session.usage = usageFromHistory(session.conversation.history);
38
+ return true;
39
+ }
40
+ function timelineEntries(conversation) {
41
+ const completed = conversation.nodes.filter((node) => node.settlement === "completed");
42
+ const completedIds = new Set(completed.map((node) => node.id));
43
+ const children = new Map();
44
+ for (const node of completed) {
45
+ let parentId = node.parentId;
46
+ while (parentId !== 0 && !completedIds.has(parentId)) {
47
+ parentId = conversation.node(parentId)?.parentId ?? 0;
48
+ }
49
+ const siblings = children.get(parentId) ?? [];
50
+ siblings.push(node);
51
+ children.set(parentId, siblings);
52
+ }
53
+ const entries = [];
54
+ const visit = (parentId, lanes) => {
55
+ const siblings = children.get(parentId) ?? [];
56
+ for (let index = 0; index < siblings.length; index++) {
57
+ const node = siblings[index];
58
+ const forks = siblings.length > 1;
59
+ const last = index === siblings.length - 1;
60
+ const hidden = Math.max(0, lanes.length - 4);
61
+ const lanePrefix = `${hidden === 0 ? "" : "… "}${lanes.slice(hidden)
62
+ .map((closed) => closed ? " " : "│ ").join("")}`;
63
+ entries.push({
64
+ node,
65
+ prefix: `${lanePrefix}${forks ? (last ? "└─ " : "├─ ") : "• "}`,
66
+ });
67
+ visit(node.id, forks ? [...lanes, last] : lanes);
68
+ }
69
+ };
70
+ visit(0, []);
71
+ return entries;
72
+ }
73
+ function preview(node) {
74
+ for (const message of node.messages) {
75
+ if (message.role !== "user")
76
+ continue;
77
+ const text = message.content.find((block) => block.kind === "text")?.text
78
+ .replace(/\s+/gu, " ").trim();
79
+ if (text !== undefined && text !== "")
80
+ return text.slice(0, 160);
81
+ }
82
+ return "Untitled turn";
83
+ }
84
+ function stamp(value) {
85
+ const date = new Date(value);
86
+ if (!Number.isFinite(date.getTime()))
87
+ return value.replace("T", " ").slice(0, 16);
88
+ const two = (part) => String(part).padStart(2, "0");
89
+ return `${two(date.getHours())}:${two(date.getMinutes())}`;
90
+ }
@@ -13,5 +13,6 @@ export function appState() {
13
13
  draft: "",
14
14
  spin: 0,
15
15
  closeWhenIdle: false,
16
+ committedNodeId: 0,
16
17
  };
17
18
  }
@@ -3,10 +3,12 @@ import { handleCommand } from "../commands.js";
3
3
  import { runTurn } from "../controller.js";
4
4
  import { resolveContextPolicy } from "../context/capacity.js";
5
5
  import { compactContext } from "../context/compactor.js";
6
+ import { compactSession } from "../context/manual.js";
6
7
  import { isContextOverflow, shouldResolveContextPolicy } from "../context/policy.js";
7
8
  import { updateSettings } from "../settings.js";
8
9
  import { saveTranscript } from "../transcript-export.js";
9
10
  import { recordAuxiliaryUsage, recordUsage } from "../usage.js";
11
+ import { selectTimeline } from "../timeline.js";
10
12
  import { answerAt } from "./approve.js";
11
13
  import { cancel as cancelOpen } from "./overlay.js";
12
14
  import { controllerOptions, turnFailure } from "./session-view.js";
@@ -14,6 +16,10 @@ import { transcribe } from "./turn.js";
14
16
  const WAITING = "Waiting";
15
17
  export function appWorkflows(options) {
16
18
  const { session, state, permissions, feedback } = options;
19
+ const choose = (picker) => new Promise((resolve) => {
20
+ state.open = { picker, settle: resolve };
21
+ options.render();
22
+ });
17
23
  async function command(text) {
18
24
  const activity = options.startActivity("command", `Running ${text.split(/\s+/)[0]}`);
19
25
  if (activity === undefined)
@@ -26,10 +32,7 @@ export function appWorkflows(options) {
26
32
  state.open = { help: true, settle: resolve };
27
33
  options.render();
28
34
  }),
29
- choose: (picker) => new Promise((resolve) => {
30
- state.open = { picker, settle: resolve };
31
- options.render();
32
- }),
35
+ choose,
33
36
  dismiss: () => {
34
37
  state.open = state.open === undefined ? undefined : cancelOpen(state.open);
35
38
  options.render();
@@ -51,6 +54,7 @@ export function appWorkflows(options) {
51
54
  state.follow = true;
52
55
  state.unseen = 0;
53
56
  state.lastMaxScroll = 0;
57
+ state.committedNodeId = 0;
54
58
  },
55
59
  permissions,
56
60
  exportTranscript: () => saveTranscript(options.transcriptRoot, state.blocks),
@@ -58,6 +62,27 @@ export function appWorkflows(options) {
58
62
  await updateSettings(patch);
59
63
  },
60
64
  refreshSettings: options.refreshSettings,
65
+ timeline: async () => {
66
+ const selected = await selectTimeline(session, choose);
67
+ if (!selected)
68
+ return "unchanged";
69
+ options.replaceTranscript();
70
+ return session.conversation.activeNodeId === state.committedNodeId
71
+ ? "unchanged"
72
+ : "selected";
73
+ },
74
+ compact: async () => {
75
+ if (session.conversation.activeNodeId !== state.committedNodeId) {
76
+ return "branch-pending";
77
+ }
78
+ return compactSession(session, {
79
+ signal: activity.control.signal,
80
+ onStatus: (status) => {
81
+ state.status = status ?? activity.label;
82
+ options.render();
83
+ },
84
+ });
85
+ },
61
86
  });
62
87
  if (outcome === "exit")
63
88
  state.closeWhenIdle = true;
@@ -128,6 +153,7 @@ export function appWorkflows(options) {
128
153
  await session.persistence?.checkpoint(next);
129
154
  session.conversation = next;
130
155
  nodeId = next.activeNodeId;
156
+ state.committedNodeId = next.activeNodeId;
131
157
  };
132
158
  const compact = async (checkpoint, projected, reason, error) => {
133
159
  if (reason === "overflow" && (error === undefined || !isContextOverflow(error))) {
package/dist/tui/app.js CHANGED
@@ -32,6 +32,7 @@ export async function runApp(session, transcriptRoot, environment = {}) {
32
32
  const workspace = await workspaceLabel(session.config.root);
33
33
  const state = appState();
34
34
  state.blocks.push(...session.conversation.transcript);
35
+ state.committedNodeId = session.conversation.activeNodeId;
35
36
  const permissions = sessionPermissions(session.tools, session.config.autoApprove);
36
37
  let closed;
37
38
  let frameTimer;
@@ -116,6 +117,16 @@ export async function runApp(session, transcriptRoot, environment = {}) {
116
117
  if (state.follow)
117
118
  state.unseen = 0;
118
119
  };
120
+ const replaceTranscript = () => {
121
+ state.blocks.splice(0, state.blocks.length, ...session.conversation.transcript);
122
+ state.scroll = 0;
123
+ state.follow = true;
124
+ state.unseen = 0;
125
+ state.lastMaxScroll = 0;
126
+ transcript.invalidate();
127
+ paint.invalidate();
128
+ render();
129
+ };
119
130
  function quit() {
120
131
  if (!live)
121
132
  return;
@@ -187,6 +198,7 @@ export async function runApp(session, transcriptRoot, environment = {}) {
187
198
  emit,
188
199
  commandNotice,
189
200
  render,
201
+ replaceTranscript,
190
202
  refreshSettings: () => {
191
203
  terminal.setReducedMotion(session.config.reducedMotion);
192
204
  paint.invalidate();
@@ -229,14 +241,9 @@ export async function runApp(session, transcriptRoot, environment = {}) {
229
241
  return;
230
242
  try {
231
243
  await launch.open(candidate.id);
232
- state.blocks.splice(0, state.blocks.length, ...session.conversation.transcript);
244
+ replaceTranscript();
245
+ state.committedNodeId = session.conversation.activeNodeId;
233
246
  state.past.length = 0;
234
- state.scroll = 0;
235
- state.follow = true;
236
- state.unseen = 0;
237
- state.lastMaxScroll = 0;
238
- transcript.invalidate();
239
- paint.invalidate();
240
247
  finishActivity(activity);
241
248
  return;
242
249
  }
@@ -5,11 +5,13 @@ export function renderNotice(block, width, pal) {
5
5
  warn: pal.ink.attention,
6
6
  error: pal.ink.removed,
7
7
  };
8
- const mark = block.tone === "error" ? "× " : block.tone === "warn" ? "! " : "· ";
8
+ const mark = block.tone === "error" ? "× " : block.tone === "warn" ? "! " : undefined;
9
9
  return [
10
10
  "",
11
11
  ...wrap(block.text, Math.max(1, width - 3)).map((line, index) => row(width, [
12
- { text: index === 0 ? mark : " ", fg: fg[block.tone], bold: index === 0 },
12
+ ...(mark === undefined
13
+ ? []
14
+ : [{ text: index === 0 ? mark : " ", fg: fg[block.tone], bold: index === 0 }]),
13
15
  { text: line, fg: fg[block.tone] },
14
16
  ], [], undefined, 1)),
15
17
  ];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@giovannijecha/jecode",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "An owned coding agent with zero external runtime dependencies.",
5
5
  "license": "MIT",
6
6
  "repository": {