mocode-ai 0.7.3 → 1.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +58 -4
- package/README.zh-CN.md +58 -4
- package/dist/agent/core.js +86 -34
- package/dist/agent/index.js +57 -6
- package/dist/agent/spawn.js +74 -30
- package/dist/agents/coordinator.js +60 -0
- package/dist/changeset/index.js +289 -0
- package/dist/changeset/types.js +1 -0
- package/dist/config/index.js +25 -13
- package/dist/context/artifacts.js +254 -0
- package/dist/context/classifier.js +1 -1
- package/dist/context/index.js +1 -0
- package/dist/i18n/index.js +16 -4
- package/dist/llm/index.js +2 -2
- package/dist/repl/index.js +31 -5
- package/dist/rollback/index.js +43 -8
- package/dist/sandbox/index.js +1 -1
- package/dist/sandbox/policy.js +2 -2
- package/dist/sandbox/root.js +9 -3
- package/dist/session/compact.js +9 -3
- package/dist/session/scheduler.js +4 -0
- package/dist/session/state.js +3 -19
- package/dist/tools/builtins/apply-patch.js +174 -0
- package/dist/tools/builtins/edit-file.js +59 -46
- package/dist/tools/builtins/index.js +10 -8
- package/dist/tools/builtins/read-file.js +4 -2
- package/dist/tools/builtins/task.js +47 -16
- package/dist/tools/builtins/write-file.js +52 -17
- package/dist/tools/constants.js +3 -3
- package/dist/tools/registry.js +5 -2
- package/dist/ui/batch.js +56 -24
- package/dist/ui/layout.js +8 -3
- package/package.json +1 -1
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
3
|
+
import { estimateTokens } from '../llm/index.js';
|
|
4
|
+
import { canonicalizePath, extractPath, toText } from './utils.js';
|
|
5
|
+
const states = new WeakMap();
|
|
6
|
+
const STALE_PREFIX = '⌦[stale artifact:';
|
|
7
|
+
const ARTIFACT_HEADER = /^\[artifact\s+([^\]]+)\]\n?/;
|
|
8
|
+
function stateFor(state) {
|
|
9
|
+
let current = states.get(state);
|
|
10
|
+
if (!current) {
|
|
11
|
+
current = { artifacts: new Map() };
|
|
12
|
+
states.set(state, current);
|
|
13
|
+
}
|
|
14
|
+
return current;
|
|
15
|
+
}
|
|
16
|
+
function parseArgs(raw) {
|
|
17
|
+
try {
|
|
18
|
+
const value = JSON.parse(raw || '{}');
|
|
19
|
+
return value && typeof value === 'object' ? value : null;
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
function callArgs(history, idx) {
|
|
26
|
+
const id = history[idx].tool_call_id;
|
|
27
|
+
if (!id)
|
|
28
|
+
return null;
|
|
29
|
+
for (let cursor = idx - 1; cursor >= 0; cursor--) {
|
|
30
|
+
const message = history[cursor];
|
|
31
|
+
if (message.role !== 'assistant')
|
|
32
|
+
continue;
|
|
33
|
+
const calls = message.tool_calls;
|
|
34
|
+
const hit = calls?.find((call) => call.id === id);
|
|
35
|
+
if (hit?.function?.name)
|
|
36
|
+
return { tool: hit.function.name, argsRaw: hit.function.arguments ?? '{}' };
|
|
37
|
+
}
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
function sourceType(tool) {
|
|
41
|
+
if (tool === 'read_file')
|
|
42
|
+
return 'read';
|
|
43
|
+
if (tool === 'grep' || tool === 'glob' || tool === 'codegraph')
|
|
44
|
+
return 'search';
|
|
45
|
+
if (tool === 'run_command')
|
|
46
|
+
return 'diagnostic';
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
function pathsFromOutput(tool, output) {
|
|
50
|
+
const paths = new Set();
|
|
51
|
+
const add = (value) => {
|
|
52
|
+
const normalized = canonicalizePath(value);
|
|
53
|
+
if (normalized)
|
|
54
|
+
paths.add(normalized);
|
|
55
|
+
};
|
|
56
|
+
if (tool === 'grep' || tool === 'codegraph' || tool === 'run_command') {
|
|
57
|
+
const expression = /^(.+?\.[A-Za-z0-9]+):(?:\d+|\s*\d+\s*(?:处匹配|matches?))/gmi;
|
|
58
|
+
let match;
|
|
59
|
+
while ((match = expression.exec(output)))
|
|
60
|
+
add(match[1]);
|
|
61
|
+
}
|
|
62
|
+
else if (tool === 'glob') {
|
|
63
|
+
for (const line of output.split(/\r?\n/)) {
|
|
64
|
+
const value = line.trim();
|
|
65
|
+
if (value && !value.includes(' ') && (value.includes('/') || value.includes('\\')))
|
|
66
|
+
add(value);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return [...paths];
|
|
70
|
+
}
|
|
71
|
+
function parseReadHash(output) {
|
|
72
|
+
return /\bhash=(sha256:[a-f0-9]{64})\b/i.exec(output)?.[1]?.toLowerCase();
|
|
73
|
+
}
|
|
74
|
+
function currentFileHash(file) {
|
|
75
|
+
if (file === '*')
|
|
76
|
+
return undefined;
|
|
77
|
+
try {
|
|
78
|
+
return `sha256:${createHash('sha256').update(readFileSync(file)).digest('hex')}`;
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
return undefined;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
function updateStats(state, artifactState) {
|
|
85
|
+
const stats = { fresh: 0, stale: 0, stubbed: 0, tokensBySource: {} };
|
|
86
|
+
for (const artifact of artifactState.artifacts.values()) {
|
|
87
|
+
stats[artifact.freshness] += 1;
|
|
88
|
+
stats.tokensBySource[artifact.source.type] =
|
|
89
|
+
(stats.tokensBySource[artifact.source.type] ?? 0) + artifact.tokenCount;
|
|
90
|
+
}
|
|
91
|
+
state.artifactStats = stats;
|
|
92
|
+
}
|
|
93
|
+
export function recordArtifact(state, history, idx, output, succeeded) {
|
|
94
|
+
if (!succeeded)
|
|
95
|
+
return;
|
|
96
|
+
const call = callArgs(history, idx);
|
|
97
|
+
if (!call)
|
|
98
|
+
return;
|
|
99
|
+
const type = sourceType(call.tool);
|
|
100
|
+
if (!type)
|
|
101
|
+
return;
|
|
102
|
+
const args = parseArgs(call.argsRaw);
|
|
103
|
+
const id = history[idx].tool_call_id ?? `${idx}`;
|
|
104
|
+
const directPath = canonicalizePath(extractPath(call.argsRaw));
|
|
105
|
+
let dependencies = directPath
|
|
106
|
+
? [{ path: directPath }]
|
|
107
|
+
: pathsFromOutput(call.tool, output).map((path) => ({ path }));
|
|
108
|
+
// Diagnostics and searches with no parseable result conservatively depend on the workspace.
|
|
109
|
+
if (dependencies.length === 0 && type !== 'read')
|
|
110
|
+
dependencies = [{ path: '*' }];
|
|
111
|
+
// Capture dependency versions now; future scheduler steps can detect external changes.
|
|
112
|
+
dependencies = dependencies.map((dependency) => {
|
|
113
|
+
const hashAtCreation = currentFileHash(dependency.path);
|
|
114
|
+
return { ...dependency, ...(hashAtCreation ? { hash: hashAtCreation } : {}) };
|
|
115
|
+
});
|
|
116
|
+
const hash = type === 'read' ? parseReadHash(output) : undefined;
|
|
117
|
+
if (hash && dependencies[0])
|
|
118
|
+
dependencies[0].hash = hash;
|
|
119
|
+
const content = toText(history[idx].content);
|
|
120
|
+
const artifact = {
|
|
121
|
+
id,
|
|
122
|
+
source: { type, tool: call.tool, toolCallId: id },
|
|
123
|
+
...(hash ? { hash } : {}),
|
|
124
|
+
version: Date.now(),
|
|
125
|
+
dependencies,
|
|
126
|
+
freshness: content.startsWith('⌦[') ? 'stubbed' : 'fresh',
|
|
127
|
+
rebuildable: true,
|
|
128
|
+
tokenCount: estimateTokens(content),
|
|
129
|
+
messageIndex: idx,
|
|
130
|
+
};
|
|
131
|
+
stateFor(state).artifacts.set(id, artifact);
|
|
132
|
+
updateStats(state, stateFor(state));
|
|
133
|
+
}
|
|
134
|
+
function affected(artifact, changed) {
|
|
135
|
+
return artifact.dependencies.some((dependency) => dependency.path === '*' || changed.has(dependency.path));
|
|
136
|
+
}
|
|
137
|
+
/** Mark and immediately stub stale facts; this is stronger than waiting for budget pressure. */
|
|
138
|
+
export function invalidateArtifacts(state, history, changedFiles) {
|
|
139
|
+
const changed = new Set(changedFiles.map(canonicalizePath).filter((item) => !!item));
|
|
140
|
+
if (changed.size === 0)
|
|
141
|
+
return 0;
|
|
142
|
+
const artifactState = stateFor(state);
|
|
143
|
+
let count = 0;
|
|
144
|
+
for (const artifact of artifactState.artifacts.values()) {
|
|
145
|
+
if (artifact.freshness !== 'fresh' || !affected(artifact, changed))
|
|
146
|
+
continue;
|
|
147
|
+
artifact.freshness = 'stale';
|
|
148
|
+
const message = history[artifact.messageIndex];
|
|
149
|
+
if (message?.role === 'tool') {
|
|
150
|
+
const original = toText(message.content);
|
|
151
|
+
const paths = artifact.dependencies.map((item) => item.path).join(', ');
|
|
152
|
+
const stub = `${STALE_PREFIX}${artifact.source.tool}] source=${artifact.id} dependencies=${paths} ` +
|
|
153
|
+
`invalidated-by=${[...changed].join(', ')}; re-run ${artifact.source.tool} before using this fact.`;
|
|
154
|
+
message.content = stub;
|
|
155
|
+
artifact.tokenCount = estimateTokens(stub);
|
|
156
|
+
}
|
|
157
|
+
count++;
|
|
158
|
+
}
|
|
159
|
+
updateStats(state, artifactState);
|
|
160
|
+
return count;
|
|
161
|
+
}
|
|
162
|
+
/** Rebuild indices after resume/compaction while retaining versions for surviving tool_call IDs. */
|
|
163
|
+
export function rehydrateArtifacts(state, history) {
|
|
164
|
+
const artifactState = stateFor(state);
|
|
165
|
+
const previous = new Map(artifactState.artifacts);
|
|
166
|
+
artifactState.artifacts.clear();
|
|
167
|
+
for (let idx = 0; idx < history.length; idx++) {
|
|
168
|
+
const message = history[idx];
|
|
169
|
+
if (message.role !== 'tool')
|
|
170
|
+
continue;
|
|
171
|
+
const call = callArgs(history, idx);
|
|
172
|
+
if (!call || !sourceType(call.tool))
|
|
173
|
+
continue;
|
|
174
|
+
const content = toText(message.content);
|
|
175
|
+
const id = message.tool_call_id ?? `${idx}`;
|
|
176
|
+
const retained = previous.get(id);
|
|
177
|
+
if (retained) {
|
|
178
|
+
retained.messageIndex = idx;
|
|
179
|
+
retained.tokenCount = estimateTokens(content);
|
|
180
|
+
retained.freshness = content.startsWith(STALE_PREFIX)
|
|
181
|
+
? 'stale'
|
|
182
|
+
: content.startsWith('⌦[') ? 'stubbed' : retained.freshness;
|
|
183
|
+
artifactState.artifacts.set(id, retained);
|
|
184
|
+
}
|
|
185
|
+
else {
|
|
186
|
+
recordArtifact(state, history, idx, content, true);
|
|
187
|
+
const artifact = artifactState.artifacts.get(id);
|
|
188
|
+
if (artifact && content.startsWith(STALE_PREFIX))
|
|
189
|
+
artifact.freshness = 'stale';
|
|
190
|
+
else if (artifact && content.startsWith('⌦['))
|
|
191
|
+
artifact.freshness = 'stubbed';
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
updateStats(state, artifactState);
|
|
195
|
+
}
|
|
196
|
+
/** Compare captured dependency versions before each model step to detect external edits. */
|
|
197
|
+
export function refreshArtifactFreshness(state, history) {
|
|
198
|
+
const changed = new Set();
|
|
199
|
+
for (const artifact of stateFor(state).artifacts.values()) {
|
|
200
|
+
if (artifact.freshness !== 'fresh')
|
|
201
|
+
continue;
|
|
202
|
+
for (const dependency of artifact.dependencies) {
|
|
203
|
+
if (!dependency.hash || dependency.path === '*')
|
|
204
|
+
continue;
|
|
205
|
+
if (currentFileHash(dependency.path) !== dependency.hash)
|
|
206
|
+
changed.add(dependency.path);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
return changed.size > 0 ? invalidateArtifacts(state, history, [...changed]) : 0;
|
|
210
|
+
}
|
|
211
|
+
/** Scheduler entry point: stale artifacts are already stubs; normalize any resumed stale message first. */
|
|
212
|
+
export function pruneStaleArtifacts(state, history) {
|
|
213
|
+
const artifactState = stateFor(state);
|
|
214
|
+
let pruned = 0;
|
|
215
|
+
for (const artifact of artifactState.artifacts.values()) {
|
|
216
|
+
if (artifact.freshness !== 'stale')
|
|
217
|
+
continue;
|
|
218
|
+
const message = history[artifact.messageIndex];
|
|
219
|
+
if (message?.role !== 'tool')
|
|
220
|
+
continue;
|
|
221
|
+
const content = toText(message.content);
|
|
222
|
+
if (!content.startsWith(STALE_PREFIX)) {
|
|
223
|
+
message.content = `${STALE_PREFIX}${artifact.source.tool}] source=${artifact.id}; re-run before use.`;
|
|
224
|
+
artifact.tokenCount = estimateTokens(String(message.content));
|
|
225
|
+
pruned++;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
updateStats(state, artifactState);
|
|
229
|
+
return pruned;
|
|
230
|
+
}
|
|
231
|
+
export function collectArtifactRefs(messages) {
|
|
232
|
+
const refs = new Set();
|
|
233
|
+
for (const message of messages) {
|
|
234
|
+
if (message.role !== 'tool')
|
|
235
|
+
continue;
|
|
236
|
+
const id = message.tool_call_id;
|
|
237
|
+
if (id)
|
|
238
|
+
refs.add(id);
|
|
239
|
+
const content = toText(message.content);
|
|
240
|
+
const hash = parseReadHash(content);
|
|
241
|
+
if (hash)
|
|
242
|
+
refs.add(hash);
|
|
243
|
+
}
|
|
244
|
+
return [...refs].slice(0, 24);
|
|
245
|
+
}
|
|
246
|
+
export function formatArtifactTokenSources(stats) {
|
|
247
|
+
if (!stats)
|
|
248
|
+
return 'none';
|
|
249
|
+
const entries = Object.entries(stats.tokensBySource)
|
|
250
|
+
.filter((entry) => typeof entry[1] === 'number' && entry[1] > 0)
|
|
251
|
+
.sort((left, right) => right[1] - left[1]);
|
|
252
|
+
return entries.length > 0 ? entries.map(([source, tokens]) => `${source} ${tokens}`).join(' · ') : 'none';
|
|
253
|
+
}
|
|
254
|
+
export { STALE_PREFIX, ARTIFACT_HEADER };
|
package/dist/context/index.js
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
// (叶子级:仅 stdlib + tools/constants + session/compact 的 capToolResultForHistory 兜底 + config 开关)。
|
|
7
7
|
export { optimizeToolResult } from './pipeline.js';
|
|
8
8
|
export { classify, knownToolKinds } from './classifier.js';
|
|
9
|
+
export { recordArtifact, invalidateArtifacts, rehydrateArtifacts, refreshArtifactFreshness, pruneStaleArtifacts, collectArtifactRefs, formatArtifactTokenSources, } from './artifacts.js';
|
|
9
10
|
export { registerEncoder, registerAll, getEncoder, registeredKinds, } from './registry.js';
|
|
10
11
|
// ── Context Budget Scheduler ───────────────────────────────────────────────
|
|
11
12
|
export { evaluateBudget, scheduleActions, formatReport, quickEstimate, userTurnBoundary, BUDGET_LAYERS, DEFAULT_BUDGET_POLICY, BUDGET_RATIO, HOT_TURN_WINDOW, TOOL_OLD_AGE, } from './budget.js';
|
package/dist/i18n/index.js
CHANGED
|
@@ -169,6 +169,12 @@ const zhCN = {
|
|
|
169
169
|
'agent.validationNoCommand': '未发现验证命令',
|
|
170
170
|
'agent.validationResult': '自动验证 {command} → {status}',
|
|
171
171
|
'agent.workedFor': '耗时 {elapsed}',
|
|
172
|
+
'agent.toolsRunning': '正在探索',
|
|
173
|
+
'agent.toolsComplete': '探索',
|
|
174
|
+
'agent.toolsFailed': '探索失败',
|
|
175
|
+
'agent.changes': '文件变更',
|
|
176
|
+
'agent.files': '{count} 个文件',
|
|
177
|
+
'agent.complete': '完成',
|
|
172
178
|
'toolSummary.lines': '{count} 行',
|
|
173
179
|
'toolSummary.files': '{count} 个文件',
|
|
174
180
|
'toolSummary.matches': '{count} 处匹配',
|
|
@@ -212,8 +218,8 @@ const zhCN = {
|
|
|
212
218
|
'subagent.status': '子 Agent:{state}',
|
|
213
219
|
'subagent.stateOn': '开启',
|
|
214
220
|
'subagent.stateOff': '关闭',
|
|
215
|
-
'subagent.changedOn': '已开启子 Agent;
|
|
216
|
-
'subagent.changedOff': '已关闭子 Agent;
|
|
221
|
+
'subagent.changedOn': '已开启子 Agent;sub-agent 将从下一次模型请求起可用。',
|
|
222
|
+
'subagent.changedOff': '已关闭子 Agent;sub-agent 已从模型工具表移除。',
|
|
217
223
|
'subagent.usage': '用法:/subagent on|off|status',
|
|
218
224
|
'plan.ready': '计划已就绪',
|
|
219
225
|
'plan.approvalDetail': '切换到 auto 模式按上述计划执行?(plan 模式只读探查,执行需切 auto)',
|
|
@@ -395,6 +401,12 @@ const en = {
|
|
|
395
401
|
'agent.validationNoCommand': 'no validation command',
|
|
396
402
|
'agent.validationResult': 'Automatic validation {command} → {status}',
|
|
397
403
|
'agent.workedFor': 'Worked for {elapsed}',
|
|
404
|
+
'agent.toolsRunning': 'Exploring',
|
|
405
|
+
'agent.toolsComplete': 'Exploration',
|
|
406
|
+
'agent.toolsFailed': 'Exploration failed',
|
|
407
|
+
'agent.changes': 'Changes',
|
|
408
|
+
'agent.files': '{count} file(s)',
|
|
409
|
+
'agent.complete': 'Complete',
|
|
398
410
|
'toolSummary.lines': '{count} lines',
|
|
399
411
|
'toolSummary.files': '{count} files',
|
|
400
412
|
'toolSummary.matches': '{count} matches',
|
|
@@ -438,8 +450,8 @@ const en = {
|
|
|
438
450
|
'subagent.status': 'Sub-agent: {state}',
|
|
439
451
|
'subagent.stateOn': 'enabled',
|
|
440
452
|
'subagent.stateOff': 'disabled',
|
|
441
|
-
'subagent.changedOn': 'Sub-agents enabled;
|
|
442
|
-
'subagent.changedOff': 'Sub-agents disabled;
|
|
453
|
+
'subagent.changedOn': 'Sub-agents enabled; sub-agent will be available from the next model request.',
|
|
454
|
+
'subagent.changedOff': 'Sub-agents disabled; sub-agent has been removed from the model tool list.',
|
|
443
455
|
'subagent.usage': 'Usage: /subagent on|off|status',
|
|
444
456
|
'plan.ready': 'Plan ready',
|
|
445
457
|
'plan.approvalDetail': 'Switch to auto mode and execute the plan above? (Plan mode is read-only; execution requires auto mode.)',
|
package/dist/llm/index.js
CHANGED
|
@@ -149,10 +149,10 @@ export function __setChatCreateImpl(impl) {
|
|
|
149
149
|
export const chatTools = [];
|
|
150
150
|
export const planChatTools = [];
|
|
151
151
|
export function refreshChatTools() {
|
|
152
|
-
//
|
|
152
|
+
// sub-agent 常驻内部 registry,运行时开关只控制模型可见 schema,因而 on/off 可即时生效。
|
|
153
153
|
const visibleTools = isSubAgentEnabled()
|
|
154
154
|
? tools
|
|
155
|
-
: tools.filter((tool) => tool.name !== '
|
|
155
|
+
: tools.filter((tool) => tool.name !== 'sub-agent');
|
|
156
156
|
const next = visibleTools.map((t) => ({
|
|
157
157
|
type: 'function',
|
|
158
158
|
function: {
|
package/dist/repl/index.js
CHANGED
|
@@ -22,6 +22,7 @@ import { estimateMessagesTokens, reconfigureClient, refreshChatTools, chatTools,
|
|
|
22
22
|
import { loadImageAttachment, renderChip, MAX_INLINE_BYTES_DEFAULT, } from '../attachments/image.js';
|
|
23
23
|
import { modelSupportsVision } from '../llm/capabilities.js';
|
|
24
24
|
import { computePruneStats } from '../context/relevance.js';
|
|
25
|
+
import { formatArtifactTokenSources } from '../context/artifacts.js';
|
|
25
26
|
import { manualCompact, contextState, newSessionId, saveSession, loadSession, listSessions, appendCurrentSessionRuntimeEvent, hashTraceValue, } from '../session/index.js';
|
|
26
27
|
import { listTurns, planRollback, applyRollback, persistSnapshots, loadSnapshots, rebuildFromHistory, resetState, getCurrentTurnId, } from '../rollback/index.js';
|
|
27
28
|
import { listSkills, effectiveSystemPrompt, } from '../skills/index.js';
|
|
@@ -223,11 +224,15 @@ function renderContextBar(history) {
|
|
|
223
224
|
const pctCol = pct >= config.compactThreshold ? ui.yellow : ui.accent;
|
|
224
225
|
const lifecycle = contextState.lifecycleStats;
|
|
225
226
|
const archived = computePruneStats(history);
|
|
227
|
+
const artifactStats = contextState.artifactStats;
|
|
228
|
+
const artifactLine = artifactStats
|
|
229
|
+
? `\n artifacts · fresh ${artifactStats.fresh} · stale ${artifactStats.stale} · stubbed ${archived.stubbed} · tokens ${formatArtifactTokenSources(artifactStats)}`
|
|
230
|
+
: '\n artifacts · no file-backed facts recorded';
|
|
226
231
|
const lifecycleLine = lifecycle
|
|
227
232
|
? `\n lifecycle · live ${lifecycle.live} · referenced ${lifecycle.referenced} · digested ${lifecycle.digested} · stubbed ${lifecycle.stubbed}`
|
|
228
233
|
: '\n lifecycle · no active snapshot (run a tool-enabled turn first)';
|
|
229
234
|
const archiveLine = `\n archived tool results · ${archived.stubbed}`;
|
|
230
|
-
return `${ui.gray}[${pctCol}${bar}${ui.reset}] ${Math.round(pct * 100)}% ${k(est)}/${k(win)} tokens · ${t('status.messages', { count: history.length })} (${src})${ui.reset}${lifecycleLine}${archiveLine}`;
|
|
235
|
+
return `${ui.gray}[${pctCol}${bar}${ui.reset}] ${Math.round(pct * 100)}% ${k(est)}/${k(win)} tokens · ${t('status.messages', { count: history.length })} (${src})${ui.reset}${artifactLine}${lifecycleLine}${archiveLine}`;
|
|
231
236
|
}
|
|
232
237
|
/** 状态行用量条(精简版,进底栏):[bar] pct% k/k。
|
|
233
238
|
* 只计算对话内容(不含 system prompt),让用户感知"我发了多少、agent 回复了多少"占用 context。 */
|
|
@@ -485,6 +490,10 @@ function echoInput(lines, trailingBlank = true) {
|
|
|
485
490
|
for (const a of pendingAttachments) {
|
|
486
491
|
layout.contentWrite(` ${ui.dim}${renderChip(a)}${ui.reset}\n`);
|
|
487
492
|
}
|
|
493
|
+
// 用户气泡会填满终端整列宽;Windows Terminal 在末列进入 pending-wrap 后,紧随的 LF
|
|
494
|
+
// 偶尔只更新内部滚屏状态,导致上一轮耗时行与新气泡暂时黏连。缓冲中的物理行始终正确,
|
|
495
|
+
// 提交后立即按缓冲重绘,避免必须等滚动或 Agent 结束时的 repaint 才显示正确边界。
|
|
496
|
+
layout.repaintViewport();
|
|
488
497
|
}
|
|
489
498
|
/**
|
|
490
499
|
* 等待 pending 撤回窗口(用户 Enter 后、agent 真发请求前的 500ms 兜底)。
|
|
@@ -637,6 +646,12 @@ export function renderHistory(history) {
|
|
|
637
646
|
}
|
|
638
647
|
const tcs = m.tool_calls;
|
|
639
648
|
if (Array.isArray(tcs) && tcs.length > 0) {
|
|
649
|
+
if (text) {
|
|
650
|
+
// contentWriteMdOnce 会裁掉 markdown 尾部空行,而 history 中的原始 text 是否以 \n
|
|
651
|
+
// 结尾并不能代表当前物理布局。按缓冲中的视觉行归一化,和实时“正文 → 工具”
|
|
652
|
+
// 边界一致地保留一条空行,避免 /resume 回放时工具摘要紧贴正文。
|
|
653
|
+
layout.normalizeMutationBoundary();
|
|
654
|
+
}
|
|
640
655
|
// 累积到 pendingBatch,顺序 = tool_calls 序
|
|
641
656
|
for (const tc of tcs) {
|
|
642
657
|
const name = tc?.function?.name ?? '';
|
|
@@ -717,7 +732,7 @@ export async function startRepl(initialHistory, sessionId, updateNotice = null,
|
|
|
717
732
|
// 与开关联动:① base 用 buildBasePrompt() 取代 config.systemPrompt(后者是启动时一次性
|
|
718
733
|
// 求值的常量,运行时 /memory_switch 不会刷新);② plan suffix 走 getPlanModeSuffix() 现拼;
|
|
719
734
|
// ③ buildMemorySection 内已自决 ;④ buildMemoryIndexSection 显式传 isMemoryEnabled() 关闭段。
|
|
720
|
-
const buildSystemMessage = (planMode) => effectiveSystemPrompt(buildBasePrompt() +
|
|
735
|
+
const buildSystemMessage = (planMode) => effectiveSystemPrompt(buildBasePrompt(currentSessionId) +
|
|
721
736
|
(planMode ? getPlanModeSuffix() : '') +
|
|
722
737
|
buildMemorySection() +
|
|
723
738
|
buildMemoryIndexSection(isMemoryEnabled()));
|
|
@@ -891,6 +906,7 @@ export async function startRepl(initialHistory, sessionId, updateNotice = null,
|
|
|
891
906
|
rolledBackTurns: Math.max(0, turnCountBeforeRollback - plan.n),
|
|
892
907
|
deletedMessages: rollbackResult.deletedMsgs,
|
|
893
908
|
revertedFiles: rollbackResult.revertedFiles,
|
|
909
|
+
conflictedFiles: rollbackResult.conflictedFiles,
|
|
894
910
|
requestedFileCount: revertPaths.size,
|
|
895
911
|
}, plan.cutoffTurnId);
|
|
896
912
|
try {
|
|
@@ -903,6 +919,9 @@ export async function startRepl(initialHistory, sessionId, updateNotice = null,
|
|
|
903
919
|
// 复显剩余对话(无提示行),输入框预填该轮 user 输入 → 下轮 Enter 重新跑
|
|
904
920
|
layout.clearContent();
|
|
905
921
|
renderHistory(history);
|
|
922
|
+
if (rollbackResult.conflictedFiles.length > 0) {
|
|
923
|
+
layout.contentWrite(`${ui.yellow} ⚠ rollback conflict: ${rollbackResult.conflictedFiles.join(', ')} 已在 Agent 提交后变化,未覆盖。${ui.reset}\n`);
|
|
924
|
+
}
|
|
906
925
|
// 末尾补空行:与后续用户消息(❯ bubble)之间分隔。runTurn 在每个 agent 轮结束后
|
|
907
926
|
// contentWrite('\n') 做轮次分隔,/resume 后接 \n\n,/theme·/model 后接 \n;
|
|
908
927
|
// rollbackFlow 原本漏了这一行,renderHistory 末尾的 batch 摘要行 / assistant 文本
|
|
@@ -1021,6 +1040,9 @@ export async function startRepl(initialHistory, sessionId, updateNotice = null,
|
|
|
1021
1040
|
layout.contentWrite(`${ui.yellow}${t('repl.loadFailed')}${ui.reset}\n`);
|
|
1022
1041
|
return;
|
|
1023
1042
|
}
|
|
1043
|
+
// Bind before rebuilding the prompt, or it can retain the previous session's notes path.
|
|
1044
|
+
currentSessionId = loaded.id;
|
|
1045
|
+
setCurrentSessionId(loaded.id, process.cwd());
|
|
1024
1046
|
if (loaded.history[0]?.role === 'system') {
|
|
1025
1047
|
loaded.history[0] = { role: 'system', content: buildSystemMessage(false) };
|
|
1026
1048
|
}
|
|
@@ -1030,8 +1052,6 @@ export async function startRepl(initialHistory, sessionId, updateNotice = null,
|
|
|
1030
1052
|
? [...loaded.queryHistory]
|
|
1031
1053
|
: queryHistoryFromMessages(loaded.history);
|
|
1032
1054
|
setAgentMode('auto'); // 续接重置为 auto(mode 不落盘;listener 重写 history[0] 回 auto,与 loaded 幂等)
|
|
1033
|
-
currentSessionId = loaded.id;
|
|
1034
|
-
setCurrentSessionId(loaded.id, process.cwd()); // 切换会话:确保该会话的 notes.md 存在
|
|
1035
1055
|
// 读回该会话的轮次/快照;无文件则从 history 重建 turns(无快照→旧轮次文件改动不可撤销)
|
|
1036
1056
|
if (!loadSnapshots(loaded.id))
|
|
1037
1057
|
rebuildFromHistory(history);
|
|
@@ -1043,6 +1063,7 @@ export async function startRepl(initialHistory, sessionId, updateNotice = null,
|
|
|
1043
1063
|
// 末尾 \n\n:与后续用户消息(❯ bubble)之间空一行。
|
|
1044
1064
|
layout.contentWrite(`${ui.dim}${t('repl.resumed', { id: loaded.id })}${ui.reset}\n\n`);
|
|
1045
1065
|
}
|
|
1066
|
+
let hasSubmittedInput = false;
|
|
1046
1067
|
while (true) {
|
|
1047
1068
|
// INPUT 态:画底栏输入框 + 状态行,光标入输入框
|
|
1048
1069
|
refreshStatusBase(history);
|
|
@@ -1053,6 +1074,10 @@ export async function startRepl(initialHistory, sessionId, updateNotice = null,
|
|
|
1053
1074
|
layout.contentWrite(` ${ui.gray}↳ ${formatReflectResult(reflectRes)}${ui.reset}\n`);
|
|
1054
1075
|
clearLastReflectResult();
|
|
1055
1076
|
}
|
|
1077
|
+
// 所有斜杠命令和 Agent 轮次共用同一个输出→输入边界,避免某条命令漏写第二个 \n
|
|
1078
|
+
// 后下一条 ❯ 气泡紧贴确认文案;已有多余空行也会收敛为恰好一行。
|
|
1079
|
+
if (hasSubmittedInput)
|
|
1080
|
+
layout.normalizeInputBoundary();
|
|
1056
1081
|
layout.enterInputMode(t('repl.idle'));
|
|
1057
1082
|
let input = null;
|
|
1058
1083
|
try {
|
|
@@ -1080,6 +1105,7 @@ export async function startRepl(initialHistory, sessionId, updateNotice = null,
|
|
|
1080
1105
|
const line = joined.trim();
|
|
1081
1106
|
if (!line)
|
|
1082
1107
|
continue;
|
|
1108
|
+
hasSubmittedInput = true;
|
|
1083
1109
|
if (line === '/exit' || line === '/quit')
|
|
1084
1110
|
break;
|
|
1085
1111
|
// RUNNING 态:回显输入 → 底栏改 dim 占位、光标回内容续写位
|
|
@@ -2029,7 +2055,7 @@ export async function startRepl(initialHistory, sessionId, updateNotice = null,
|
|
|
2029
2055
|
const initPrompt = `请直接初始化或优化当前项目的 Project Skill,并完成写入。
|
|
2030
2056
|
|
|
2031
2057
|
要求:
|
|
2032
|
-
1. 直接由你完成,禁止调用
|
|
2058
|
+
1. 直接由你完成,禁止调用 sub-agent 工具或派生任何子 agent。
|
|
2033
2059
|
2. 优先利用系统提示中已有的 Project Snapshot 和 Project Skill;不要重复扫描其中已有的目录、依赖、命令和模块清单。
|
|
2034
2060
|
3. 最多进行 1 次 codegraph 探索;只有缺少关键依据时,才额外进行少量定点 read_file/grep。禁止全仓 glob 和逐文件扫描。
|
|
2035
2061
|
4. Skill 只记录 Snapshot 无法提供的 WHY/HOW/GOTCHAS/CONVENTIONS:设计取舍、关键调用链、非直觉边界、项目约定和可操作坑点。使用具体路径和例子,删除重复或过时内容。
|
package/dist/rollback/index.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
1
2
|
import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, readlinkSync, rmdirSync, rmSync, symlinkSync, unlinkSync, writeFileSync, } from 'node:fs';
|
|
2
3
|
import path from 'node:path';
|
|
3
4
|
import { config } from '../config/index.js';
|
|
@@ -52,6 +53,11 @@ function readState(full) {
|
|
|
52
53
|
function sameState(a, b) {
|
|
53
54
|
return a.kind === b.kind && a.data === b.data && a.mode === b.mode;
|
|
54
55
|
}
|
|
56
|
+
function stateFingerprint(state) {
|
|
57
|
+
return createHash('sha256')
|
|
58
|
+
.update(JSON.stringify([state.kind, state.data ?? null, state.mode ?? null]))
|
|
59
|
+
.digest('hex');
|
|
60
|
+
}
|
|
55
61
|
function stateFromSnapshot(snapshot) {
|
|
56
62
|
if (snapshot.kind) {
|
|
57
63
|
return { kind: snapshot.kind, data: snapshot.before ?? undefined, mode: snapshot.mode };
|
|
@@ -64,7 +70,7 @@ function stateFromSnapshot(snapshot) {
|
|
|
64
70
|
data: Buffer.from(snapshot.before, 'utf8').toString('base64'),
|
|
65
71
|
};
|
|
66
72
|
}
|
|
67
|
-
function snapshotFromState(rel, state, sequence, op, createdParents = []) {
|
|
73
|
+
function snapshotFromState(rel, state, sequence, op, createdParents = [], after) {
|
|
68
74
|
return {
|
|
69
75
|
turnId: currentTurnId,
|
|
70
76
|
path: rel,
|
|
@@ -75,6 +81,7 @@ function snapshotFromState(rel, state, sequence, op, createdParents = []) {
|
|
|
75
81
|
sequence,
|
|
76
82
|
ops: [op],
|
|
77
83
|
createdParents: createdParents.length > 0 ? createdParents : undefined,
|
|
84
|
+
afterFingerprint: after ? stateFingerprint(after) : undefined,
|
|
78
85
|
};
|
|
79
86
|
}
|
|
80
87
|
/** 同轮同路径只保留最早的 before;后续实际改动仅合并工具名。 */
|
|
@@ -90,11 +97,15 @@ function addSnapshot(next) {
|
|
|
90
97
|
const existingSequence = existing.sequence ?? Number.MAX_SAFE_INTEGER;
|
|
91
98
|
const nextSequence = next.sequence ?? Number.MAX_SAFE_INTEGER;
|
|
92
99
|
const ops = new Set([...(existing.ops ?? []), ...(next.ops ?? [])]);
|
|
100
|
+
const latestAfterFingerprint = nextSequence >= existingSequence
|
|
101
|
+
? next.afterFingerprint
|
|
102
|
+
: existing.afterFingerprint;
|
|
93
103
|
if (nextSequence < existingSequence) {
|
|
94
|
-
snapshots[existingIndex] = { ...next, ops: [...ops] };
|
|
104
|
+
snapshots[existingIndex] = { ...next, ops: [...ops], afterFingerprint: latestAfterFingerprint };
|
|
95
105
|
}
|
|
96
106
|
else {
|
|
97
107
|
existing.ops = [...ops];
|
|
108
|
+
existing.afterFingerprint = latestAfterFingerprint;
|
|
98
109
|
}
|
|
99
110
|
}
|
|
100
111
|
function missingParents(full) {
|
|
@@ -139,14 +150,14 @@ export function endPathMutation(capture, op) {
|
|
|
139
150
|
const after = readState(full);
|
|
140
151
|
if (!sameState(capture.before, after)) {
|
|
141
152
|
changed = true;
|
|
142
|
-
addSnapshot(snapshotFromState(capture.path, capture.before, capture.sequence, op, capture.createdParents));
|
|
153
|
+
addSnapshot(snapshotFromState(capture.path, capture.before, capture.sequence, op, capture.createdParents, after));
|
|
143
154
|
}
|
|
144
155
|
// write_file 会递归创建父目录;即使最终写文件失败,这些目录也是本轮真实副作用。
|
|
145
156
|
for (const parentRel of capture.createdParents) {
|
|
146
157
|
const parent = safeFullPath(parentRel);
|
|
147
158
|
if (parent && readState(parent).kind !== 'missing') {
|
|
148
159
|
changed = true;
|
|
149
|
-
addSnapshot(snapshotFromState(parentRel, { kind: 'missing' }, capture.sequence, op));
|
|
160
|
+
addSnapshot(snapshotFromState(parentRel, { kind: 'missing' }, capture.sequence, op, [], readState(parent)));
|
|
150
161
|
}
|
|
151
162
|
}
|
|
152
163
|
if (changed)
|
|
@@ -202,7 +213,7 @@ export function endWorkspaceMutation(capture, op) {
|
|
|
202
213
|
if (sameState(beforeState, afterState))
|
|
203
214
|
continue;
|
|
204
215
|
changed = true;
|
|
205
|
-
addSnapshot(snapshotFromState(rel, beforeState, capture.sequence, op));
|
|
216
|
+
addSnapshot(snapshotFromState(rel, beforeState, capture.sequence, op, [], afterState));
|
|
206
217
|
}
|
|
207
218
|
if (changed)
|
|
208
219
|
mutationVersion += 1;
|
|
@@ -285,7 +296,11 @@ function restoreSnapshot(snapshot) {
|
|
|
285
296
|
const state = stateFromSnapshot(snapshot);
|
|
286
297
|
try {
|
|
287
298
|
if (state.kind === 'missing') {
|
|
288
|
-
|
|
299
|
+
const current = readState(full);
|
|
300
|
+
if (current.kind === 'directory')
|
|
301
|
+
rmdirSync(full);
|
|
302
|
+
else
|
|
303
|
+
rmSync(full, { recursive: false, force: true });
|
|
289
304
|
for (const parentRel of snapshot.createdParents ?? []) {
|
|
290
305
|
const parent = safeFullPath(parentRel);
|
|
291
306
|
if (!parent)
|
|
@@ -329,9 +344,16 @@ export function applyRollback(plan, history, revertPaths) {
|
|
|
329
344
|
const deletedMsgs = history.length - plan.cutoffIndex;
|
|
330
345
|
history.length = plan.cutoffIndex;
|
|
331
346
|
const picks = new Map();
|
|
347
|
+
const latest = new Map();
|
|
332
348
|
for (const snapshot of snapshots) {
|
|
333
349
|
if (snapshot.turnId <= plan.cutoffTurnId || !revertPaths.has(snapshot.path))
|
|
334
350
|
continue;
|
|
351
|
+
const latestSnapshot = latest.get(snapshot.path);
|
|
352
|
+
if (!latestSnapshot || snapshot.turnId > latestSnapshot.turnId ||
|
|
353
|
+
(snapshot.turnId === latestSnapshot.turnId &&
|
|
354
|
+
(snapshot.sequence ?? -1) > (latestSnapshot.sequence ?? -1))) {
|
|
355
|
+
latest.set(snapshot.path, snapshot);
|
|
356
|
+
}
|
|
335
357
|
const existing = picks.get(snapshot.path);
|
|
336
358
|
if (!existing ||
|
|
337
359
|
snapshot.turnId < existing.turnId ||
|
|
@@ -341,7 +363,18 @@ export function applyRollback(plan, history, revertPaths) {
|
|
|
341
363
|
picks.set(snapshot.path, snapshot);
|
|
342
364
|
}
|
|
343
365
|
}
|
|
344
|
-
const selected = [
|
|
366
|
+
const selected = [];
|
|
367
|
+
const conflictedFiles = [];
|
|
368
|
+
for (const snapshot of picks.values()) {
|
|
369
|
+
const expected = latest.get(snapshot.path)?.afterFingerprint;
|
|
370
|
+
const full = safeFullPath(snapshot.path);
|
|
371
|
+
if (expected && (!full || stateFingerprint(readState(full)) !== expected)) {
|
|
372
|
+
conflictedFiles.push(snapshot.path);
|
|
373
|
+
}
|
|
374
|
+
else {
|
|
375
|
+
selected.push(snapshot);
|
|
376
|
+
}
|
|
377
|
+
}
|
|
345
378
|
// 先深到浅删除本轮新建项,再浅到深恢复原目录/文件。
|
|
346
379
|
const removals = selected
|
|
347
380
|
.filter((item) => stateFromSnapshot(item).kind === 'missing')
|
|
@@ -353,11 +386,13 @@ export function applyRollback(plan, history, revertPaths) {
|
|
|
353
386
|
for (const snapshot of [...removals, ...restores]) {
|
|
354
387
|
if (restoreSnapshot(snapshot))
|
|
355
388
|
revertedFiles.push(snapshot.path);
|
|
389
|
+
else if (!conflictedFiles.includes(snapshot.path))
|
|
390
|
+
conflictedFiles.push(snapshot.path);
|
|
356
391
|
}
|
|
357
392
|
turns = turns.filter((turn) => turn.turnId <= plan.cutoffTurnId);
|
|
358
393
|
snapshots = snapshots.filter((snapshot) => snapshot.turnId <= plan.cutoffTurnId);
|
|
359
394
|
currentTurnId = turns.at(-1)?.turnId ?? 0;
|
|
360
|
-
return { deletedMsgs, revertedFiles };
|
|
395
|
+
return { deletedMsgs, revertedFiles, conflictedFiles };
|
|
361
396
|
}
|
|
362
397
|
export function pruneAfterCompaction(history) {
|
|
363
398
|
const count = history.filter((message) => message.role === 'user').length;
|
package/dist/sandbox/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// 沙箱子系统 barrel。叶子:仅 node:path / node:fs,不反向依赖业务。
|
|
2
|
-
export { getSandboxRoot, setSandboxRoot } from './root.js';
|
|
2
|
+
export { getSandboxRoot, setSandboxRoot, withSandboxRoot } from './root.js';
|
|
3
3
|
export { jailResolve, jailGlobPattern, isInsideRoot } from './jail.js';
|
|
4
4
|
export { filterEnv, isCommandDenied } from './command.js';
|
|
5
5
|
export { SANDBOX_EXEMPT_TOOLS, SANDBOX_PATH_TOOLS, enforceSandbox, } from './policy.js';
|
package/dist/sandbox/policy.js
CHANGED
|
@@ -8,7 +8,7 @@ import { jailResolve, jailGlobPattern } from './jail.js';
|
|
|
8
8
|
* - web_*:跨网络,非文件路径
|
|
9
9
|
* - ask_human / switch_mode:无文件路径
|
|
10
10
|
* - codegraph:只读 cwd 下 .codegraph/ 索引(只读、不写盘)
|
|
11
|
-
* -
|
|
11
|
+
* - sub-agent:派生子 agent,通过 scoped sandbox root 使用隔离 overlay
|
|
12
12
|
*/
|
|
13
13
|
export const SANDBOX_EXEMPT_TOOLS = new Set([
|
|
14
14
|
'memory_save', 'memory_update', 'memory_forget', 'memory_search', 'memory_list',
|
|
@@ -16,7 +16,7 @@ export const SANDBOX_EXEMPT_TOOLS = new Set([
|
|
|
16
16
|
'web_search', 'web_fetch',
|
|
17
17
|
'ask_human', 'switch_mode',
|
|
18
18
|
'codegraph',
|
|
19
|
-
'
|
|
19
|
+
'sub-agent',
|
|
20
20
|
]);
|
|
21
21
|
/**
|
|
22
22
|
* 路径类工具:enforceSandbox 集中把 args.path 重写为牢内绝对路径(默认安全;工具内
|
package/dist/sandbox/root.js
CHANGED
|
@@ -3,12 +3,18 @@
|
|
|
3
3
|
// —— 全是「业务 → 叶子」,同 src/agent/mode.ts。
|
|
4
4
|
//
|
|
5
5
|
// 设计:sandboxRoot 是纯边界记录(默认 = process.cwd(),不 chdir),避免与 config.sessionDir 等
|
|
6
|
-
// 模块加载期计算的值产生错位(若 chdir 会令那些值变陈旧)
|
|
7
|
-
//
|
|
6
|
+
// 模块加载期计算的值产生错位(若 chdir 会令那些值变陈旧)。主 Agent 使用全局 root,
|
|
7
|
+
// 并发子 Agent 通过 AsyncLocalStorage 获得互不干扰的 overlay root。
|
|
8
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
8
9
|
let currentRoot = null;
|
|
10
|
+
const scopedRoots = new AsyncLocalStorage();
|
|
9
11
|
/** 当前沙箱根(绝对路径)。未初始化返 null,调用方 ?? process.cwd() 兜底(防御)。 */
|
|
10
12
|
export function getSandboxRoot() {
|
|
11
|
-
return currentRoot;
|
|
13
|
+
return scopedRoots.getStore() ?? currentRoot;
|
|
14
|
+
}
|
|
15
|
+
/** Run one asynchronous agent against an isolated sandbox without changing sibling roots. */
|
|
16
|
+
export function withSandboxRoot(root, run) {
|
|
17
|
+
return scopedRoots.run(root, run);
|
|
12
18
|
}
|
|
13
19
|
/** 设置沙箱根。repl startRepl 启动时调一次(默认 process.cwd();--sandbox-root / SANDBOX_ROOT 可覆盖)。
|
|
14
20
|
* 返回之前的值,供未来 save/restore(子 agent worktree 隔离)用。 */
|