mocode-ai 1.4.2 → 1.4.3

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.
Files changed (47) hide show
  1. package/README.md +13 -1
  2. package/dist/agent/core.js +14 -936
  3. package/dist/agent/index.js +37 -13
  4. package/dist/agent/model-turn.js +218 -0
  5. package/dist/agent/pipeline.js +18 -0
  6. package/dist/agent/run-contracts.js +1 -0
  7. package/dist/agent/run-coordinator.js +758 -0
  8. package/dist/agent/runtime-context.js +118 -24
  9. package/dist/agent/spawn.js +11 -7
  10. package/dist/agent/stages/context-trimmer.js +63 -0
  11. package/dist/agent/stages/contracts.js +12 -0
  12. package/dist/agent/stages/history-manager.js +178 -0
  13. package/dist/agent/stages/legacy-adapters.js +19 -0
  14. package/dist/agent/stages/model-runner.js +29 -0
  15. package/dist/agent/stages/run-policy.js +73 -0
  16. package/dist/agent/stages/tool-dispatcher.js +341 -0
  17. package/dist/agent/tool-helpers.js +12 -12
  18. package/dist/agent/tool-turn.js +87 -0
  19. package/dist/agent/trace-state.js +97 -101
  20. package/dist/agent/turn-lifecycle.js +110 -0
  21. package/dist/config/index.js +14 -0
  22. package/dist/host/stdio.js +101 -40
  23. package/dist/llm/index.js +51 -35
  24. package/dist/llm/providers/anthropic.js +16 -10
  25. package/dist/llm/runtime.js +1 -0
  26. package/dist/permissions/index.js +21 -5
  27. package/dist/repl/commands/compact.js +2 -2
  28. package/dist/repl/commands/session.js +3 -12
  29. package/dist/repl/message-format.js +5 -0
  30. package/dist/repl/runtime.js +95 -55
  31. package/dist/rollback/index.js +29 -624
  32. package/dist/rollback/store.js +593 -0
  33. package/dist/runtime/index.js +1 -0
  34. package/dist/runtime/runtime.js +307 -0
  35. package/dist/session/compact.js +22 -14
  36. package/dist/session/index.js +1 -0
  37. package/dist/session/persist.js +10 -146
  38. package/dist/session/scheduler.js +28 -16
  39. package/dist/session/state.js +16 -12
  40. package/dist/session/store.js +218 -0
  41. package/dist/session/trace.js +5 -15
  42. package/dist/tools/policy.js +19 -15
  43. package/dist/tools/registry.js +21 -229
  44. package/dist/tools/router.js +5 -3
  45. package/dist/tools/tool-runtime.js +267 -0
  46. package/dist/ui/layout-internal/content-write.js +4 -0
  47. package/package.json +7 -3
@@ -2,8 +2,7 @@
2
2
  // rewritten only after real occupancy reaches the shared pressure threshold.
3
3
  import { evaluateBudget, scheduleActions, formatReport, DEFAULT_BUDGET_POLICY, } from '../context/budget.js';
4
4
  import { chatTools } from '../llm/index.js';
5
- import { config } from '../config/index.js';
6
- import { maybeCompact, contextState } from './compact.js';
5
+ import { defaultCompactionRuntime, maybeCompact, contextState, } from './compact.js';
7
6
  import { pruneStaleArtifacts, refreshArtifactFreshness } from '../context/artifacts.js';
8
7
  import { pruneSuperseded } from '../context/relevance.js';
9
8
  import { createAgeAwareEncodingState } from '../context/age-aware.js';
@@ -21,8 +20,8 @@ function emptyPressure(report) {
21
20
  };
22
21
  }
23
22
  /** One scheduler instance is owned by one agent run. */
