@newsails/veil-cli 1.0.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/.veil/agents/analyst/AGENT.md +21 -0
- package/.veil/agents/analyst/agent.json +23 -0
- package/.veil/agents/assistant/AGENT.md +15 -0
- package/.veil/agents/assistant/agent.json +19 -0
- package/.veil/agents/coder/AGENT.md +18 -0
- package/.veil/agents/coder/agent.json +19 -0
- package/.veil/agents/hello/AGENT.md +5 -0
- package/.veil/agents/hello/agent.json +13 -0
- package/.veil/agents/writer/AGENT.md +12 -0
- package/.veil/agents/writer/agent.json +17 -0
- package/.veil/memory/MEMORY.md +343 -0
- package/.veil/memory/agents/analyst/MEMORY.md +55 -0
- package/.veil/memory/agents/hello/MEMORY.md +12 -0
- package/.veil/runtime.pid +1 -0
- package/.veil/settings.json +10 -0
- package/.veil-studio/studio.db +0 -0
- package/.veil-studio/studio.db-shm +0 -0
- package/.veil-studio/studio.db-wal +0 -0
- package/PLAN/01-vision.md +26 -0
- package/PLAN/02-tech-stack.md +94 -0
- package/PLAN/03-agents.md +232 -0
- package/PLAN/04-runtime.md +171 -0
- package/PLAN/05-tools.md +211 -0
- package/PLAN/06-communication.md +243 -0
- package/PLAN/07-storage.md +218 -0
- package/PLAN/08-api-cli.md +153 -0
- package/PLAN/09-permissions.md +108 -0
- package/PLAN/10-ably.md +105 -0
- package/PLAN/11-file-formats.md +442 -0
- package/PLAN/12-folder-structure.md +205 -0
- package/PLAN/13-operations.md +212 -0
- package/PLAN/README.md +23 -0
- package/README.md +128 -0
- package/REPORT.md +174 -0
- package/TODO.md +45 -0
- package/ai-tests/FRONTEND_PROMPT.md +220 -0
- package/ai-tests/Research & Planning.md +814 -0
- package/ai-tests/prompt-001-basic-api.md +230 -0
- package/ai-tests/prompt-002-basic-flows.md +230 -0
- package/ai-tests/prompt-003-agent-behaviors.md +220 -0
- package/api/middleware.js +60 -0
- package/api/routes/agents.js +193 -0
- package/api/routes/chat.js +93 -0
- package/api/routes/completions.js +122 -0
- package/api/routes/daemons.js +80 -0
- package/api/routes/memory.js +169 -0
- package/api/routes/models.js +40 -0
- package/api/routes/remote-methods.js +74 -0
- package/api/routes/sessions.js +208 -0
- package/api/routes/settings.js +108 -0
- package/api/routes/system.js +50 -0
- package/api/routes/tasks.js +270 -0
- package/api/server.js +120 -0
- package/cli/formatter.js +70 -0
- package/cli/index.js +443 -0
- package/cli/parser.js +113 -0
- package/config/config.json +10 -0
- package/config/models.json +6826 -0
- package/core/agent.js +329 -0
- package/core/cancel.js +38 -0
- package/core/compaction.js +176 -0
- package/core/events.js +13 -0
- package/core/loop.js +564 -0
- package/core/memory.js +51 -0
- package/core/prompt.js +185 -0
- package/core/queue.js +96 -0
- package/core/registry.js +291 -0
- package/core/remote-methods.js +124 -0
- package/core/router.js +386 -0
- package/core/running-sessions.js +18 -0
- package/docs/api/01-system.md +84 -0
- package/docs/api/02-agents.md +374 -0
- package/docs/api/03-chat.md +269 -0
- package/docs/api/04-tasks.md +470 -0
- package/docs/api/05-sessions.md +444 -0
- package/docs/api/06-daemons.md +142 -0
- package/docs/api/07-memory.md +186 -0
- package/docs/api/08-settings.md +133 -0
- package/docs/api/09-models.md +119 -0
- package/docs/api/09-websocket.md +350 -0
- package/docs/api/10-completions.md +134 -0
- package/docs/api/README.md +116 -0
- package/docs/guide/01-quickstart.md +220 -0
- package/docs/guide/02-folder-structure.md +185 -0
- package/docs/guide/03-configuration.md +252 -0
- package/docs/guide/04-agents.md +267 -0
- package/docs/guide/05-cli.md +290 -0
- package/docs/guide/06-tools.md +643 -0
- package/docs/guide/07-permissions.md +236 -0
- package/docs/guide/08-memory.md +139 -0
- package/docs/guide/09-multi-agent.md +271 -0
- package/docs/guide/10-daemons.md +226 -0
- package/docs/guide/README.md +53 -0
- package/docs/index.html +623 -0
- package/examples/README.md +151 -0
- package/examples/agents/assistant/AGENT.md +31 -0
- package/examples/agents/assistant/SOUL.md +9 -0
- package/examples/agents/assistant/agent.json +74 -0
- package/examples/agents/hello/AGENT.md +15 -0
- package/examples/agents/hello/agent.json +14 -0
- package/examples/agents/monitor/AGENT.md +51 -0
- package/examples/agents/monitor/agent.json +33 -0
- package/examples/agents/monitor/heartbeats/monitor.md +24 -0
- package/examples/agents/orchestrator/AGENT.md +70 -0
- package/examples/agents/orchestrator/agent.json +30 -0
- package/examples/agents/researcher/AGENT.md +52 -0
- package/examples/agents/researcher/agent.json +49 -0
- package/examples/agents/researcher/skills/web-research.md +28 -0
- package/examples/skills/code-review.md +72 -0
- package/examples/skills/summarise.md +59 -0
- package/examples/skills/web-research.md +42 -0
- package/examples/tools/word-count/index.js +27 -0
- package/examples/tools/word-count/tool.json +18 -0
- package/infrastructure/database.js +563 -0
- package/infrastructure/scheduler.js +122 -0
- package/llm/client.js +206 -0
- package/migrations/001-initial.sql +121 -0
- package/migrations/002-debuggability.sql +13 -0
- package/migrations/003-drop-orphaned-columns.sql +72 -0
- package/migrations/004-session-message-token-fields.sql +78 -0
- package/migrations/005-session-thinking.sql +5 -0
- package/package.json +30 -0
- package/schemas/agent.json +143 -0
- package/schemas/settings.json +111 -0
- package/scripts/fetch-models.js +93 -0
- package/session-debug-scenario.md +248 -0
- package/settings/fields.js +52 -0
- package/system-prompts/base-core.md +7 -0
- package/system-prompts/environment.md +13 -0
- package/system-prompts/reminders/anti-drift.md +6 -0
- package/system-prompts/reminders/stall-recovery.md +10 -0
- package/system-prompts/safety-rules.md +25 -0
- package/system-prompts/task-heuristics.md +27 -0
- package/test/client.js +71 -0
- package/test/integration/01-health.test.js +25 -0
- package/test/integration/02-agents.test.js +80 -0
- package/test/integration/03-chat-hello.test.js +48 -0
- package/test/integration/04-chat-multiturn.test.js +61 -0
- package/test/integration/05-chat-writer.test.js +48 -0
- package/test/integration/06-task-basic.test.js +68 -0
- package/test/integration/07-task-tools.test.js +74 -0
- package/test/integration/08-task-code-analysis.test.js +69 -0
- package/test/integration/09-memory-analyst.test.js +63 -0
- package/test/integration/10-task-advanced.test.js +85 -0
- package/test/integration/11-sessions-advanced.test.js +84 -0
- package/test/integration/12-assistant-chat-tools.test.js +75 -0
- package/test/integration/13-edge-cases.test.js +99 -0
- package/test/integration/14-cancel.test.js +62 -0
- package/test/integration/15-debug.test.js +106 -0
- package/test/integration/16-memory-api.test.js +83 -0
- package/test/integration/17-settings-api.test.js +41 -0
- package/test/integration/18-tool-search-activation.test.js +119 -0
- package/test/results/.gitkeep +0 -0
- package/test/runner.js +206 -0
- package/test/smoke.js +216 -0
- package/tools/agent_message.js +85 -0
- package/tools/agent_send.js +80 -0
- package/tools/agent_spawn.js +44 -0
- package/tools/bash.js +49 -0
- package/tools/edit_file.js +41 -0
- package/tools/glob.js +64 -0
- package/tools/grep.js +82 -0
- package/tools/list_dir.js +63 -0
- package/tools/log_write.js +31 -0
- package/tools/memory_read.js +38 -0
- package/tools/memory_search.js +65 -0
- package/tools/memory_write.js +42 -0
- package/tools/read_file.js +48 -0
- package/tools/sleep.js +22 -0
- package/tools/task_create.js +41 -0
- package/tools/task_respond.js +37 -0
- package/tools/task_spawn.js +64 -0
- package/tools/task_status.js +39 -0
- package/tools/task_subscribe.js +37 -0
- package/tools/todo_read.js +26 -0
- package/tools/todo_write.js +38 -0
- package/tools/tool_activate.js +24 -0
- package/tools/tool_search.js +24 -0
- package/tools/web_fetch.js +50 -0
- package/tools/web_search.js +52 -0
- package/tools/write_file.js +28 -0
- package/ui/api.js +190 -0
- package/ui/app.js +281 -0
- package/ui/index.html +382 -0
- package/ui/views/agents.js +377 -0
- package/ui/views/chat.js +610 -0
- package/ui/views/connection.js +96 -0
- package/ui/views/daemons.js +129 -0
- package/ui/views/feed.js +194 -0
- package/ui/views/memory.js +263 -0
- package/ui/views/models.js +146 -0
- package/ui/views/sessions.js +314 -0
- package/ui/views/settings.js +142 -0
- package/ui/views/tasks.js +415 -0
- package/utils/context.js +49 -0
- package/utils/id.js +16 -0
- package/utils/models.js +88 -0
- package/utils/paths.js +213 -0
- package/utils/settings.js +172 -0
package/core/loop.js
ADDED
|
@@ -0,0 +1,564 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { callLLM, extractMessage, extractUsage } = require('../llm/client');
|
|
4
|
+
const { resolveTool, validateToolInput, buildToolsForLLM, listCustomToolSummaries, activateToolByName } = require('./registry');
|
|
5
|
+
const { assembleSystemPrompt, buildReminder } = require('./prompt');
|
|
6
|
+
const { addMessage, getMessages, updateSession, updateTask, addTaskEvent, saveTodos, getTodos, saveTaskContext } = require('../infrastructure/database');
|
|
7
|
+
const { getModelConfig } = require('../utils/settings');
|
|
8
|
+
const { calculateCost } = require('../utils/models');
|
|
9
|
+
const { manageContext } = require('./compaction');
|
|
10
|
+
const { drainNonFollowup, drainFollowup, postCorrelatedResponse } = require('./queue');
|
|
11
|
+
const F = require('../settings/fields');
|
|
12
|
+
const eventBus = require('./events');
|
|
13
|
+
const remoteMethodQueue = require('./remote-methods');
|
|
14
|
+
const runningSessions = require('./running-sessions');
|
|
15
|
+
|
|
16
|
+
const ANTI_DRIFT_EVERY = 10; // inject anti-drift reminder every N iterations
|
|
17
|
+
const STALL_THRESHOLD = 3; // iterations with no tool calls = stall
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Check permissions for a tool call.
|
|
21
|
+
* @param {{ toolName: string, mode: string, settings: Object, agentConfig: Object }} opts
|
|
22
|
+
* @returns {'allow'|'deny'|'ask'}
|
|
23
|
+
*/
|
|
24
|
+
function checkPermission({ toolName, toolInput, mode, settings, agentConfig, modeConfig }) {
|
|
25
|
+
// Always deny auth file reads
|
|
26
|
+
const authDenyPatterns = ['.veil/auth.json', '.veil\\auth.json'];
|
|
27
|
+
const inputStr = JSON.stringify(toolInput || {});
|
|
28
|
+
for (const p of authDenyPatterns) {
|
|
29
|
+
if (inputStr.includes(p)) return 'deny';
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Check agent-level per-mode deny list
|
|
33
|
+
const agentDeny = modeConfig.permissions?.deny || [];
|
|
34
|
+
for (const pattern of agentDeny) {
|
|
35
|
+
if (matchPermissionPattern(toolName, toolInput, pattern)) return 'deny';
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Check agent-level per-mode allow list
|
|
39
|
+
const agentAllow = modeConfig.permissions?.allow || [];
|
|
40
|
+
for (const pattern of agentAllow) {
|
|
41
|
+
if (matchPermissionPattern(toolName, toolInput, pattern)) return 'allow';
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Check settings-level deny
|
|
45
|
+
const settingsDeny = settings.permissions?.deny || [];
|
|
46
|
+
for (const pattern of settingsDeny) {
|
|
47
|
+
if (matchPermissionPattern(toolName, toolInput, pattern)) return 'deny';
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Check settings-level ask
|
|
51
|
+
const settingsAsk = settings.permissions?.ask || [];
|
|
52
|
+
for (const pattern of settingsAsk) {
|
|
53
|
+
if (matchPermissionPattern(toolName, toolInput, pattern)) return 'ask';
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Check settings-level allow
|
|
57
|
+
const settingsAllow = settings.permissions?.allow || [];
|
|
58
|
+
for (const pattern of settingsAllow) {
|
|
59
|
+
if (matchPermissionPattern(toolName, toolInput, pattern)) return 'allow';
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Default: chat=ask, others=deny
|
|
63
|
+
return mode === 'chat' ? 'ask' : 'deny';
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Simple permission pattern matcher: tool_name(glob_pattern)
|
|
68
|
+
* @param {string} toolName
|
|
69
|
+
* @param {Object} toolInput
|
|
70
|
+
* @param {string} pattern
|
|
71
|
+
* @returns {boolean}
|
|
72
|
+
*/
|
|
73
|
+
function matchPermissionPattern(toolName, toolInput, pattern) {
|
|
74
|
+
const match = pattern.match(/^([\w:.*]+)(?:\((.+)\))?$/);
|
|
75
|
+
if (!match) return false;
|
|
76
|
+
const [, patTool, patArg] = match;
|
|
77
|
+
|
|
78
|
+
// Tool name match (support wildcards)
|
|
79
|
+
if (!globMatch(toolName, patTool)) return false;
|
|
80
|
+
|
|
81
|
+
// Argument pattern (optional)
|
|
82
|
+
if (patArg) {
|
|
83
|
+
const inputStr = JSON.stringify(toolInput || '');
|
|
84
|
+
if (!inputStr.includes(patArg.replace(/\*\*/g, '').replace(/\*/g, ''))) return false;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return true;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Simple glob matcher (* = any chars except /, ** = any chars including /).
|
|
92
|
+
* @param {string} str
|
|
93
|
+
* @param {string} pattern
|
|
94
|
+
* @returns {boolean}
|
|
95
|
+
*/
|
|
96
|
+
function globMatch(str, pattern) {
|
|
97
|
+
if (pattern === '*' || pattern === '**') return true;
|
|
98
|
+
const regexStr = pattern
|
|
99
|
+
.replace(/[.+^${}()|[\]\\]/g, '\\$&')
|
|
100
|
+
.replace(/\*\*/g, '__DOUBLESTAR__')
|
|
101
|
+
.replace(/\*/g, '[^/]*')
|
|
102
|
+
.replace(/__DOUBLESTAR__/g, '.*');
|
|
103
|
+
return new RegExp(`^${regexStr}$`).test(str);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Build a structured error object for task failures.
|
|
109
|
+
* @param {{ code: string, message: string, iteration?: number, tool?: string, tokensUsed?: number, elapsed?: number }} opts
|
|
110
|
+
* @returns {string} JSON string
|
|
111
|
+
*/
|
|
112
|
+
function buildStructuredError({ code, message, iteration, tool, tokensUsed, elapsed }) {
|
|
113
|
+
return JSON.stringify({ code, message, ...(iteration !== undefined && { iteration }), ...(tool && { tool }), ...(tokensUsed !== undefined && { tokensUsed }), ...(elapsed !== undefined && { elapsed }) });
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Emit an event on the WebSocket bus.
|
|
118
|
+
* @param {{ type: string, taskId?: string, sessionId?: string, agentName: string, event: Object }} opts
|
|
119
|
+
*/
|
|
120
|
+
function emitBusEvent({ type, taskId, sessionId, agentName, event }) {
|
|
121
|
+
try {
|
|
122
|
+
eventBus.emit('event', { type, ...(taskId && { taskId }), ...(sessionId && { sessionId }), agentName, event: { ...event, timestamp: Date.now() } });
|
|
123
|
+
} catch { /* bus errors must never crash the loop */ }
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Core agentic loop — async generator.
|
|
128
|
+
* Yields progress events. Runs identically for all modes.
|
|
129
|
+
*
|
|
130
|
+
* @param {{ agent: Object, messages: Object[], settings: Object, mode: string, taskId?: string, sessionId: string, cwd: string, modeConfig: Object, cancelSignal?: AbortSignal, tokenBudget?: number, thinking?: Object, onStreamChunk?: (text: string) => void }} opts
|
|
131
|
+
*/
|
|
132
|
+
async function* runLoop({ agent, messages, settings, mode, taskId, sessionId, cwd, modeConfig, cancelSignal, tokenBudget, thinking, onStreamChunk, onInferenceToolStart }) {
|
|
133
|
+
runningSessions.add(sessionId);
|
|
134
|
+
try {
|
|
135
|
+
const modelConfig = getModelConfig(settings, F.MODEL_MAIN);
|
|
136
|
+
const maxIterations = modeConfig.maxIterations || F.DEFAULT_MAX_ITERATIONS;
|
|
137
|
+
const maxDurationSeconds = modeConfig.maxDurationSeconds || F.DEFAULT_MAX_DURATION_SECONDS;
|
|
138
|
+
const startTime = Date.now();
|
|
139
|
+
|
|
140
|
+
let iteration = 0;
|
|
141
|
+
let stallCount = 0;
|
|
142
|
+
let lastToolCallIteration = -1;
|
|
143
|
+
let totalTokens = { input: 0, output: 0, cache: 0, cost: 0 };
|
|
144
|
+
const modelKey = agent.model || modelConfig[F.MODEL_NAME];
|
|
145
|
+
let consecutiveLlmErrors = 0;
|
|
146
|
+
const MAX_CONSECUTIVE_LLM_ERRORS = 3;
|
|
147
|
+
|
|
148
|
+
// Build tools list for LLM
|
|
149
|
+
let llmTools = buildToolsForLLM({ agentConfig: agent, cwd, modeConfig });
|
|
150
|
+
const customSummaries = listCustomToolSummaries({ agentConfig: agent, cwd });
|
|
151
|
+
|
|
152
|
+
// Append custom tool summaries to system prompt context (Tier 2)
|
|
153
|
+
if (customSummaries.length > 0) {
|
|
154
|
+
const customList = customSummaries.map(t => `- **${t.name}**: ${t.description}`).join('\n');
|
|
155
|
+
const lastSystem = messages.findIndex(m => m.role === 'system');
|
|
156
|
+
if (lastSystem >= 0) {
|
|
157
|
+
messages[lastSystem].content += `\n\n## Additional Available Tools\n\nUse \`tool_search\` to find tools by name or description and inspect their full schemas.\nUse \`tool_activate\` to make a specific tool callable before using it.\n\n${customList}`;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
let pendingCorrelationIds = [];
|
|
162
|
+
|
|
163
|
+
while (true) {
|
|
164
|
+
iteration++;
|
|
165
|
+
|
|
166
|
+
// ── Cancellation check ───────────────────────────────────────────────────
|
|
167
|
+
if (cancelSignal?.aborted) {
|
|
168
|
+
yield { type: 'status.change', from: 'processing', to: 'canceled', reason: 'user_cancel' };
|
|
169
|
+
if (taskId) {
|
|
170
|
+
updateTask(taskId, { status: 'canceled', finishedAt: new Date().toISOString() });
|
|
171
|
+
addTaskEvent({ taskId, type: 'status.change', data: { from: 'processing', to: 'canceled', reason: 'user_cancel' } });
|
|
172
|
+
emitBusEvent({ type: 'task.status', taskId, agentName: agent.name, event: { status: 'canceled', reason: 'user_cancel' } });
|
|
173
|
+
}
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// ── Limits check ────────────────────────────────────────────────────────
|
|
178
|
+
if (iteration > maxIterations) {
|
|
179
|
+
const errStr = buildStructuredError({ code: 'MAX_ITERATIONS', message: `Max iterations (${maxIterations}) reached`, iteration, tokensUsed: totalTokens.input + totalTokens.output });
|
|
180
|
+
yield { type: 'limit.reached', reason: 'maxIterations', iteration };
|
|
181
|
+
if (modeConfig.onExhausted === 'wait') {
|
|
182
|
+
yield { type: 'status.change', from: 'processing', to: 'waiting', reason: 'maxIterations exhausted' };
|
|
183
|
+
if (taskId) updateTask(taskId, { status: 'waiting' });
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
if (taskId) {
|
|
187
|
+
updateTask(taskId, { status: 'failed', error: errStr });
|
|
188
|
+
addTaskEvent({ taskId, type: 'status.change', data: { from: 'processing', to: 'failed', reason: 'maxIterations' } });
|
|
189
|
+
emitBusEvent({ type: 'task.status', taskId, agentName: agent.name, event: { status: 'failed', error: JSON.parse(errStr) } });
|
|
190
|
+
}
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const elapsed = (Date.now() - startTime) / 1000;
|
|
195
|
+
if (elapsed > maxDurationSeconds) {
|
|
196
|
+
const errStr = buildStructuredError({ code: 'MAX_DURATION', message: `Max duration (${maxDurationSeconds}s) exceeded`, iteration, elapsed, tokensUsed: totalTokens.input + totalTokens.output });
|
|
197
|
+
yield { type: 'limit.reached', reason: 'maxDurationSeconds', elapsed };
|
|
198
|
+
if (modeConfig.onExhausted === 'wait') {
|
|
199
|
+
yield { type: 'status.change', from: 'processing', to: 'waiting', reason: 'maxDuration exhausted' };
|
|
200
|
+
if (taskId) updateTask(taskId, { status: 'waiting' });
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
if (taskId) {
|
|
204
|
+
updateTask(taskId, { status: 'failed', error: errStr });
|
|
205
|
+
addTaskEvent({ taskId, type: 'status.change', data: { from: 'processing', to: 'failed', reason: 'maxDuration' } });
|
|
206
|
+
emitBusEvent({ type: 'task.status', taskId, agentName: agent.name, event: { status: 'failed', error: JSON.parse(errStr) } });
|
|
207
|
+
}
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// ── Inject reminders ─────────────────────────────────────────────────────
|
|
212
|
+
if (iteration > 1 && iteration % ANTI_DRIFT_EVERY === 0) {
|
|
213
|
+
const taskBrief = messages.find(m => m.role === 'user')?.content?.slice(0, 200) || 'current task';
|
|
214
|
+
const reminder = buildReminder({ type: 'anti-drift', vars: { TASK_BRIEF: taskBrief } });
|
|
215
|
+
if (reminder) messages.push({ role: 'user', content: reminder });
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
if (stallCount >= STALL_THRESHOLD) {
|
|
219
|
+
const reminder = buildReminder({ type: 'stall-recovery', vars: { STALL_COUNT: stallCount } });
|
|
220
|
+
if (reminder) messages.push({ role: 'user', content: reminder });
|
|
221
|
+
stallCount = 0;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// ── Drain non-followup messages ──────────────────────────────────────────
|
|
225
|
+
const { messages: nonFollowupMsgs, correlationIds: drainedIds } = drainNonFollowup(agent.name, sessionId);
|
|
226
|
+
messages.push(...nonFollowupMsgs);
|
|
227
|
+
if (drainedIds.length > 0) pendingCorrelationIds.push(...drainedIds);
|
|
228
|
+
|
|
229
|
+
// ── Context compaction ──────────────────────────────────────────────────
|
|
230
|
+
const taskBrief = messages.find(m => m.role === 'user')?.content?.slice(0, 500) || '';
|
|
231
|
+
const { messages: managedMessages, compacted } = await manageContext({
|
|
232
|
+
messages,
|
|
233
|
+
settings,
|
|
234
|
+
taskBrief,
|
|
235
|
+
cwd,
|
|
236
|
+
agentName: agent.name,
|
|
237
|
+
});
|
|
238
|
+
if (compacted) {
|
|
239
|
+
messages.length = 0;
|
|
240
|
+
messages.push(...managedMessages);
|
|
241
|
+
yield { type: 'context.compacted', iteration, messageCount: messages.length };
|
|
242
|
+
if (taskId) addTaskEvent({ taskId, type: 'context.compacted', data: { iteration, messageCount: messages.length } });
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
yield { type: 'iteration.start', iteration, tokensSoFar: totalTokens.input + totalTokens.output };
|
|
246
|
+
if (taskId) addTaskEvent({ taskId, type: 'iteration.start', data: { iteration, tokensSoFar: totalTokens.input + totalTokens.output } });
|
|
247
|
+
emitBusEvent({ type: taskId ? 'task.event' : 'chat.event', taskId, sessionId, agentName: agent.name, event: { type: 'iteration.start', iteration, tokensSoFar: totalTokens.input + totalTokens.output } });
|
|
248
|
+
|
|
249
|
+
// ── Context snapshot (for debuggability) ─────────────────────────────────
|
|
250
|
+
if (taskId) {
|
|
251
|
+
try {
|
|
252
|
+
saveTaskContext(taskId, {
|
|
253
|
+
messages: messages.map(m => ({
|
|
254
|
+
role: m.role,
|
|
255
|
+
content: m.content ? String(m.content).slice(0, 2000) : null,
|
|
256
|
+
tool_calls: m.tool_calls || undefined,
|
|
257
|
+
tool_call_id: m.tool_call_id || undefined,
|
|
258
|
+
})),
|
|
259
|
+
tools: llmTools.map(t => t.function.name),
|
|
260
|
+
iteration,
|
|
261
|
+
});
|
|
262
|
+
} catch { /* snapshot failure must not crash the loop */ }
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// ── LLM call ─────────────────────────────────────────────────────────────
|
|
266
|
+
let response;
|
|
267
|
+
try {
|
|
268
|
+
response = await callLLM({
|
|
269
|
+
baseUrl: modelConfig[F.MODEL_BASE_URL],
|
|
270
|
+
apiKey: modelConfig[F.MODEL_API_KEY],
|
|
271
|
+
model: agent.model || modelConfig[F.MODEL_NAME],
|
|
272
|
+
messages,
|
|
273
|
+
tools: llmTools,
|
|
274
|
+
temperature: agent.temperature,
|
|
275
|
+
reasoning: agent.reasoning,
|
|
276
|
+
maxTokens: agent.maxTokens,
|
|
277
|
+
thinking: thinking || agent.thinking || null,
|
|
278
|
+
onChunk: onStreamChunk || null,
|
|
279
|
+
onToolStart: onInferenceToolStart || null,
|
|
280
|
+
});
|
|
281
|
+
} catch (err) {
|
|
282
|
+
consecutiveLlmErrors++;
|
|
283
|
+
yield { type: 'llm.error', error: err.message, iteration, attempt: consecutiveLlmErrors };
|
|
284
|
+
emitBusEvent({ type: taskId ? 'task.event' : 'chat.event', taskId, sessionId, agentName: agent.name, event: { type: 'llm.error', error: err.message, iteration, attempt: consecutiveLlmErrors } });
|
|
285
|
+
if (consecutiveLlmErrors <= MAX_CONSECUTIVE_LLM_ERRORS) {
|
|
286
|
+
await new Promise(r => setTimeout(r, 1000 * consecutiveLlmErrors));
|
|
287
|
+
continue;
|
|
288
|
+
}
|
|
289
|
+
const errStr = buildStructuredError({ code: 'LLM_ERROR', message: `LLM error after ${consecutiveLlmErrors} attempts: ${err.message}`, iteration, tokensUsed: totalTokens.input + totalTokens.output });
|
|
290
|
+
if (taskId) {
|
|
291
|
+
updateTask(taskId, { status: 'failed', error: errStr });
|
|
292
|
+
emitBusEvent({ type: 'task.status', taskId, agentName: agent.name, event: { status: 'failed', error: JSON.parse(errStr) } });
|
|
293
|
+
}
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
consecutiveLlmErrors = 0; // reset on successful LLM call
|
|
298
|
+
|
|
299
|
+
const { content, toolCalls, finishReason } = extractMessage(response);
|
|
300
|
+
const usage = extractUsage(response);
|
|
301
|
+
totalTokens.input += usage.input;
|
|
302
|
+
totalTokens.output += usage.output;
|
|
303
|
+
totalTokens.cache += usage.cache;
|
|
304
|
+
const turnCost = usage.cost || calculateCost(modelKey, { input: usage.input, output: usage.output, cache: usage.cache });
|
|
305
|
+
totalTokens.cost += turnCost;
|
|
306
|
+
|
|
307
|
+
// Save assistant message
|
|
308
|
+
const assistantMsg = { role: 'assistant', content: content || null };
|
|
309
|
+
if (toolCalls) assistantMsg.tool_calls = toolCalls;
|
|
310
|
+
messages.push(assistantMsg);
|
|
311
|
+
|
|
312
|
+
// Reply to any pending correlated agent_message senders
|
|
313
|
+
if (content && pendingCorrelationIds.length > 0) {
|
|
314
|
+
for (const cid of pendingCorrelationIds) postCorrelatedResponse(cid, content);
|
|
315
|
+
pendingCorrelationIds = [];
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// Persist assistant message and capture DB id for message event
|
|
319
|
+
const assistantCreatedAt = new Date().toISOString();
|
|
320
|
+
let assistantMsgId = null;
|
|
321
|
+
if (sessionId) {
|
|
322
|
+
assistantMsgId = addMessage({ sessionId, role: 'assistant', content: content || null, toolCalls,
|
|
323
|
+
modelKey, inputTokens: usage.input, outputTokens: usage.output, cacheTokens: usage.cache, cost: turnCost });
|
|
324
|
+
updateSession(sessionId, {
|
|
325
|
+
totalInputTokens: totalTokens.input, totalOutputTokens: totalTokens.output,
|
|
326
|
+
totalCacheTokens: totalTokens.cache, cost: totalTokens.cost,
|
|
327
|
+
contextSize: usage.input + usage.output,
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
if (taskId) {
|
|
331
|
+
updateTask(taskId, { tokenInput: totalTokens.input, tokenOutput: totalTokens.output, tokenCache: totalTokens.cache, iterations: iteration });
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// Emit session-aligned message event (all modes, all iterations with content or tool calls)
|
|
335
|
+
if (content || toolCalls) yield {
|
|
336
|
+
type: 'message',
|
|
337
|
+
message: {
|
|
338
|
+
id: assistantMsgId,
|
|
339
|
+
session_id: sessionId || null,
|
|
340
|
+
role: 'assistant',
|
|
341
|
+
content: content || null,
|
|
342
|
+
tool_calls: toolCalls || null,
|
|
343
|
+
tool_call_id: null,
|
|
344
|
+
created_at: assistantCreatedAt,
|
|
345
|
+
},
|
|
346
|
+
finishReason,
|
|
347
|
+
iteration,
|
|
348
|
+
tokenUsage: { input: usage.input, output: usage.output, cache: usage.cache, cost: turnCost },
|
|
349
|
+
};
|
|
350
|
+
|
|
351
|
+
// ── Token budget check ───────────────────────────────────────────────────
|
|
352
|
+
const effectiveBudget = tokenBudget || modeConfig.tokenBudget;
|
|
353
|
+
if (effectiveBudget && (totalTokens.input + totalTokens.output) >= effectiveBudget) {
|
|
354
|
+
const tokensUsed = totalTokens.input + totalTokens.output;
|
|
355
|
+
const errStr = buildStructuredError({ code: 'BUDGET_EXCEEDED', message: `Token budget exhausted: ${tokensUsed} / ${effectiveBudget}`, iteration, tokensUsed, elapsed: (Date.now() - startTime) / 1000 });
|
|
356
|
+
yield { type: 'limit.reached', reason: 'tokenBudget', tokensUsed, tokenBudget: effectiveBudget };
|
|
357
|
+
if (taskId) {
|
|
358
|
+
updateTask(taskId, { status: 'failed', error: errStr, finishedAt: new Date().toISOString() });
|
|
359
|
+
addTaskEvent({ taskId, type: 'status.change', data: { from: 'processing', to: 'failed', reason: 'tokenBudget', tokensUsed, tokenBudget: effectiveBudget } });
|
|
360
|
+
emitBusEvent({ type: 'task.status', taskId, agentName: agent.name, event: { status: 'failed', error: JSON.parse(errStr) } });
|
|
361
|
+
}
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// ── Tool calls ───────────────────────────────────────────────────────────
|
|
366
|
+
if (toolCalls && toolCalls.length > 0) {
|
|
367
|
+
lastToolCallIteration = iteration;
|
|
368
|
+
stallCount = 0;
|
|
369
|
+
|
|
370
|
+
for (const tc of toolCalls) {
|
|
371
|
+
const toolName = tc.function.name;
|
|
372
|
+
let toolInput;
|
|
373
|
+
try {
|
|
374
|
+
toolInput = JSON.parse(tc.function.arguments || '{}');
|
|
375
|
+
} catch {
|
|
376
|
+
toolInput = {};
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
yield { type: 'tool.start', toolName, toolInput };
|
|
380
|
+
if (taskId) addTaskEvent({ taskId, type: 'tool.start', data: { toolName, toolInput } });
|
|
381
|
+
emitBusEvent({ type: taskId ? 'task.event' : 'chat.tool', taskId, sessionId, agentName: agent.name, event: { type: 'tool.start', toolName, toolInput } });
|
|
382
|
+
|
|
383
|
+
const toolStartTime = Date.now();
|
|
384
|
+
|
|
385
|
+
// Mid-tool cancellation check
|
|
386
|
+
if (cancelSignal?.aborted) {
|
|
387
|
+
yield { type: 'status.change', from: 'processing', to: 'canceled', reason: 'user_cancel' };
|
|
388
|
+
if (taskId) {
|
|
389
|
+
updateTask(taskId, { status: 'canceled', finishedAt: new Date().toISOString() });
|
|
390
|
+
addTaskEvent({ taskId, type: 'status.change', data: { from: 'processing', to: 'canceled', reason: 'user_cancel' } });
|
|
391
|
+
emitBusEvent({ type: 'task.status', taskId, agentName: agent.name, event: { status: 'canceled', reason: 'user_cancel' } });
|
|
392
|
+
}
|
|
393
|
+
return;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// Permission check
|
|
397
|
+
const permission = checkPermission({ toolName, toolInput, mode, settings, agentConfig: agent, modeConfig });
|
|
398
|
+
if (permission === 'deny') {
|
|
399
|
+
const toolResult = { role: 'tool', tool_call_id: tc.id, content: `Permission denied for tool "${toolName}"` };
|
|
400
|
+
messages.push(toolResult);
|
|
401
|
+
yield { type: 'tool.end', toolName, toolInput, durationMs: 0, success: false, output: 'Permission denied', outputPreview: 'Permission denied' };
|
|
402
|
+
if (taskId) addTaskEvent({ taskId, type: 'tool.end', data: { toolName, durationMs: 0, success: false, outputPreview: 'Permission denied' } });
|
|
403
|
+
emitBusEvent({ type: taskId ? 'task.event' : 'chat.tool', taskId, sessionId, agentName: agent.name, event: { type: 'tool.end', toolName, durationMs: 0, success: false, outputPreview: 'Permission denied' } });
|
|
404
|
+
continue;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// In chat mode with 'ask', we'll treat as allow for now (interactive approval TBD in v2)
|
|
408
|
+
|
|
409
|
+
// Validate input
|
|
410
|
+
const { valid, errors } = validateToolInput(toolName, toolInput);
|
|
411
|
+
if (!valid) {
|
|
412
|
+
const errMsg = `Invalid tool input for "${toolName}": ${errors.join('; ')}`;
|
|
413
|
+
messages.push({ role: 'tool', tool_call_id: tc.id, content: errMsg });
|
|
414
|
+
yield { type: 'tool.end', toolName, toolInput, durationMs: 0, success: false, output: errMsg, outputPreview: errMsg };
|
|
415
|
+
continue;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// Resolve and execute
|
|
419
|
+
const tool = resolveTool({ name: toolName, agentConfig: agent, cwd });
|
|
420
|
+
let toolOutput;
|
|
421
|
+
let toolSuccess = true;
|
|
422
|
+
|
|
423
|
+
if (!tool) {
|
|
424
|
+
toolOutput = `Tool "${toolName}" not found`;
|
|
425
|
+
toolSuccess = false;
|
|
426
|
+
} else {
|
|
427
|
+
try {
|
|
428
|
+
// Run PreToolUse hook if configured
|
|
429
|
+
await runHook({ hookType: 'PreToolUse', toolName, toolInput, settings, agent, sessionId, taskId, mode, cwd });
|
|
430
|
+
|
|
431
|
+
const _remoteMethodExecution = ({ method, data, timeoutMs } = {}) =>
|
|
432
|
+
remoteMethodQueue.enqueue({ method, data, timeoutMs });
|
|
433
|
+
|
|
434
|
+
const result = await Promise.race([
|
|
435
|
+
tool.execute({ ...toolInput, _cwd: cwd, _agent: agent, _sessionId: sessionId, _taskId: taskId, _settings: settings, _remoteMethodExecution }),
|
|
436
|
+
new Promise((_, reject) => setTimeout(() => reject(new Error(`Tool timeout after ${tool.schema.timeout || 30}s`)), (tool.schema.timeout || 30) * 1000))
|
|
437
|
+
]);
|
|
438
|
+
|
|
439
|
+
toolOutput = typeof result === 'string' ? result : JSON.stringify(result, null, 2);
|
|
440
|
+
|
|
441
|
+
// Run PostToolUse hook if configured
|
|
442
|
+
await runHook({ hookType: 'PostToolUse', toolName, toolInput, toolOutput, settings, agent, sessionId, taskId, mode, cwd });
|
|
443
|
+
} catch (err) {
|
|
444
|
+
toolOutput = `Tool error: ${err.message}`;
|
|
445
|
+
toolSuccess = false;
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
const durationMs = Date.now() - toolStartTime;
|
|
450
|
+
const toolContent = String(toolOutput);
|
|
451
|
+
const outputPreview = toolContent.slice(0, 200);
|
|
452
|
+
const toolCreatedAt = new Date().toISOString();
|
|
453
|
+
|
|
454
|
+
messages.push({ role: 'tool', tool_call_id: tc.id, content: toolContent });
|
|
455
|
+
let toolMsgId = null;
|
|
456
|
+
if (sessionId) toolMsgId = addMessage({ sessionId, role: 'tool', toolCallId: tc.id, content: toolContent });
|
|
457
|
+
|
|
458
|
+
yield {
|
|
459
|
+
type: 'message',
|
|
460
|
+
message: {
|
|
461
|
+
id: toolMsgId,
|
|
462
|
+
session_id: sessionId || null,
|
|
463
|
+
role: 'tool',
|
|
464
|
+
content: toolContent,
|
|
465
|
+
tool_calls: null,
|
|
466
|
+
tool_call_id: tc.id,
|
|
467
|
+
created_at: toolCreatedAt,
|
|
468
|
+
},
|
|
469
|
+
};
|
|
470
|
+
yield { type: 'tool.end', toolName, toolInput, durationMs, success: toolSuccess, output: toolContent, outputPreview };
|
|
471
|
+
if (taskId) addTaskEvent({ taskId, type: 'tool.end', data: { toolName, durationMs, success: toolSuccess, outputPreview } });
|
|
472
|
+
emitBusEvent({ type: taskId ? 'task.event' : 'chat.tool', taskId, sessionId, agentName: agent.name, event: { type: 'tool.end', toolName, durationMs, success: toolSuccess, outputPreview } });
|
|
473
|
+
|
|
474
|
+
// Explicit activation: inject the requested custom tool into llmTools for the next LLM call
|
|
475
|
+
if (toolName === 'tool_activate' && toolSuccess) {
|
|
476
|
+
const requestedName = toolInput.name;
|
|
477
|
+
if (requestedName && !llmTools.find(lt => lt.function.name === requestedName)) {
|
|
478
|
+
const oaiFmt = activateToolByName(requestedName, agent, cwd, modeConfig);
|
|
479
|
+
if (oaiFmt) llmTools.push(oaiFmt);
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
// Drain non-followup messages after each tool
|
|
484
|
+
const { messages: toolNonFollowupMsgs, correlationIds: toolDrainedIds } = drainNonFollowup(agent.name, sessionId);
|
|
485
|
+
messages.push(...toolNonFollowupMsgs);
|
|
486
|
+
if (toolDrainedIds.length > 0) pendingCorrelationIds.push(...toolDrainedIds);
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
// Continue loop (send tool results back to LLM)
|
|
490
|
+
continue;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
// ── No tool calls — agent produced a text response ────────────────────
|
|
494
|
+
stallCount++;
|
|
495
|
+
|
|
496
|
+
// Drain followup messages
|
|
497
|
+
const followupMsgs = drainFollowup(agent.name, sessionId);
|
|
498
|
+
messages.push(...followupMsgs);
|
|
499
|
+
|
|
500
|
+
// Mode-specific completion logic
|
|
501
|
+
if (mode === 'chat') {
|
|
502
|
+
// Chat mode: return response, stop
|
|
503
|
+
yield { type: 'chat.response', content, sessionId, totalTokens: { ...totalTokens }, model: modelKey, iteration };
|
|
504
|
+
return;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
if (mode === 'task' || mode === 'subagent') {
|
|
508
|
+
// Task done if agent produced a final answer (finish_reason = stop with no tools)
|
|
509
|
+
if (finishReason === 'stop') {
|
|
510
|
+
yield { type: 'task.complete', content, sessionId, taskId };
|
|
511
|
+
if (taskId) {
|
|
512
|
+
updateTask(taskId, { status: 'finished', output: content, finishedAt: new Date().toISOString() });
|
|
513
|
+
addTaskEvent({ taskId, type: 'status.change', data: { from: 'processing', to: 'finished' } });
|
|
514
|
+
emitBusEvent({ type: 'task.status', taskId, agentName: agent.name, event: { status: 'finished', output: content ? content.slice(0, 500) : null } });
|
|
515
|
+
}
|
|
516
|
+
return;
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
if (mode === 'daemon') {
|
|
521
|
+
// Daemon done after one response
|
|
522
|
+
yield { type: 'daemon.tick.complete', content };
|
|
523
|
+
return;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
// If we reach here with no tool calls and no return, continue (handles edge cases)
|
|
527
|
+
}
|
|
528
|
+
} finally {
|
|
529
|
+
runningSessions.delete(sessionId);
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
/**
|
|
534
|
+
* Run a PreToolUse or PostToolUse hook.
|
|
535
|
+
* @param {{ hookType: string, toolName: string, toolInput: Object, toolOutput?: string, settings: Object, agent: Object, sessionId?: string, taskId?: string, mode: string, cwd: string }} opts
|
|
536
|
+
*/
|
|
537
|
+
async function runHook({ hookType, toolName, toolInput, toolOutput, settings, agent, sessionId, taskId, mode, cwd }) {
|
|
538
|
+
const hookCmd = settings.hooks?.[hookType];
|
|
539
|
+
if (!hookCmd) return;
|
|
540
|
+
|
|
541
|
+
const env = {
|
|
542
|
+
...process.env,
|
|
543
|
+
VEIL_TOOL_NAME: toolName,
|
|
544
|
+
VEIL_TOOL_INPUT: JSON.stringify(toolInput),
|
|
545
|
+
VEIL_AGENT_NAME: agent.name,
|
|
546
|
+
VEIL_SESSION_ID: sessionId || '',
|
|
547
|
+
VEIL_TASK_ID: taskId || '',
|
|
548
|
+
VEIL_MODE: mode,
|
|
549
|
+
VEIL_AGENT_FOLDER: agent.agentFolder || '',
|
|
550
|
+
};
|
|
551
|
+
if (toolOutput !== undefined) env.VEIL_TOOL_OUTPUT = JSON.stringify(toolOutput);
|
|
552
|
+
|
|
553
|
+
try {
|
|
554
|
+
const { execSync } = require('child_process');
|
|
555
|
+
execSync(hookCmd, { cwd, env, timeout: 10000, stdio: 'ignore' });
|
|
556
|
+
} catch (err) {
|
|
557
|
+
if (hookType === 'PreToolUse' && err.status === 1) {
|
|
558
|
+
throw new Error(`PreToolUse hook blocked tool execution`);
|
|
559
|
+
}
|
|
560
|
+
// PostToolUse failures are logged but don't affect tool result
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
module.exports = { runLoop, checkPermission };
|
package/core/memory.js
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const paths = require('../utils/paths');
|
|
6
|
+
|
|
7
|
+
const MAX_LINES_DEFAULT = 200;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Append content to memory, then check line cap and export if needed.
|
|
11
|
+
* @param {{ cwd: string, agentName: string, scope?: 'agent'|'global', content: string, maxLines?: number }} opts
|
|
12
|
+
*/
|
|
13
|
+
function writeMemory({ cwd, agentName, scope = 'agent', content, maxLines = MAX_LINES_DEFAULT }) {
|
|
14
|
+
const dir = scope === 'global'
|
|
15
|
+
? paths.getProjectMemoryDir(cwd)
|
|
16
|
+
: paths.getAgentMemoryDir(cwd, agentName);
|
|
17
|
+
|
|
18
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
19
|
+
|
|
20
|
+
const filePath = path.join(dir, 'MEMORY.md');
|
|
21
|
+
const timestamp = new Date().toISOString().slice(0, 10);
|
|
22
|
+
const entry = `\n\n<!-- ${timestamp} -->\n${content.trim()}`;
|
|
23
|
+
|
|
24
|
+
fs.appendFileSync(filePath, entry, 'utf8');
|
|
25
|
+
|
|
26
|
+
// Check line cap — if exceeded, export oldest content to topic files
|
|
27
|
+
const lines = fs.readFileSync(filePath, 'utf8').split('\n');
|
|
28
|
+
if (lines.length > maxLines) {
|
|
29
|
+
exportOldMemory({ filePath, dir, lines, maxLines });
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Export overflow memory lines to a dated archive file.
|
|
35
|
+
* @param {{ filePath: string, dir: string, lines: string[], maxLines: number }} opts
|
|
36
|
+
*/
|
|
37
|
+
function exportOldMemory({ filePath, dir, lines, maxLines }) {
|
|
38
|
+
const keepFrom = lines.length - maxLines;
|
|
39
|
+
const overflow = lines.slice(0, keepFrom).join('\n');
|
|
40
|
+
const keep = lines.slice(keepFrom).join('\n');
|
|
41
|
+
|
|
42
|
+
const date = new Date().toISOString().slice(0, 7); // YYYY-MM
|
|
43
|
+
const archivePath = path.join(dir, `archive-${date}.md`);
|
|
44
|
+
|
|
45
|
+
if (overflow.trim()) {
|
|
46
|
+
fs.appendFileSync(archivePath, overflow + '\n', 'utf8');
|
|
47
|
+
}
|
|
48
|
+
fs.writeFileSync(filePath, keep, 'utf8');
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
module.exports = { writeMemory };
|