@giovannijecha/jecode 0.6.0 → 0.7.1

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/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
- }
package/dist/config.js CHANGED
@@ -3,29 +3,24 @@ import * as path from "node:path";
3
3
  import { DEFAULT_COMPACTION_PERCENT, MAX_COMPACTION_PERCENT, MIN_COMPACTION_PERCENT, } from "./context/policy.js";
4
4
  import { EFFORTS, readSettings } from "./settings.js";
5
5
  import { parseOllamaEndpoint } from "./providers/ollama-endpoint.js";
6
- const FLAGS = [
6
+ const VALUE_FLAGS = [
7
7
  "provider",
8
8
  "model",
9
9
  "ollama-host",
10
- "reduced-motion",
11
10
  "effort",
12
11
  "max-tokens",
13
12
  "max-steps",
14
13
  "compaction-percent",
15
14
  "root",
15
+ ];
16
+ const BOOLEAN_FLAGS = [
17
+ "reduced-motion",
16
18
  "auto-approve",
17
19
  "ephemeral",
18
20
  ];
21
+ const FLAGS = [...VALUE_FLAGS, ...BOOLEAN_FLAGS];
19
22
  export function loadConfig(argv, saved = readSettings()) {
20
23
  const flags = parseFlags(argv);
21
- // A flag nobody declared is a typo. Swallowed in silence it becomes a
22
- // setting the user believes is on, and the run that ignores it looks like
23
- // the feature is broken rather than misspelled.
24
- for (const name of Object.keys(flags)) {
25
- if (!FLAGS.includes(name)) {
26
- throw new Error(`unknown flag --${name} (known: ${FLAGS.map((f) => `--${f}`).join(", ")})`);
27
- }
28
- }
29
24
  const providerId = pick(flags.provider, process.env.JECODE_PROVIDER, saved.provider ?? "anthropic");
30
25
  const ollamaHost = optional(flags["ollama-host"], process.env.OLLAMA_HOST, saved.ollamaHost);
31
26
  const effort = pick(flags.effort, process.env.JECODE_EFFORT, saved.effort ?? "high");
@@ -45,7 +40,9 @@ export function loadConfig(argv, saved = readSettings()) {
45
40
  maxSteps: toInt(pick(flags["max-steps"], process.env.JECODE_MAX_STEPS, String(saved.maxSteps ?? 40)), "max-steps"),
46
41
  compactionPercent: toPercent(pick(flags["compaction-percent"], process.env.JECODE_COMPACTION_PERCENT, String(saved.compactionPercent ?? DEFAULT_COMPACTION_PERCENT))),
47
42
  root: path.resolve(pick(flags.root, undefined, process.cwd())),
48
- autoApprove: flags["auto-approve"] === "true" || process.env.JECODE_AUTO_APPROVE === "1",
43
+ autoApprove: flags["auto-approve"] === "true" ||
44
+ flags["auto-approve"] === "1" ||
45
+ process.env.JECODE_AUTO_APPROVE === "1",
49
46
  ephemeral: bool(flags.ephemeral, process.env.JECODE_EPHEMERAL, false),
50
47
  };
51
48
  }
@@ -85,27 +82,55 @@ function toPercent(value) {
85
82
  }
86
83
  return percent;
87
84
  }