24
- export function createBudgetScheduler(state = contextState) {
25
- const evaluate = (history, step, activeTools, ephemeralTokens) => evaluateBudget(history, config.contextWindowTokens, step, state.correction, activeTools, ephemeralTokens);
23
+ export function createBudgetScheduler(state = contextState, runtime = defaultCompactionRuntime) {
24
+ const evaluate = (history, step, activeTools, ephemeralTokens) => evaluateBudget(history, runtime.config.contextWindowTokens, step, state.correction, activeTools, ephemeralTokens);
26
25
  const scheduler = {
27
26
  lastRunLog: null,
28
27
  async runStep(history, step, activeTools = chatTools, ephemeralTokens = 0, signal) {
@@ -35,11 +34,11 @@ export function createBudgetScheduler(state = contextState) {
35
34
  // A single 80% pressure event owns every history rewrite. Run all enabled
36
35
  // low-cost cleanup first, then always compact; do not introduce per-stage
37
36
  // thresholds or stop early when one stage happens to cross below 80%.
38
- if (config.contextRelprune) {
37
+ if (runtime.config.contextRelprune) {
39
38
  pressure.superseded = pruneSuperseded(history, report.hotBoundary);
40
39
  }
41
40
  pressure.staleArtifacts = pruneStaleArtifacts(state, history, report.hotBoundary);
42
- if (config.contextOptimize) {
41
+ if (runtime.config.contextOptimize) {
43
42
  const ageAware = createAgeAwareEncodingState(history);
44
43
  pressure.encodedLogsAndSearches = ageAware.sweepPressure(history, report.hotBoundary);
45
44
  }
@@ -50,10 +49,12 @@ export function createBudgetScheduler(state = contextState) {
50
49
  const actions = scheduleActions(report);
51
50
  let compactHistoryCalled = false;
52
51
  let historyRebuilt = false;
52
+ let contentMutated = pressure.superseded > 0 || pressure.staleArtifacts > 0 || pressure.encodedLogsAndSearches > 0;
53
53
  for (const _action of actions) {
54
- const result = await maybeCompact(history, report, undefined, state, activeTools, signal);
54
+ const result = await maybeCompact(history, report, undefined, state, activeTools, signal, runtime);
55
55
  compactHistoryCalled = true;
56
56
  historyRebuilt ||= result?.historyRebuilt === true;
57
+ contentMutated ||= result?.compacted === true;
57
58
  }
58
59
  const log = {
59
60
  step,
@@ -61,6 +62,7 @@ export function createBudgetScheduler(state = contextState) {
61
62
  actions,
62
63
  pressure,
63
64
  compactHistoryCalled,
65
+ historyMutation: historyRebuilt ? 'rebuild' : contentMutated ? 'content' : 'none',
64
66
  ts: Date.now(),
65
67
  };
66
68
  scheduler.lastRunLog = log;
@@ -70,28 +72,35 @@ export function createBudgetScheduler(state = contextState) {
70
72
  };
71
73
  return scheduler;
72
74
  }
73
- export async function runScheduler(history, step, state = contextState, activeTools = chatTools) {
74
- return createBudgetScheduler(state).runStep(history, step, activeTools);
75
+ export async function runScheduler(history, step, state = contextState, activeTools = chatTools, runtime = defaultCompactionRuntime) {
76
+ return createBudgetScheduler(state, runtime).runStep(history, step, activeTools);
75
77
  }
76
78
  /** User-requested compaction bypasses automatic pressure gating. */
77
79
  export async function manualCompact(history, focus, opts) {
78
80
  const signal = opts?.signal;
79
- if (config.contextBudget === false) {
81
+ const runtime = opts?.runtime ?? defaultCompactionRuntime;
82
+ const state = opts?.contextState ?? contextState;
83
+ const activeTools = opts?.activeTools ?? chatTools;
84
+ if (runtime.config.contextBudget === false) {
80
85
  const result = await import('./compact.js').then(({ compactHistory }) => compactHistory(history, {
81
- window: config.contextWindowTokens,
86
+ window: runtime.config.contextWindowTokens,
82
87
  threshold: DEFAULT_BUDGET_POLICY.pressureTriggerRatio,
83
88
  focus,
84
89
  manual: true,
85
90
  force: opts?.force,
91
+ tools: activeTools,
92
+ contextState: state,
93
+ runtime,
86
94
  signal,
87
95
  }));
88
- const report = evaluateBudget(history, config.contextWindowTokens, -1, contextState.correction);
96
+ const report = evaluateBudget(history, runtime.config.contextWindowTokens, -1, state.correction, activeTools);
89
97
  const log = {
90
98
  step: -1,
91
99
  report,
92
100
  actions: [{ kind: 'compact_history', focus }],
93
101
  pressure: emptyPressure(report),
94
102
  compactHistoryCalled: true,
103
+ historyMutation: result.historyRebuilt ? 'rebuild' : result.compacted ? 'content' : 'none',
95
104
  ts: Date.now(),
96
105
  compactDetail: {
97
106
  reason: result.reason,
@@ -102,10 +111,10 @@ export async function manualCompact(history, focus, opts) {
102
111
  focus,
103
112
  },
104
113
  };
105
- contextState.schedulerLog = log;
114
+ state.schedulerLog = log;
106
115
  return log;
107
116
  }
108
- const report = evaluateBudget(history, config.contextWindowTokens, -1, contextState.correction);
117
+ const report = evaluateBudget(history, runtime.config.contextWindowTokens, -1, state.correction, activeTools);
109
118
  let actions = scheduleActions(report);
110
119
  if (!actions.some((action) => action.kind === 'compact_history')) {
111
120
  actions = [...actions, { kind: 'compact_history', focus }];
@@ -114,6 +123,7 @@ export async function manualCompact(history, focus, opts) {
114
123
  actions = actions.map((action) => (action.kind === 'compact_history' ? { ...action, focus } : action));
115
124
  }
116
125
  let compactHistoryCalled = false;
126
+ let historyMutation = 'none';
117
127
  let compactDetail;
118
128
  for (const action of actions) {
119
129
  if (action.kind !== 'compact_history')
@@ -122,9 +132,10 @@ export async function manualCompact(history, focus, opts) {
122
132
  manual: true,
123
133
  force: opts?.force,
124
134
  focus: action.focus,
125
- }, contextState, chatTools, signal);
135
+ }, state, activeTools, signal, runtime);
126
136
  compactHistoryCalled = true;
127
137
  if (result) {
138
+ historyMutation = result.historyRebuilt ? 'rebuild' : result.compacted ? 'content' : historyMutation;
128
139
  compactDetail = {
129
140
  reason: result.reason,
130
141
  estimateBefore: result.estimateBefore,
@@ -141,10 +152,11 @@ export async function manualCompact(history, focus, opts) {
141
152
  actions,
142
153
  pressure: emptyPressure(report),
143
154
  compactHistoryCalled,
155
+ historyMutation,
144
156
  ts: Date.now(),
145
157
  compactDetail,
146
158
  };
147
- contextState.schedulerLog = log;
159
+ state.schedulerLog = log;
148
160
  return log;
149
161
  }
150
162
  export { evaluateBudget, scheduleActions, formatReport };
@@ -1,17 +1,21 @@
1
- // session/state.ts - 会话状态跟踪模块
2
- // 提供当前活跃会话 ID 的全局访问点,供 config/buildNotepadSection 等读取会话级 notes.md。
3
- // 避免 repl/index.ts ↔ config/index.ts 循环依赖。
4
- let currentSessionId;
5
- /** 获取当前活跃会话 ID(供 buildNotepadSection 等使用)。 */
1
+ import { AsyncLocalStorage } from 'node:async_hooks';
2
+ // Default-runtime session identity stays free of config/store imports to avoid initialization cycles.
3
+ let defaultCurrentSessionId;
4
+ const activeSessionIdProviders = new AsyncLocalStorage();
5
+ /** 获取当前异步 runtime 的会话 ID;无 scope 时读取默认进程 runtime。 */
6
6
  export function getCurrentSessionId() {
7
- return currentSessionId;
7
+ return activeSessionIdProviders.getStore()?.() ?? defaultCurrentSessionId;
8
8
  }
9
- /**
10
- * 设置当前活跃会话 ID。notes.md 由 agent 按需创建;这里不能预建空文件,
11
- * 否则 write_file(expected_hash=null) 的首次创建会必然冲突。
12
- * 由 repl/index.ts 在会话启动 / /resume 切换时调用。
13
- */
9
+ /** SessionStore 默认兼容实例专用:绕过异步 scope,避免 provider 自递归。 */
10
+ export function getDefaultCurrentSessionId() {
11
+ return defaultCurrentSessionId;
12
+ }
13
+ /** 设置默认进程 runtime 的当前活跃会话 ID。 */
14
14
  export function setCurrentSessionId(id, cwd) {
15
- currentSessionId = id;
15
+ defaultCurrentSessionId = id;
16
16
  void cwd;
17
17
  }
18
+ /** 让 notes/config 等旧读取入口在异步 runtime 树内看到实例会话身份。 */
19
+ export function withCurrentSessionIdProvider(provider, run) {
20
+ return activeSessionIdProviders.run(provider, run);
21
+ }
@@ -0,0 +1,218 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { AsyncLocalStorage } from 'node:async_hooks';
3
+ import { existsSync, mkdirSync, readFileSync, readdirSync, unlinkSync, writeFileSync } from 'node:fs';
4
+ import path from 'node:path';
5
+ import { config, getActiveModel } from '../config/index.js';
6
+ import { isToolRouteGroupName } from '../config/profiles.js';
7
+ import { truncateDisplay } from '../ui/render.js';
8
+ import { getDefaultCurrentSessionId, setCurrentSessionId as setDefaultCurrentSessionId, withCurrentSessionIdProvider, } from './state.js';
9
+ /** 新会话 id: 时间前缀保持可排序,毫秒与随机段避免同进程/跨进程碰撞。 */
10
+ function createTimestampId() {
11
+ const d = new Date();
12
+ const p = (n, width = 2) => String(n).padStart(width, '0');
13
+ return `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}-${p(d.getMilliseconds(), 3)}-${randomUUID().slice(0, 8)}`;
14
+ }
15
+ /** 时间前缀 → ISO 字符串;兼容旧秒级 ID,解析失败回退原 id。 */
16
+ function idToIso(id) {
17
+ const m = /^(\d{4})(\d{2})(\d{2})-(\d{2})(\d{2})(\d{2})(?:-(\d{3})-[a-f0-9]{8})?$/.exec(id);
18
+ if (!m)
19
+ return id;
20
+ return `${m[1]}-${m[2]}-${m[3]}T${m[4]}:${m[5]}:${m[6]}${m[7] ? `.${m[7]}` : ''}`;
21
+ }
22
+ function toText(content) {
23
+ if (content == null)
24
+ return '';
25
+ if (typeof content === 'string')
26
+ return content;
27
+ try {
28
+ return JSON.stringify(content);
29
+ }
30
+ catch {
31
+ return String(content);
32
+ }
33
+ }
34
+ function firstUserOf(history) {
35
+ for (const message of history) {
36
+ if (message.role !== 'user')
37
+ continue;
38
+ const text = toText(message.content)
39
+ .replace(/\n/g, ' ')
40
+ .trim();
41
+ return truncateDisplay(text, 40);
42
+ }
43
+ return '';
44
+ }
45
+ /**
46
+ * Runtime-local session persistence and identity.
47
+ *
48
+ * Disk format remains compatible with the historical helpers: the current format is
49
+ * `<sessionsRoot>/<id>/session.json`, with `<sessionsRoot>/<id>.json` as a read fallback.
50
+ */
51
+ export class SessionStore {
52
+ workspaceRoot;
53
+ sessionsRootProvider;
54
+ getModel;
55
+ currentSessionIdProvider;
56
+ currentSessionIdSetter;
57
+ currentSessionId;
58
+ constructor(options = {}) {
59
+ this.workspaceRoot = path.resolve(options.workspaceRoot ?? process.cwd());
60
+ if (typeof options.sessionsRoot === 'function') {
61
+ const provider = options.sessionsRoot;
62
+ this.sessionsRootProvider = () => path.resolve(provider());
63
+ }
64
+ else {
65
+ const fixedRoot = path.resolve(options.sessionsRoot ?? config.sessionDir);
66
+ this.sessionsRootProvider = () => fixedRoot;
67
+ }
68
+ this.getModel = options.getModel ?? getActiveModel;
69
+ this.currentSessionIdProvider = options.getCurrentSessionId;
70
+ this.currentSessionIdSetter = options.setCurrentSessionId;
71
+ }
72
+ get sessionsRoot() {
73
+ return this.sessionsRootProvider();
74
+ }
75
+ sessionDir() {
76
+ const root = this.sessionsRoot;
77
+ mkdirSync(root, { recursive: true });
78
+ return root;
79
+ }
80
+ createId() {
81
+ return createTimestampId();
82
+ }
83
+ getCurrentSessionId() {
84
+ return this.currentSessionIdProvider?.() ?? this.currentSessionId;
85
+ }
86
+ setCurrentSessionId(id) {
87
+ if (this.currentSessionIdSetter)
88
+ this.currentSessionIdSetter(id);
89
+ else
90
+ this.currentSessionId = id;
91
+ }
92
+ sessionPath(id) {
93
+ return path.join(this.sessionsRoot, id, 'session.json');
94
+ }
95
+ artifactPath(id, filename) {
96
+ return path.join(this.sessionsRoot, id, filename);
97
+ }
98
+ appendTrace(id, value) {
99
+ try {
100
+ const dir = path.join(this.sessionsRoot, id);
101
+ mkdirSync(dir, { recursive: true });
102
+ const line = `${JSON.stringify(value)}\n`;
103
+ const tracePath = path.join(dir, 'trace.jsonl');
104
+ writeFileSync(tracePath, line, { encoding: 'utf8', flag: 'a' });
105
+ }
106
+ catch {
107
+ // Observability is best-effort and cannot block coding work.
108
+ }
109
+ }
110
+ save(history, id, queryHistory = [], lastToolGroups = []) {
111
+ const meta = {
112
+ id,
113
+ createdAt: idToIso(id),
114
+ model: this.getModel(),
115
+ firstUser: history.length > 1
116
+ ? firstUserOf(history)
117
+ : truncateDisplay((queryHistory[0] ?? '').replace(/\n/g, ' ').trim(), 40),
118
+ };
119
+ const currentPath = this.sessionPath(id);
120
+ const legacyPath = path.join(this.sessionsRoot, `${id}.json`);
121
+ if (history.length <= 1 && queryHistory.length === 0 && !existsSync(currentPath) && !existsSync(legacyPath)) {
122
+ return meta;
123
+ }
124
+ mkdirSync(path.join(this.sessionsRoot, id), { recursive: true });
125
+ const record = {
126
+ ...meta,
127
+ history,
128
+ queryHistory: [...queryHistory],
129
+ lastToolGroups: [...lastToolGroups],
130
+ };
131
+ writeFileSync(currentPath, JSON.stringify(record), 'utf8');
132
+ if (existsSync(legacyPath))
133
+ unlinkSync(legacyPath);
134
+ return meta;
135
+ }
136
+ load(id) {
137
+ const currentPath = this.sessionPath(id);
138
+ const legacyPath = path.join(this.sessionsRoot, `${id}.json`);
139
+ const source = existsSync(currentPath) ? currentPath : legacyPath;
140
+ if (!existsSync(source))
141
+ return null;
142
+ try {
143
+ const rec = JSON.parse(readFileSync(source, 'utf8'));
144
+ if (!rec || !Array.isArray(rec.history))
145
+ return null;
146
+ return {
147
+ id: rec.id,
148
+ createdAt: rec.createdAt ?? idToIso(rec.id ?? id),
149
+ model: rec.model ?? '',
150
+ firstUser: rec.firstUser ?? '',
151
+ history: rec.history,
152
+ queryHistory: Array.isArray(rec.queryHistory)
153
+ ? rec.queryHistory.filter((query) => typeof query === 'string')
154
+ : undefined,
155
+ lastToolGroups: Array.isArray(rec.lastToolGroups) ? rec.lastToolGroups.filter(isToolRouteGroupName) : undefined,
156
+ };
157
+ }
158
+ catch {
159
+ return null;
160
+ }
161
+ }
162
+ list(limit) {
163
+ const root = this.sessionsRoot;
164
+ if (!existsSync(root))
165
+ return [];
166
+ const entries = readdirSync(root, { withFileTypes: true });
167
+ const ids = [];
168
+ for (const entry of entries) {
169
+ if (entry.isDirectory() && /^\d{8}-\d{6}(?:-\d{3}-[a-f0-9]{8})?$/.test(entry.name)) {
170
+ ids.push(entry.name);
171
+ }
172
+ else if (entry.isFile() && entry.name.endsWith('.json') && !entry.name.endsWith('.snapshots.json')) {
173
+ ids.push(entry.name.replace(/\.json$/, ''));
174
+ }
175
+ }
176
+ const maxResults = typeof limit === 'number' ? Math.max(0, limit) : Infinity;
177
+ const out = [];
178
+ for (const id of ids.sort().reverse()) {
179
+ if (out.length >= maxResults)
180
+ break;
181
+ const currentPath = this.sessionPath(id);
182
+ const legacyPath = path.join(root, `${id}.json`);
183
+ const source = existsSync(currentPath) ? currentPath : legacyPath;
184
+ try {
185
+ const rec = JSON.parse(readFileSync(source, 'utf8'));
186
+ if (!rec || typeof rec.id !== 'string')
187
+ continue;
188
+ out.push({
189
+ id: rec.id,
190
+ createdAt: rec.createdAt ?? idToIso(rec.id),
191
+ model: rec.model ?? '',
192
+ firstUser: rec.firstUser ?? '',
193
+ });
194
+ }
195
+ catch {
196
+ // 跳过损坏文件。
197
+ }
198
+ }
199
+ return out;
200
+ }
201
+ }
202
+ /** 旧函数 API 的进程级兼容实例;session root 每次读取 config,保留测试和运行时切换语义。 */
203
+ export const defaultSessionStore = new SessionStore({
204
+ sessionsRoot: () => config.sessionDir,
205
+ workspaceRoot: process.cwd(),
206
+ getModel: getActiveModel,
207
+ getCurrentSessionId: getDefaultCurrentSessionId,
208
+ setCurrentSessionId: (id) => setDefaultCurrentSessionId(id, process.cwd()),
209
+ });
210
+ const activeSessionStores = new AsyncLocalStorage();
211
+ /** 当前异步 runtime 树使用的 session store;无 scope 时回退默认兼容实例。 */
212
+ export function getActiveSessionStore() {
213
+ return activeSessionStores.getStore() ?? defaultSessionStore;
214
+ }
215
+ /** 让旧 session/trace 入口在异步 runtime 树内自动使用对应实例。 */
216
+ export function withSessionStore(store, run) {
217
+ return activeSessionStores.run(store, () => withCurrentSessionIdProvider(() => store.getCurrentSessionId(), run));
218
+ }
@@ -1,9 +1,6 @@
1
1
  import { randomUUID } from 'node:crypto';
2
- import { appendFileSync, mkdirSync } from 'node:fs';
3
- import path from 'node:path';
4
- import { config } from '../config/index.js';
5
2
  import { getCurrentTurnId } from '../rollback/index.js';
6
- import { getCurrentSessionId } from './state.js';
3
+ import { getActiveSessionStore } from './store.js';
7
4
  export function createTraceEvent(input) {
8
5
  return {
9
6
  schemaVersion: 1,
@@ -13,32 +10,25 @@ export function createTraceEvent(input) {
13
10
  };
14
11
  }
15
12
  function appendTraceLine(sessionId, value) {
16
- try {
17
- const dir = path.join(config.sessionDir, sessionId);
18
- mkdirSync(dir, { recursive: true });
19
- appendFileSync(path.join(dir, 'trace.jsonl'), `${JSON.stringify(value)}\n`, 'utf8');
20
- }
21
- catch {
22
- // Observability is best-effort and cannot block coding work.
23
- }
13
+ getActiveSessionStore().appendTrace(sessionId, value);
24
14
  }
25
15
  /** Persists a typed event in the current session's append-only black-box log. */
26
16
  export function appendCurrentSessionTraceEvent(event) {
27
- const sessionId = getCurrentSessionId();
17
+ const sessionId = getActiveSessionStore().getCurrentSessionId();
28
18
  if (!sessionId)
29
19
  return;
30
20
  appendTraceLine(sessionId, { ...event, sessionId });
31
21
  }
32
22
  /** Records events initiated outside runAgentCore, such as Ctrl+C, /compact, and /rollback. */
33
23
  export function appendCurrentSessionRuntimeEvent(type, data, turnId = getCurrentTurnId()) {
34
- const sessionId = getCurrentSessionId();
24
+ const sessionId = getActiveSessionStore().getCurrentSessionId();
35
25
  if (!sessionId)
36
26
  return;
37
27
  appendTraceLine(sessionId, createTraceEvent({ sessionId, turnId, type, data }));
38
28
  }
39
29
  /** Legacy turn-summary sink retained for API compatibility. New production code writes events. */
40
30
  export function appendCurrentSessionTrace(trace) {
41
- const sessionId = getCurrentSessionId();
31
+ const sessionId = getActiveSessionStore().getCurrentSessionId();
42
32
  if (!sessionId)
43
33
  return;
44
34
  appendTraceLine(sessionId, { ...trace, sessionId });
@@ -5,16 +5,16 @@ const clampConfidence = (value) => (Number.isFinite(value) ? Math.max(0, Math.mi
5
5
  function envGateAllows(name) {
6
6
  return !name || process.env[name] !== 'false';
7
7
  }
8
- function registeredNames() {
9
- return tools.map((tool) => tool.name);
8
+ function registeredNames(catalog = tools) {
9
+ return catalog.map((tool) => tool.name);
10
10
  }
11
- /** 当前进程真正可路由的簇;旧 env=false 只作为硬 veto,不再强制把簇常驻 schema。 */
12
- export function getAvailableToolRouteGroups() {
13
- const names = registeredNames();
11
+ /** 当前 runtime 真正可路由的簇;旧 env=false 只作为硬 veto,不再强制把簇常驻 schema。 */
12
+ export function getAvailableToolRouteGroups(catalog = tools, gateAllows = envGateAllows) {
13
+ const names = registeredNames(catalog);
14
14
  const registered = new Set(names);
15
15
  return TOOL_ROUTE_GROUP_NAMES.filter((group) => {
16
16
  const definition = TOOL_ROUTE_GROUPS[group];
17
- if (!envGateAllows(definition.gateEnv))
17
+ if (!gateAllows(definition.gateEnv))
18
18
  return false;
19
19
  const groupNames = getToolRouteGroupNames(group, names);
20
20
  if (group === 'mcp')
@@ -22,8 +22,8 @@ export function getAvailableToolRouteGroups() {
22
22
  return groupNames.length > 0 && groupNames.every((name) => registered.has(name));
23
23
  });
24
24
  }
25
- export function toolRouteCatalog(groups = getAvailableToolRouteGroups()) {
26
- const names = registeredNames();
25
+ export function toolRouteCatalog(groups = getAvailableToolRouteGroups(), catalog = tools) {
26
+ const names = registeredNames(catalog);
27
27
  return groups
28
28
  .map((group) => {
29
29
  const members = getToolRouteGroupNames(group, names);
@@ -31,8 +31,8 @@ export function toolRouteCatalog(groups = getAvailableToolRouteGroups()) {
31
31
  })
32
32
  .join('\n');
33
33
  }
34
- export function getToolChatSchema(name) {
35
- const tool = tools.find((candidate) => candidate.name === name);
34
+ export function getToolChatSchema(name, catalog = tools) {
35
+ const tool = catalog.find((candidate) => candidate.name === name);
36
36
  if (!tool)
37
37
  return null;
38
38
  return {
@@ -87,12 +87,16 @@ export class ToolPolicyController {
87
87
  confidence;
88
88
  autoCache = null;
89
89
  planCache = null;
90
+ catalog;
91
+ gateAllows;
90
92
  constructor(init = {}) {
93
+ this.catalog = init.tools ?? tools;
94
+ this.gateAllows = init.gateAllows ?? envGateAllows;
91
95
  this.id = init.id ?? `route-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
92
96
  this.maxExpansions = Math.max(0, init.maxExpansions ?? 3);
93
97
  this.reason = init.reason?.trim() || 'LLM router selected common tools only.';
94
98
  this.confidence = clampConfidence(init.confidence ?? 0);
95
- const available = new Set(getAvailableToolRouteGroups());
99
+ const available = new Set(getAvailableToolRouteGroups(this.catalog, this.gateAllows));
96
100
  const requested = process.env.MOCODE_TOOL_POLICY === 'full' ? available : new Set(init.groups ?? []);
97
101
  for (const group of requested) {
98
102
  if (available.has(group))
@@ -106,7 +110,7 @@ export class ToolPolicyController {
106
110
  return this.expansionCount < this.maxExpansions && this.remainingGroups().length > 0;
107
111
  }
108
112
  remainingGroups() {
109
- const available = new Set(getAvailableToolRouteGroups());
113
+ const available = new Set(getAvailableToolRouteGroups(this.catalog, this.gateAllows));
110
114
  return TOOL_ROUTE_GROUP_NAMES.filter((group) => available.has(group) && !this.selected.has(group));
111
115
  }
112
116
  snapshot(planMode = false) {
@@ -126,7 +130,7 @@ export class ToolPolicyController {
126
130
  const remaining = this.canExpand ? this.remainingGroups() : [];
127
131
  if (remaining.length > 0)
128
132
  append(ADD_TOOL_GROUPS_TOOL_NAME);
129
- const registered = registeredNames();
133
+ const registered = registeredNames(this.catalog);
130
134
  for (const group of TOOL_ROUTE_GROUP_NAMES) {
131
135
  if (!this.selected.has(group))
132
136
  continue;
@@ -139,7 +143,7 @@ export class ToolPolicyController {
139
143
  const chatTools = visibleNames.flatMap((name) => {
140
144
  if (name === ADD_TOOL_GROUPS_TOOL_NAME)
141
145
  return [addToolGroupsSchema(remaining)];
142
- const schema = getToolChatSchema(name);
146
+ const schema = getToolChatSchema(name, this.catalog);
143
147
  return schema ? [schema] : [];
144
148
  });
145
149
  const snapshot = {
@@ -168,7 +172,7 @@ export class ToolPolicyController {
168
172
  snapshot: this.snapshot(false),
169
173
  };
170
174
  }
171
- const available = new Set(getAvailableToolRouteGroups());
175
+ const available = new Set(getAvailableToolRouteGroups(this.catalog, this.gateAllows));
172
176
  for (const value of rawGroups) {
173
177
  if (!isToolRouteGroupName(value)) {
174
178
  rejected.push(`${String(value)}: unknown group`);