@orbit-intelligence/orbit-agent 0.3.15 → 0.3.16

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.
@@ -23,6 +23,12 @@ ${extras.conventions}`
23
23
  When a user request matches one of these skills, load its instructions before starting. To load a skill body your model can call the \`load_project_skill\` tool; use the skill's name exactly as listed.
24
24
  ${extras.skills.map((s) => `- \`${s.name}\` — ${s.summary}`).join('\n')}`
25
25
  : '';
26
+ const memoryBlock = extras?.memory && extras.memory.trim().length > 0
27
+ ? `
28
+ # Persistent memory
29
+ Facts the user asked you to remember across sessions (MEMORY.md). Treat these as durable context; update them only via the /remember command or explicit user request.
30
+ ${extras.memory.trim()}`
31
+ : '';
26
32
  return `You are orbit-agent, an interactive terminal agent for software engineering tasks. You help users safely and efficiently, using the tools below and following these instructions strictly.
27
33
 
28
34
  # Operating environment
@@ -33,6 +39,7 @@ ${extras.skills.map((s) => `- \`${s.name}\` — ${s.summary}`).join('\n')}`
33
39
  ${termuxBlock}
34
40
  ${conventionsBlock}
35
41
  ${skillsBlock}
42
+ ${memoryBlock}
36
43
 
37
44
  # Core mandates
38
45
  - Conventions: Rigorously adhere to existing project conventions when reading or modifying code. Analyze surrounding code, tests, and configuration first.
@@ -37,7 +37,13 @@ export async function runOrchestrateCmd(opts) {
37
37
  toolTimeoutMs: config.tools?.timeoutMs ?? 30_000,
38
38
  streamTimeoutMs: config.runtime?.streamTimeoutMs ?? 120_000,
39
39
  };
40
- const registry = new ToolRegistry({ cwd, canWrite: true, shell: config.tools.shell });
40
+ const registry = new ToolRegistry({
41
+ cwd,
42
+ canWrite: true,
43
+ shell: config.tools.shell,
44
+ filesystem: config.tools.filesystem,
45
+ search: config.tools.search,
46
+ });
41
47
  registerOrchestrationTools(registry);
42
48
  const app = new TuiApp({
43
49
  config,
@@ -17,8 +17,9 @@ import { hasAnySecrets } from '../core/llm/secrets.js';
17
17
  import { runWizard } from '../setup/wizard.js';
18
18
  import { createSession, saveSession, pruneSessions } from '../session/store.js';
19
19
  import { createEventLog, appendEvent, readEventLog } from '../session/event-log.js';
20
- import { loadProjectContext } from '../core/project-context.js';
20
+ import { loadProjectContext, appendProjectMemory } from '../core/project-context.js';
21
21
  import { createSkillLoaderTool } from '../core/skill-loader.js';
22
+ import { newMessage } from '../core/types.js';
22
23
  const THEME_NAMES = ['tokyonight', 'catppuccin-mocha', 'catppuccin-latte', 'nord', 'gruvbox', 'monokai', 'clean-dark'];
23
24
  const PROVIDER_IDS = ['orbitx', 'groq', 'gemini', 'openrouter', 'openai', 'anthropic', 'grok', 'deepseek', 'ollama'];
24
25
  export async function main(argv) {
@@ -135,14 +136,16 @@ export async function main(argv) {
135
136
  console.error(`⚠ No models available for routing.\n ${tip}\n`);
136
137
  return 1;
137
138
  }
138
- // Project context: AGENTS.md conventions + .orbit/skills. Injected into the
139
- // system prompt; skills exposed both to the model (load_project_skill) and
140
- // the /skills command.
141
- const project = loadProjectContext(cwd);
142
- const systemPrompt = buildSystemPrompt(cfg, cwd, {
139
+ // Project context: AGENTS.md conventions + MEMORY.md + .orbit/skills.
140
+ // Injected into the system prompt; skills exposed both to the model
141
+ // (load_project_skill) and the /skills command.
142
+ let project = loadProjectContext(cwd);
143
+ const buildPrompt = () => buildSystemPrompt(cfg, cwd, {
143
144
  conventions: project.conventions,
145
+ memory: project.memory,
144
146
  skills: project.skills.map((s) => ({ name: s.name, summary: s.summary })),
145
147
  });
148
+ let systemPrompt = buildPrompt();
146
149
  if (args.orchestrate) {
147
150
  const { runOrchestrateCmd } = await import('./orchestrate.js');
148
151
  return runOrchestrateCmd({
@@ -154,7 +157,13 @@ export async function main(argv) {
154
157
  });
155
158
  }
156
159
  const context = new ContextManager(systemPrompt);
157
- const registry = new ToolRegistry({ cwd, canWrite: true, shell: cfg.tools.shell });
160
+ const registry = new ToolRegistry({
161
+ cwd,
162
+ canWrite: true,
163
+ shell: cfg.tools.shell,
164
+ filesystem: cfg.tools.filesystem,
165
+ search: cfg.tools.search,
166
+ });
158
167
  let session = createSession(new Date().toLocaleString(), cwd);
159
168
  if (args.session) {
160
169
  const { loadSession } = await import('../session/store.js');
@@ -212,6 +221,25 @@ export async function main(argv) {
212
221
  cfg.permissions.mode = 'allow';
213
222
  saveConfig(cfg);
214
223
  return null;
224
+ case 'remember': {
225
+ const note = cmd.value?.trim();
226
+ if (!note)
227
+ return 'Usage: /remember <note>';
228
+ const path = appendProjectMemory(cwd, note);
229
+ if (path === null)
230
+ return 'Could not write MEMORY.md.';
231
+ // Reload memory and refresh the live system prompt without a restart.
232
+ project = loadProjectContext(cwd);
233
+ systemPrompt = buildPrompt();
234
+ context.setSystemPrompt(systemPrompt);
235
+ return `Remembered → ${path}`;
236
+ }
237
+ case 'compact': {
238
+ if (context.history().length === 0)
239
+ return 'Nothing to compact yet.';
240
+ const result = await compactContext(bus, router, context, cfg.agent.contextBudgetTokens);
241
+ return result;
242
+ }
215
243
  default:
216
244
  return null;
217
245
  }
@@ -329,10 +357,17 @@ async function runSingleShot(cfg, cwd, prompt, autoAllow) {
329
357
  const project = loadProjectContext(cwd);
330
358
  const systemPrompt = buildSystemPrompt(cfg, cwd, {
331
359
  conventions: project.conventions,
360
+ memory: project.memory,
332
361
  skills: project.skills.map((s) => ({ name: s.name, summary: s.summary })),
333
362
  });
334
363
  const context = new ContextManager(systemPrompt);
335
- const registry = new ToolRegistry({ cwd, canWrite: true, shell: cfg.tools.shell });
364
+ const registry = new ToolRegistry({
365
+ cwd,
366
+ canWrite: true,
367
+ shell: cfg.tools.shell,
368
+ filesystem: cfg.tools.filesystem,
369
+ search: cfg.tools.search,
370
+ });
336
371
  registry.register(createSkillLoaderTool(project));
337
372
  const permissionMode = autoAllow ? 'allow' : cfg.permissions.mode;
338
373
  const permissions = new PermissionManager({ ...cfg.permissions, mode: permissionMode }, { ask: (q) => askConsole(q) });
@@ -399,3 +434,62 @@ function emitRoute(bus, router, cfg) {
399
434
  bus.emit('onRoute', { ...firstRoute, strategy: cfg.routing.strategy });
400
435
  }
401
436
  }
437
+ // ---------------------------------------------------------------------------
438
+ // /compact — summarize the older turns with the model, keep the current turn.
439
+ // ---------------------------------------------------------------------------
440
+ const SUMMARIZE_SYSTEM = `You are a conversation summarizer for a coding-agent session. Produce a terse, structured summary of the conversation transcript you are given: a short Objective, Important Details (exact file paths, command names, and file:line references), Work State (Completed / Active / Blocked), and Next Move. Preserve hard facts verbatim where possible — do not invent state. Keep it under ~400 words. Output only the summary.`;
441
+ /**
442
+ * Compact the conversation: summarize everything before the current turn via
443
+ * the router, replace it with a single summary message, and emit
444
+ * onContextSummary. Falls back to drop-only compaction when the model call
445
+ * fails or there is nothing to summarize.
446
+ */
447
+ async function compactContext(bus, router, context, budget) {
448
+ const { older, current } = context.splitCurrentTurn();
449
+ if (older.length === 0) {
450
+ // Single-turn context: nothing older to summarize; drop-only path.
451
+ const res = context.compact(budget);
452
+ bus.emit('onContextSummary', res);
453
+ return res.droppedPairs > 0
454
+ ? `Compacted (drop-only): dropped ${res.droppedPairs} turns · ${res.tokensBefore} → ${res.tokensAfter} tokens.`
455
+ : 'Context already fits the budget.';
456
+ }
457
+ const tokensBefore = context.estimateTokens();
458
+ try {
459
+ const summary = await summarizeWith(router, older);
460
+ if (summary && summary.trim().length > 0) {
461
+ const summaryMsg = newMessage(`sum_${Date.now()}`, 'user', summary);
462
+ context.replaceHistory([summaryMsg, ...current]);
463
+ const tokensAfter = context.estimateTokens();
464
+ const droppedPairs = older.filter((m) => m.role === 'user').length;
465
+ bus.emit('onContextSummary', { droppedPairs, tokensBefore, tokensAfter });
466
+ return `Compacted with model summary: ${droppedPairs} older turns replaced · ${tokensBefore} → ${tokensAfter} tokens.`;
467
+ }
468
+ }
469
+ catch {
470
+ // model summarization unavailable — fall through to drop-only
471
+ }
472
+ const res = context.compact(budget);
473
+ bus.emit('onContextSummary', res);
474
+ return res.droppedPairs > 0
475
+ ? `Model compact failed; dropped ${res.droppedPairs} turns instead · ${res.tokensBefore} → ${res.tokensAfter} tokens.`
476
+ : 'Context already fits the budget.';
477
+ }
478
+ /** Stream a structured summary of `older` through the router, returning its text. */
479
+ async function summarizeWith(router, older) {
480
+ // Peek at the current route so the router streams on the exact active model.
481
+ const peek = router.peek();
482
+ if (!peek)
483
+ throw new Error('no route');
484
+ const model = `${peek.provider}/${peek.model}`;
485
+ const sub = new ContextManager(SUMMARIZE_SYSTEM);
486
+ for (const m of older)
487
+ sub.add(m);
488
+ sub.addUser('Summarize the transcript above.');
489
+ let text = '';
490
+ for await (const ev of router.stream({ messages: sub.toOutgoing(), model, temperature: 0.2 })) {
491
+ if (ev.type === 'token')
492
+ text += ev.text;
493
+ }
494
+ return text;
495
+ }
@@ -174,11 +174,17 @@ export class AgentLoop {
174
174
  // don't pollute history, burn a turn slot, and try the model again.
175
175
  continue;
176
176
  }
177
+ // Record the assistant message (with its tool_calls) BEFORE executing the
178
+ // tools: toOutgoing() pairs each tool result with the assistant turn that
179
+ // invoked it. Adding it afterwards would walk the results back to the
180
+ // user message instead (nothing precedes a bare tool message in the wire
181
+ // history), so the provider would never deliver the file contents. This
182
+ // ordering bug made read_file/list_dir output invisible to the model.
183
+ this.opts.context.addAssistant(asstMsg.content, { model: asstMsg.model, toolCalls });
177
184
  // Execute each tool call, feed results back into context
178
185
  for (const call of toolCalls) {
179
186
  await this.executeTool(call);
180
187
  }
181
- this.opts.context.addAssistant(asstMsg.content, { model: asstMsg.model, toolCalls });
182
188
  bus.emit('onAssistantEnd', asstMsg);
183
189
  // Overflow-driven compaction (adapted from opencode's loop protection):
184
190
  // after a tool-heavy turn, bring the outgoing context back inside budget
@@ -206,9 +212,19 @@ export class AgentLoop {
206
212
  const decision = await this.opts.permissions.checkCommand(parseCommandArg(call.args));
207
213
  denied = decision === 'deny';
208
214
  }
209
- else if (call.name === 'write_file') {
210
- const decision = await this.opts.permissions.checkPath(parseWritePath(call.args), 'write');
215
+ else if (call.name === 'write_file' || call.name === 'edit_file') {
216
+ const writePath = parseWritePath(call.args);
217
+ const decision = await this.opts.permissions.checkPath(writePath, 'write');
211
218
  denied = decision === 'deny';
219
+ // Containment: mutating file tools must stay inside the working
220
+ // directory (or an allowPath) so a model can't `../`-escape the project.
221
+ if (!denied && writePath) {
222
+ const resolved = resolveCwdPath(writePath, this.opts.cwd ?? '');
223
+ const cwdRoot = resolve(this.opts.cwd ?? '.');
224
+ const inCwd = pathWithin(resolved, cwdRoot);
225
+ const inAllowed = this.opts.permissions.allowPaths.some((a) => pathWithin(resolved, resolveCwdPath(a, this.opts.cwd ?? '.')));
226
+ denied = !inCwd && !inAllowed;
227
+ }
212
228
  }
213
229
  else if (MUTATING_TOOLS[call.name]) {
214
230
  const decision = await this.opts.permissions.checkCommand(MUTATING_TOOLS[call.name]);
@@ -368,6 +384,12 @@ function resolveCwdPath(p, cwd) {
368
384
  return p;
369
385
  return resolve(cwd, p);
370
386
  }
387
+ /** True when `candidate` is `root` or a descendant of it (path-sealed). */
388
+ export function pathWithin(candidate, root) {
389
+ const c = resolve(candidate);
390
+ const r = resolve(root);
391
+ return c === r || c.startsWith(r.endsWith('/') ? r : `${r}/`);
392
+ }
371
393
  async function captureSnapshot(args, cwd, toolName) {
372
394
  if (toolName !== 'edit_file' && toolName !== 'write_file')
373
395
  return null;
@@ -59,12 +59,25 @@ export class ContextManager {
59
59
  }
60
60
  if (m.role === 'tool') {
61
61
  const tr = m;
62
- let last = out[out.length - 1];
63
- // find the preceding assistant turn
64
- let idx = out.length - 1;
65
- while (idx >= 0 && out[idx].role !== 'assistant')
66
- idx--;
67
- last = out[idx] ?? out[out.length - 1];
62
+ // Pair the result with the assistant turn that declared its tool call:
63
+ // prefer the nearest preceding assistant whose toolCalls carries this
64
+ // toolCallId (never a bare user/system message). Falls back to the most
65
+ // recent assistant when an id is missing (legacy/compacted history).
66
+ let last;
67
+ for (let idx = out.length - 1; idx >= 0; idx--) {
68
+ const cand = out[idx];
69
+ if (cand.role !== 'assistant')
70
+ continue;
71
+ last = cand;
72
+ if (tr.toolCallId) {
73
+ const declared = (cand.toolCalls ?? []).some((tc) => tc.id === tr.toolCallId);
74
+ if (declared)
75
+ break;
76
+ }
77
+ else {
78
+ break;
79
+ }
80
+ }
68
81
  if (last && tr.toolCallId) {
69
82
  const existing = last.toolResults ?? [];
70
83
  last.toolResults = [
@@ -164,4 +177,25 @@ export class ContextManager {
164
177
  }
165
178
  return Math.ceil(chars / 4) + count;
166
179
  }
180
+ /**
181
+ * Split history into the current (newest) turn — the last user message and
182
+ * everything after it — and the older turns. Used by /compact to summarize
183
+ * the older portion while keeping the active turn verbatim.
184
+ */
185
+ splitCurrentTurn() {
186
+ let lastUser = -1;
187
+ for (let i = this.messages.length - 1; i >= 0; i--) {
188
+ if (this.messages[i]?.role === 'user') {
189
+ lastUser = i;
190
+ break;
191
+ }
192
+ }
193
+ if (lastUser < 0)
194
+ return { older: [], current: this.messages };
195
+ return { older: this.messages.slice(0, lastUser), current: this.messages.slice(lastUser) };
196
+ }
197
+ /** Replace the whole history (used by /compact to inject the summary). */
198
+ replaceHistory(messages) {
199
+ this.messages = messages;
200
+ }
167
201
  }
@@ -1,4 +1,4 @@
1
- import { readFileSync, readdirSync, statSync } from 'node:fs';
1
+ import { readFileSync, readdirSync, statSync, mkdirSync, writeFileSync } from 'node:fs';
2
2
  import { resolve, dirname, join, basename } from 'node:path';
3
3
  /**
4
4
  * Project-scoped instruction loading, modeled on Agent Build / Claude Code:
@@ -14,6 +14,10 @@ import { resolve, dirname, join, basename } from 'node:path';
14
14
  */
15
15
  const CONVENTION_FILES = ['AGENTS.md', '.orbit/AGENTS.md', '.agents/AGENTS.md', 'CLAUDE.md'];
16
16
  const MAX_TOTAL_BYTES = 120_000;
17
+ /** Files holding long-term memory (read once at startup, editable via /remember). */
18
+ const MEMORY_FILES = ['MEMORY.md', '.orbit/MEMORY.md'];
19
+ /** Cap for injected memory, ~25KB; /remember bounds appends to this. */
20
+ const MAX_MEMORY_BYTES = 25_000;
17
21
  export function loadProjectContext(cwd) {
18
22
  const files = [];
19
23
  const bodies = [];
@@ -34,6 +38,7 @@ export function loadProjectContext(cwd) {
34
38
  }
35
39
  return total > MAX_TOTAL_BYTES;
36
40
  });
41
+ const { memory, memoryFile } = loadMemory(cwd);
37
42
  const skillsDir = findSkillsDir(cwd);
38
43
  const skillBodies = {};
39
44
  const skills = [];
@@ -52,7 +57,62 @@ export function loadProjectContext(cwd) {
52
57
  }
53
58
  skills.sort((a, b) => a.name.localeCompare(b.name));
54
59
  }
55
- return { files, conventions: bodies.join('\n'), skills, skillBodies };
60
+ return { files, conventions: bodies.join('\n'), memory, memoryFile, skills, skillBodies };
61
+ }
62
+ /**
63
+ * Locate memory files walking up from cwd (lowest dir wins), concatenating in
64
+ * order. Capped at MAX_MEMORY_BYTES like conventions but separately, so a blob
65
+ * of memory never eats the AGENTS.md budget.
66
+ */
67
+ function loadMemory(cwd) {
68
+ const found = [];
69
+ collectUpward(cwd, (dir) => {
70
+ for (const name of MEMORY_FILES) {
71
+ const p = join(dir, name);
72
+ if (found.some((f) => f.p === p))
73
+ continue;
74
+ const raw = readSafe(p);
75
+ if (raw === null)
76
+ continue;
77
+ found.push({ p, raw });
78
+ }
79
+ return false;
80
+ });
81
+ if (found.length === 0)
82
+ return { memory: '', memoryFile: null };
83
+ let memory = '';
84
+ for (const { p, raw } of found) {
85
+ memory += `## ${relLabel(p, cwd)}\n${raw.trim()}\n\n`;
86
+ if (memory.length > MAX_MEMORY_BYTES)
87
+ break;
88
+ }
89
+ return { memory: memory.slice(0, MAX_MEMORY_BYTES), memoryFile: found[0].p };
90
+ }
91
+ /**
92
+ * Append a note to the project's memory file (/remember). Creates MEMORY.md in
93
+ * cwd when none exists. Returns the file path written, or null on failure.
94
+ */
95
+ export function appendProjectMemory(cwd, note) {
96
+ const target = resolve(cwd, 'MEMORY.md');
97
+ const existing = readSafe(target) ?? '';
98
+ const header = existing.trim().length > 0 ? '' : '# orbit memory\n';
99
+ let body = existing + (existing.endsWith('\n') ? '' : '\n') + `- ${new Date().toISOString().slice(0, 10)}: ${note.trim()}\n`;
100
+ if (body.length > MAX_MEMORY_BYTES) {
101
+ // Keep the newest notes: drop the oldest lines until under cap.
102
+ const lines = body.split('\n');
103
+ while (body.length > MAX_MEMORY_BYTES && lines.length > 4) {
104
+ lines.shift();
105
+ body = lines.join('\n');
106
+ }
107
+ }
108
+ try {
109
+ mkdirSync(dirname(target), { recursive: true });
110
+ writeFileSync(target, header + body, 'utf8');
111
+ return target;
112
+ }
113
+ catch {
114
+ return null;
115
+ }
56
116
  }