88
- // Accepts --key value, --key=value, and bare --flag (which reads as "true").
85
+ // Value flags accept --key value and --key=value. Boolean flags are bare or
86
+ // take a real boolean value, so an accidental positional argument is never
87
+ // swallowed as configuration.
89
88
  function parseFlags(argv) {
90
89
  const flags = {};
91
90
  for (let i = 0; i < argv.length; i++) {
92
91
  const arg = argv[i];
93
- if (arg === undefined || !arg.startsWith("--"))
92
+ if (arg === undefined)
94
93
  continue;
94
+ if (!arg.startsWith("--"))
95
+ throw new Error(`unexpected argument "${arg}"`);
95
96
  const body = arg.slice(2);
96
97
  const eq = body.indexOf("=");
97
- if (eq !== -1) {
98
- flags[body.slice(0, eq)] = body.slice(eq + 1);
98
+ const name = eq === -1 ? body : body.slice(0, eq);
99
+ const inline = eq === -1 ? undefined : body.slice(eq + 1);
100
+ if (!FLAGS.includes(name)) {
101
+ throw new Error(`unknown flag --${name} (known: ${FLAGS.map((flag) => `--${flag}`).join(", ")})`);
102
+ }
103
+ if (BOOLEAN_FLAGS.includes(name)) {
104
+ if (inline === undefined) {
105
+ const next = argv[i + 1];
106
+ if (next !== undefined && ["true", "false", "1", "0"].includes(next)) {
107
+ flags[name] = next;
108
+ i++;
109
+ }
110
+ else {
111
+ flags[name] = "true";
112
+ }
113
+ }
114
+ else if (["true", "false", "1", "0"].includes(inline)) {
115
+ flags[name] = inline;
116
+ }
117
+ else {
118
+ throw new Error(`--${name} must be true or false`);
119
+ }
99
120
  continue;
100
121
  }
101
- const next = argv[i + 1];
102
- if (next !== undefined && !next.startsWith("--")) {
103
- flags[body] = next;
104
- i++;
122
+ if (inline !== undefined) {
123
+ if (inline === "")
124
+ throw new Error(`--${name} requires a value`);
125
+ flags[name] = inline;
126
+ continue;
105
127
  }
106
- else {
107
- flags[body] = "true";
128
+ const next = argv[i + 1];
129
+ if (next === undefined || next.startsWith("--")) {
130
+ throw new Error(`--${name} requires a value`);
108
131
  }
132
+ flags[name] = next;
133
+ i++;
109
134
  }
110
135
  return flags;
111
136
  }
@@ -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
+ }
@@ -40,17 +40,21 @@ export function credentialRedactor(source = process.env) {
40
40
  const ready = [];
41
41
  let at = 0;
42
42
  while (at < combined.length) {
43
+ const rest = combined.slice(at);
44
+ // A complete shorter credential can also be the prefix of a longer
45
+ // one. Hold that ambiguous suffix until the next chunk proves which
46
+ // value arrived, otherwise the longer credential leaks its tail.
47
+ if (rest.length < longest &&
48
+ values.some((value) => value.length > rest.length && value.startsWith(rest))) {
49
+ pending = rest;
50
+ return ready.join("");
51
+ }
43
52
  const complete = values.find((value) => combined.startsWith(value, at));
44
53
  if (complete !== undefined) {
45
54
  ready.push(REDACTED);
46
55
  at += complete.length;
47
56
  continue;
48
57
  }
49
- const rest = combined.slice(at);
50
- if (rest.length < longest && values.some((value) => value.startsWith(rest))) {
51
- pending = rest;
52
- return ready.join("");
53
- }
54
58
  ready.push(combined[at]);
55
59
  at++;
56
60
  }
@@ -1,5 +1,6 @@
1
1
  // Translation between the normalized vocabulary and the Anthropic wire shape.
2
2
  // Pure functions, no I/O — which is what makes them testable without a key.
3
+ import { wireTokenCount } from "./wire-usage.js";
3
4
  export function toWireTool(tool) {
4
5
  return { name: tool.name, description: tool.description, input_schema: tool.input };
5
6
  }
@@ -70,10 +71,10 @@ function normalizeUsage(data) {
70
71
  if (usage === undefined)
71
72
  return undefined;
72
73
  return {
73
- inputTokens: usage.input_tokens ?? 0,
74
- outputTokens: usage.output_tokens ?? 0,
75
- cachedInputTokens: usage.cache_read_input_tokens ?? 0,
76
- cacheWriteInputTokens: usage.cache_creation_input_tokens ?? 0,
74
+ inputTokens: wireTokenCount(usage.input_tokens),
75
+ outputTokens: wireTokenCount(usage.output_tokens),
76
+ cachedInputTokens: wireTokenCount(usage.cache_read_input_tokens),
77
+ cacheWriteInputTokens: wireTokenCount(usage.cache_creation_input_tokens),
77
78
  reasoningTokens: 0,
78
79
  };
79
80
  }
@@ -6,6 +6,7 @@
6
6
  // Chat Completions differs on two points that matter here — a tool result is a
7
7
  // message of its own with role "tool", not a block inside a user turn, and tool
8
8
  // arguments travel as a JSON string rather than an object.
9
+ import { wireTokenCount } from "./wire-usage.js";
9
10
  export function toWireTool(tool) {
10
11
  return {
11
12
  type: "function",
@@ -83,8 +84,8 @@ function normalizeUsage(reply) {
83
84
  if (reply.usage === undefined)
84
85
  return undefined;
85
86
  return {
86
- inputTokens: reply.usage.prompt_tokens ?? 0,
87
- outputTokens: reply.usage.completion_tokens ?? 0,
87
+ inputTokens: wireTokenCount(reply.usage.prompt_tokens),
88
+ outputTokens: wireTokenCount(reply.usage.completion_tokens),
88
89
  cachedInputTokens: 0,
89
90
  cacheWriteInputTokens: 0,
90
91
  reasoningTokens: 0,
@@ -1,6 +1,7 @@
1
1
  // Translation between the normalized vocabulary and the OpenAI Responses wire
2
2
  // shape: a flat `input` list where tool calls and their outputs are top-level
3
3
  // items keyed by `call_id`, rather than blocks nested inside a message.
4
+ import { wireTokenCount } from "./wire-usage.js";
4
5
  export function toWireTool(tool) {
5
6
  return {
6
7
  type: "function",
@@ -83,11 +84,11 @@ function normalizeUsage(data) {
83
84
  if (usage === undefined || usage === null)
84
85
  return undefined;
85
86
  return {
86
- inputTokens: usage.input_tokens ?? 0,
87
- outputTokens: usage.output_tokens ?? 0,
88
- cachedInputTokens: usage.input_tokens_details?.cached_tokens ?? 0,
89
- cacheWriteInputTokens: usage.input_tokens_details?.cache_write_tokens ?? 0,
90
- reasoningTokens: usage.output_tokens_details?.reasoning_tokens ?? 0,
87
+ inputTokens: wireTokenCount(usage.input_tokens),
88
+ outputTokens: wireTokenCount(usage.output_tokens),
89
+ cachedInputTokens: wireTokenCount(usage.input_tokens_details?.cached_tokens),
90
+ cacheWriteInputTokens: wireTokenCount(usage.input_tokens_details?.cache_write_tokens),
91
+ reasoningTokens: wireTokenCount(usage.output_tokens_details?.reasoning_tokens),
91
92
  };
92
93
  }
93
94
  // Arguments arrive as a JSON string and models vary in how they escape it, so
@@ -0,0 +1,6 @@
1
+ // Usage comes from remote JSON and eventually reaches the strict session
2
+ // codec. Normalize it at the wire boundary so one malformed counter cannot
3
+ // make an otherwise valid saved session unreadable.
4
+ export function wireTokenCount(value) {
5
+ return typeof value === "number" && Number.isSafeInteger(value) && value >= 0 ? value : 0;
6
+ }
@@ -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))) {