@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.
- package/dist/prompts/system.js +7 -0
- package/dist/src/cli/orchestrate.js +7 -1
- package/dist/src/cli/run.js +102 -8
- package/dist/src/core/agent/agent-loop.js +25 -3
- package/dist/src/core/context/context-manager.js +40 -6
- package/dist/src/core/project-context.js +62 -2
- package/dist/src/core/tools/registry.js +10 -6
- package/dist/src/tui/app.js +43 -0
- package/dist/src/tui/components/Header.js +1 -1
- package/dist/src/tui/store.js +4 -0
- package/package.json +1 -1
package/dist/prompts/system.js
CHANGED
|
@@ -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({
|
|
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,
|
package/dist/src/cli/run.js
CHANGED
|
@@ -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.
|
|
139
|
-
// system prompt; skills exposed both to the model
|
|
140
|
-
// the /skills command.
|
|
141
|
-
|
|
142
|
-
const
|
|
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({
|
|
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({
|
|
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
|
|
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
|
-
|
|
63
|
-
//
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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);
|
package/dist/src/tui/app.js
CHANGED
|
@@ -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' })] }),
|
|
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
|
}
|
package/dist/src/tui/store.js
CHANGED
|
@@ -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