57
117
  function collectUpward(start, visit) {
58
118
  let dir = resolve(start);
@@ -15,12 +15,16 @@ export class ToolRegistry {
15
15
  ctx;
16
16
  constructor(init) {
17
17
  this.ctx = { cwd: init.cwd, canWrite: init.canWrite ?? true };
18
- this.register(readFileTool);
19
- this.register(editFileTool);
20
- this.register(writeFileTool);
21
- this.register(listDirTool);
22
- this.register(globTool);
23
- this.register(grepTool);
18
+ if (init.filesystem ?? true) {
19
+ this.register(readFileTool);
20
+ this.register(editFileTool);
21
+ this.register(writeFileTool);
22
+ this.register(listDirTool);
23
+ this.register(globTool);
24
+ }
25
+ if (init.search ?? true) {
26
+ this.register(grepTool);
27
+ }
24
28
  if (init.withGit ?? true) {
25
29
  for (const t of gitTools)
26
30
  this.register(t);
@@ -247,6 +247,18 @@ export class TuiApp {
247
247
  }
248
248
  this.store.refresh();
249
249
  });
250
+ // Automatic context compaction happened (agent loop) — surface it.
251
+ this.bus.on('onContextSummary', ({ droppedPairs, tokensBefore, tokensAfter }) => {
252
+ this.store.contextTokens = tokensAfter;
253
+ this.pushMessage({
254
+ id: `ctx-${Date.now()}`,
255
+ role: 'system',
256
+ content: droppedPairs > 0
257
+ ? `Context compacted: dropped ${droppedPairs} older turns (${tokensBefore} → ${tokensAfter} tokens).`
258
+ : `Context within budget (${tokensAfter} tokens).`,
259
+ createdAt: Date.now(),
260
+ });
261
+ });
250
262
  // Plan mode lifecycle.
251
263
  this.bus.on('onPlanProposed', ({ text }) => {
252
264
  const plan = this.store.plan;
@@ -909,6 +921,8 @@ export class TuiApp {
909
921
  `/diff toggle unified diff view (or press d)`,
910
922
  `/skills list project skills (AGENTS.md / .orbit/skills)`,
911
923
  `/theme <name> change theme (${THEME_NAMES.join(' · ')})`,
924
+ `/compact summarize old context to free budget`,
925
+ `/remember <note> save a fact to MEMORY.md`,
912
926
  `/yolo auto-approve tools`,
913
927
  `/yes answer a permission prompt`,
914
928
  `/agents toggle agent dock (orchestrate mode)`,
@@ -1007,6 +1021,30 @@ export class TuiApp {
1007
1021
  store.yolo = true;
1008
1022
  this.note('Permission mode → allow (yolo).');
1009
1023
  return;
1024
+ case 'compact': {
1025
+ if (store.streaming) {
1026
+ this.note('Wait for the current turn to finish.');
1027
+ return;
1028
+ }
1029
+ this.note('Compacting context…');
1030
+ const result = await this.onCommand({ type: 'compact' });
1031
+ if (result) {
1032
+ store.contextTokens = parseTokenCount(result);
1033
+ this.note(result);
1034
+ }
1035
+ else
1036
+ this.note('Nothing to compact.');
1037
+ return;
1038
+ }
1039
+ case 'remember': {
1040
+ if (!arg) {
1041
+ this.note('Usage: /remember <note> — save a durable fact to MEMORY.md.');
1042
+ return;
1043
+ }
1044
+ const result = await this.onCommand({ type: 'remember', value: arg });
1045
+ this.note(result ?? 'Saved to memory.');
1046
+ return;
1047
+ }
1010
1048
  case 'yes':
1011
1049
  if (store.pendingAsk) {
1012
1050
  const { resolve } = store.pendingAsk;
@@ -1095,3 +1133,8 @@ function truncateForNote(text, max = 60) {
1095
1133
  const t = text.replace(/\s+/g, ' ').trim();
1096
1134
  return t.length <= max ? t : `${t.slice(0, max)}…`;
1097
1135
  }
1136
+ /** Pull the trailing "N tokens" value out of a /compact result message. */
1137
+ function parseTokenCount(result) {
1138
+ const m = result.match(/(\d+) tokens?\.?$/);
1139
+ return m ? Number(m[1]) : null;
1140
+ }
@@ -19,5 +19,5 @@ export function Header({ width }) {
19
19
  const border = store.theme ? themeColor(store.theme, 'border') : '#565f89';
20
20
  const model = store.route ? `${store.route.provider}/${store.route.model}` : 'no route';
21
21
  const dir = shortPath(store.cwd || process.cwd());
22
- return (_jsxs(Box, { width: width, borderStyle: "single", borderColor: border, flexDirection: "column", paddingX: 1, children: [_jsxs(Box, { height: 1, children: [_jsx(Text, { color: accent, bold: true, wrap: "truncate-end", children: '>_ orbit' }), _jsx(Text, { dimColor: true, wrap: "truncate-end", children: ` (v${store.version})` }), _jsx(Text, { color: muted, wrap: "truncate-end", children: ` model: ${model}` }), _jsx(Text, { color: dim, wrap: "truncate-end", children: ' /model' })] }), _jsx(Box, { height: 1, children: _jsx(Text, { color: dim, wrap: "truncate-end", children: `directory: ${dir}` }) })] }));
22
+ return (_jsxs(Box, { width: width, borderStyle: "single", borderColor: border, flexDirection: "column", paddingX: 1, children: [_jsxs(Box, { height: 1, children: [_jsx(Text, { color: accent, bold: true, wrap: "truncate-end", children: '>_ orbit' }), _jsx(Text, { dimColor: true, wrap: "truncate-end", children: ` (v${store.version})` }), _jsx(Text, { color: muted, wrap: "truncate-end", children: ` model: ${model}` }), _jsx(Text, { color: dim, wrap: "truncate-end", children: ' /model' })] }), _jsxs(Box, { height: 1, children: [_jsx(Text, { color: dim, wrap: "truncate-end", children: `directory: ${dir}` }), store.contextTokens !== null && (_jsx(Text, { color: muted, wrap: "truncate-end", children: ` context: ${store.contextTokens} tok` }))] })] }));
23
23
  }
@@ -10,6 +10,8 @@ const SLASH_COMMANDS = [
10
10
  { cmd: '/model', desc: '[id] select model' },
11
11
  { cmd: '/provider', desc: '<id> switch provider' },
12
12
  { cmd: '/theme', desc: '<name> switch theme' },
13
+ { cmd: '/compact', desc: 'summarize old context to free budget' },
14
+ { cmd: '/remember', desc: '<note> save a fact to MEMORY.md' },
13
15
  { cmd: '/plan', desc: '<task> propose a plan (approve with /run)' },
14
16
  { cmd: '/run', desc: 'execute the proposed plan' },
15
17
  { cmd: '/skills', desc: 'list project skills' },
@@ -32,6 +34,8 @@ export class AppStore {
32
34
  pendingAsk = null;
33
35
  inputTokens = 0;
34
36
  outputTokens = 0;
37
+ /** Estimated context size after the last compaction (tokens). */
38
+ contextTokens = null;
35
39
  scrollOffset = 0;
36
40
  cancelArmed = false;
37
41
  greeted = false;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@orbit-intelligence/orbit-agent",
3
- "version": "0.3.15",
3
+ "version": "0.3.16",
4
4
  "description": "orbit — a premium pure-TypeScript coding agent for Termux and desktop terminals, powered by Orbit X.",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",