mocode-ai 1.7.10 → 1.8.0

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 (39) hide show
  1. package/dist/agent/index.js +50 -14
  2. package/dist/agent/run-coordinator.js +10 -4
  3. package/dist/agent/stages/tool-dispatcher.js +1 -0
  4. package/dist/agent/tool-helpers.js +55 -10
  5. package/dist/config/file.js +11 -3
  6. package/dist/config/index.js +16 -3
  7. package/dist/config/presets.js +9 -0
  8. package/dist/headless.js +8 -1
  9. package/dist/index.js +1 -1
  10. package/dist/llm/index.js +10 -1
  11. package/dist/llm/providers/anthropic.js +9 -1
  12. package/dist/llm/providers/vertex.js +7 -1
  13. package/dist/memory/graph.js +262 -1
  14. package/dist/memory/index.js +3 -1
  15. package/dist/memory/persona.js +142 -0
  16. package/dist/memory/recall-stats.js +61 -0
  17. package/dist/memory/recall.js +47 -0
  18. package/dist/memory/reflect.js +112 -17
  19. package/dist/memory/relation-vocab.js +84 -0
  20. package/dist/models/map-preset.js +4 -0
  21. package/dist/repl/commands/model-actions.js +3 -0
  22. package/dist/repl/commands/model.js +39 -3
  23. package/dist/repl/runtime.js +13 -4
  24. package/dist/runtime/runtime.js +1 -1
  25. package/dist/session/compact-archive.js +232 -0
  26. package/dist/session/compact.js +19 -2
  27. package/dist/session/persist.js +4 -3
  28. package/dist/session/retention.js +33 -2
  29. package/dist/session/store.js +187 -3
  30. package/dist/tools/builtins/edit-file.js +110 -22
  31. package/dist/tools/builtins/grep.js +5 -2
  32. package/dist/tools/builtins/memory-graph.js +2 -0
  33. package/dist/tools/builtins/memory-search.js +20 -14
  34. package/dist/tools/builtins/write-file.js +4 -2
  35. package/dist/tools/constants.js +4 -0
  36. package/dist/tools/ripgrep-client.js +11 -1
  37. package/dist/tools/tool-runtime.js +65 -2
  38. package/dist/ui/diff.js +68 -4
  39. package/package.json +1 -1
@@ -13,7 +13,8 @@ import path from 'node:path';
13
13
  import { chat } from '../llm/index.js';
14
14
  import { config, isMemoryEnabled } from '../config/index.js';
15
15
  import { saveEntry, updateEntry, forgetEntry, loadAll, gcMemories } from './store.js';
16
- import { addTriple } from './graph.js';
16
+ import { addTriple, ensurePrimaryPerson, PRIMARY_PERSON_ID } from './graph.js';
17
+ import { normalizeRelation, isKnownRelation, relationCheatSheet } from './relation-vocab.js';
17
18
  // ── 日志(静默容错,裁尾保最近)─────────────────────────────────────────────
18
19
  function logPath() {
19
20
  return path.join(process.cwd(), '.mocode', 'memory.log');
@@ -91,7 +92,7 @@ function buildMemorySample() {
91
92
  }
92
93
  const TYPES = 'decision | fact | pitfall | reference | feedback';
93
94
  const REFLECT_SYS = `You are mocode's memory reflector. Review the recent session and existing memories, producing **only** updates worth remembering long-term.
94
- Output strictly JSON (no markdown code blocks, no explanatory text): {"saves":[{"type":"...","name":"...","summary":"...","body":"..."}],"updates":[{"id":"...","reason":"...","summary":"...","body":"..."}],"forgets":[{"id":"...","reason":"..."}],"triples":[{"src":"...","relation":"...","dst":"...","fact":"..."}]}
95
+ Output strictly JSON (no markdown code blocks, no explanatory text): {"saves":[{"type":"...","name":"...","summary":"...","body":"..."}],"updates":[{"id":"...","reason":"...","summary":"...","body":"..."}],"forgets":[{"id":"...","reason":"..."}],"triples":[{"src":"...","relation":"...","dst":"...","fact":"..."}],"profile":[{"relation":"...","dst":"...","fact":"..."}]}
95
96
  Empty arrays are valid (if nothing is worth saving, all arrays are empty).
96
97
  Rules:
97
98
  ① Only store non-obvious, cross-session-useful facts/decisions/pitfalls; do not store current bugs, temp files, undecided TODOs, or volatile items;
@@ -100,16 +101,17 @@ Rules:
100
101
  ④ names in saves must be concise and not collide with existing ones; type ∈ {${TYPES}};
101
102
  ⑤ If an existing memory contradicts new facts or is outdated, update the old entry (modify summary/body) rather than creating a duplicate;
102
103
  ⑥ forgets are for memories clearly stale / superseded by a new entry (archive, not hard-delete);
103
- ⑦ triples are knowledge-graph facts distilled from this session: concise entity names (lowercase snake_case or proper nouns), relation in snake_case (e.g. depends_on, decided_by, implemented_in, conflicts_with), plus a one-line fact. Only emit triples that are stable, non-obvious and cross-session-useful (2-6 at most); they may reference entities from saves/updates or existing memories.`;
104
+ ⑦ triples are PROJECT knowledge-graph facts about THIS CODEBASE distilled from this session: concise entity names (lowercase snake_case or proper nouns) plus a one-line fact. Prioritize the high-reuse structural relations in this order — dependency/ownership (depends_on, uses, used_by, calls, part_of), location (implemented_in, reads, writes, configured_by, runs_on, listens_on), verification (tests, documented_in), rationale/risk (decided_by, conflicts_with); full list: ${relationCheatSheet('project')}. Favor facts that would help a future session navigate or avoid re-deriving (which module implements X, what depends on Y, what conflicts with Z). A relation outside this list is allowed only when none fits (it gets flagged for review). Only emit triples that are stable, non-obvious and reusable in this repo (2-6 at most).
105
+ ⑧ profile is for facts about THE USER (the human), which must apply across ALL projects — identity, role, expertise, language/communication style, stable preferences, current goals, constraints. They are attached to the single canonical person entity. Each item: dst is a short atomic value/entity, fact is one neutral Chinese sentence stating it. Prefer these relations: ${relationCheatSheet('global')}. A relation outside this list is allowed only when none fits. NEVER put repo paths, commands, file names, ports, secrets or one-task details in profile — those belong in triples. Only emit profile when the user explicitly revealed something durable about themselves (0-3 items); when unsure, omit.`;
104
106
  const REFLECT_USER = (transcript, sample) => `## Recent session\n${transcript}\n\n## Existing memories\n${sample}\n\nProduce JSON:`;
105
- function parsePlan(content) {
107
+ export function parsePlan(content) {
106
108
  if (!content)
107
109
  return null;
108
110
  let s = content.trim();
109
- // 去 ```json … ``` 代码块(模型偶发包裹)
110
- const fence = s.match(/```(?:json)?\s*([\s\S]*?)```/i);
111
- if (fence)
112
- s = fence[1].trim();
111
+ // 去 ```json … ``` 代码块(模型偶发包裹);取最后一个块(前面可能有解释文字)。
112
+ const fences = [...s.matchAll(/```(?:json)?\s*([\s\S]*?)```/gi)];
113
+ if (fences.length)
114
+ s = fences[fences.length - 1][1].trim();
113
115
  // 容错:截到首个 { 到末个 }
114
116
  const first = s.indexOf('{');
115
117
  const last = s.lastIndexOf('}');
@@ -122,12 +124,40 @@ function parsePlan(content) {
122
124
  return p;
123
125
  }
124
126
  catch {
127
+ // 再试一次:去掉对象/数组尾逗号(模型常见的非法 JSON)。
128
+ try {
129
+ const p = JSON.parse(s.replace(/,\s*([}\]])/g, '$1'));
130
+ if (p && typeof p === 'object')
131
+ return p;
132
+ }
133
+ catch {
134
+ // 落到 null
135
+ }
125
136
  return null;
126
137
  }
127
138
  }
139
+ /** 取一次反思 LLM 返回的可解析文本(content 优先;tool_calls 的 JSON 串参数兜底)。 */
140
+ async function fetchReflection(messages, signal) {
141
+ const r = await chat(messages, {}, signal, undefined, { reasoningEffort: 'low' });
142
+ if (!r.toolCalls.length && r.content)
143
+ return { content: r.content, raw: null };
144
+ // 推理模型偶发只把 JSON 塞进 tool_calls 参数、或 content 为 null:从工具调用里捞 JSON 串。
145
+ for (const tc of r.toolCalls) {
146
+ const args = tc.function?.arguments;
147
+ if (typeof args === 'string' && args.includes('{'))
148
+ return { content: args, raw: null };
149
+ }
150
+ // 无可用正文(典型:只吐 reasoning_content):保留简短指纹供日志排查。
151
+ const fingerprint = r.content
152
+ ? `content=${r.content.slice(0, 200)}`
153
+ : `content=null, toolCalls=${r.toolCalls.length}`;
154
+ return { content: null, raw: fingerprint };
155
+ }
128
156
  /**
129
157
  * 跑一次反思:chat() 空 handlers(静默)→ 解析 JSON → 经 store 落地 → gcMemories。
130
- * 解析失败整 pass 放弃(不部分落地)。store 调用全同步,落地是一个原子块。
158
+ * 首轮解析失败时做**一次修复式重试**(明确要求"只输出 JSON、不要思考过程"),
159
+ * 重试仍失败才报 parse-failed——此时 rawParseFailure 带原始返回指纹,由 kickoff 落
160
+ * memory.log,不再像旧版那样无声丢掉一整轮且无从排查。store 调用全同步。
131
161
  * 60s 超时(AbortSignal.timeout)防 exit 时 drain 挂死。
132
162
  */
133
163
  export async function runReflection(transcript, signal) {
@@ -135,7 +165,7 @@ export async function runReflection(transcript, signal) {
135
165
  const sample = buildMemorySample();
136
166
  const sys = { role: 'system', content: REFLECT_SYS };
137
167
  const user = { role: 'user', content: REFLECT_USER(transcript, sample) };
138
- let result = {
168
+ const result = {
139
169
  ts,
140
170
  saves: 0,
141
171
  updates: 0,
@@ -145,12 +175,27 @@ export async function runReflection(transcript, signal) {
145
175
  gcCapped: 0,
146
176
  gcGced: 0,
147
177
  };
178
+ const timeoutSignal = signal ?? AbortSignal.timeout(60000);
148
179
  let content = null;
149
180
  try {
150
- const r = await chat([sys, user], {}, signal ?? AbortSignal.timeout(60000), undefined, { reasoningEffort: 'low' });
151
- // 推理模型偶发只返 reasoning_content(content null)或幻觉 tool_calls → 视为无产出
152
- if (!r.toolCalls.length && r.content)
153
- content = r.content;
181
+ const first = await fetchReflection([sys, user], timeoutSignal);
182
+ content = first.content;
183
+ // 首轮拿不到可解析正文 → 一次修复式重试(同一超时信号的剩余预算)。
184
+ if (parsePlan(content) === null) {
185
+ result.repairAttempted = true;
186
+ const repairUser = {
187
+ role: 'user',
188
+ content: '你的上一条回复无法被解析为目标 JSON。现在只输出一个 JSON 对象,不要 markdown 围栏、不要解释、不要思考过程,' +
189
+ '形状严格为 {"saves":[],"updates":[],"forgets":[],"triples":[]};没有值得长期记住的内容就全部给空数组。',
190
+ };
191
+ const second = await fetchReflection([sys, user, { role: 'assistant', content: content ?? '' }, repairUser], timeoutSignal);
192
+ if (second.content)
193
+ content = second.content;
194
+ if (!content && first.raw)
195
+ result.rawParseFailure = first.raw;
196
+ else if (second.raw && parsePlan(content) === null)
197
+ result.rawParseFailure = second.raw;
198
+ }
154
199
  }
155
200
  catch (e) {
156
201
  result.error = e instanceof Error ? e.name + ': ' + e.message : String(e);
@@ -159,6 +204,8 @@ export async function runReflection(transcript, signal) {
159
204
  const plan = parsePlan(content);
160
205
  if (!plan) {
161
206
  result.error = 'parse-failed';
207
+ if (!result.rawParseFailure)
208
+ result.rawParseFailure = (content ?? '').slice(0, 300);
162
209
  return result;
163
210
  }
164
211
  let saves = 0;
@@ -201,14 +248,19 @@ export async function runReflection(transcript, signal) {
201
248
  }
202
249
  }
203
250
  // 知识图谱三元组:容错——单项失败跳过,不影响 saves/updates/forgets 已落地的结果。
251
+ // 关系名先归一(别名/拼写);词表外关系仍允许写(软封闭世界),但计 relViolations 供观测。
204
252
  let triples = 0;
253
+ let relViolations = 0;
205
254
  if (Array.isArray(plan.triples)) {
206
255
  for (const t of plan.triples.slice(0, 10)) {
207
256
  if (!t?.src || !t?.relation || !t?.dst)
208
257
  continue;
258
+ const relation = normalizeRelation(String(t.relation));
259
+ if (!isKnownRelation(relation, 'project'))
260
+ relViolations++;
209
261
  const r = addTriple({
210
262
  src: String(t.src),
211
- relation: String(t.relation),
263
+ relation,
212
264
  dst: String(t.dst),
213
265
  fact: t.fact ? String(t.fact) : undefined,
214
266
  });
@@ -216,8 +268,43 @@ export async function runReflection(transcript, signal) {
216
268
  triples++;
217
269
  }
218
270
  }
271
+ // 全局用户画像:挂到规范 person-primary、写 global 图(与项目 triples 分流)。
272
+ let profiles = 0;
273
+ if (Array.isArray(plan.profile) && plan.profile.length > 0) {
274
+ let personId = PRIMARY_PERSON_ID;
275
+ try {
276
+ personId = ensurePrimaryPerson().id;
277
+ }
278
+ catch {
279
+ personId = PRIMARY_PERSON_ID;
280
+ }
281
+ for (const p of plan.profile.slice(0, 6)) {
282
+ if (!p?.relation || !p.dst)
283
+ continue;
284
+ const relation = normalizeRelation(String(p.relation));
285
+ if (!isKnownRelation(relation, 'global'))
286
+ relViolations++;
287
+ const r = addTriple({
288
+ src: personId,
289
+ relation,
290
+ dst: String(p.dst),
291
+ fact: p.fact ? String(p.fact) : undefined,
292
+ scope: 'global',
293
+ });
294
+ if (r.ok && !r.duplicate)
295
+ profiles++;
296
+ }
297
+ }
219
298
  const gc = gcMemories();
220
- result = { ...result, saves, updates, forgets, triples, gcDecayed: gc.decayed, gcCapped: gc.capped, gcGced: gc.gced };
299
+ result.saves = saves;
300
+ result.updates = updates;
301
+ result.forgets = forgets;
302
+ result.triples = triples;
303
+ result.profiles = profiles;
304
+ result.relViolations = relViolations;
305
+ result.gcDecayed = gc.decayed;
306
+ result.gcCapped = gc.capped;
307
+ result.gcGced = gc.gced;
221
308
  return result;
222
309
  }
223
310
  function normalizeType(t) {
@@ -231,9 +318,13 @@ function normalizeType(t) {
231
318
  // ── 后台编排:kickoff / drain / 缓存 ─────────────────────────────────────────
232
319
  let inflight = null;
233
320
  let lastReflectResult = null;
234
- /** 摘要串(供 repl flush):存N 改N 忘N 图N;有错误附上。 */
321
+ /** 摘要串(供 repl flush):存N 改N 忘N 图N [画像N 违N];有错误附上。 */
235
322
  export function formatReflectResult(r) {
236
323
  const parts = [`存${r.saves}`, `改${r.updates}`, `忘${r.forgets}`, `图${r.triples}`];
324
+ if (r.profiles)
325
+ parts.push(`画像${r.profiles}`);
326
+ if (r.relViolations)
327
+ parts.push(`违${r.relViolations}`);
237
328
  if (r.gcDecayed || r.gcCapped || r.gcGced) {
238
329
  parts.push(`遗忘(衰减${r.gcDecayed}/封顶${r.gcCapped}/清除${r.gcGced})`);
239
330
  }
@@ -256,6 +347,10 @@ export function kickoffReflection(transcript) {
256
347
  const r = await runReflection(transcript);
257
348
  lastReflectResult = r;
258
349
  appendLog(`[${r.ts}] ${formatReflectResult(r)}`);
350
+ if (r.error === 'parse-failed' && r.rawParseFailure) {
351
+ // 留证:旧版 parse-failed 无声丢弃,无法排查(实测约三成反思命中)。
352
+ appendLog(` ↳ parse-failed 原始返回: ${r.rawParseFailure.replace(/\s+/g, ' ').slice(0, 500)}`);
353
+ }
259
354
  }
260
355
  catch (e) {
261
356
  appendLog(`[${new Date().toISOString()}] 反思异常: ${e instanceof Error ? e.message : String(e)}`);
@@ -0,0 +1,84 @@
1
+ // relation-vocab.ts —— 受控关系词表(轻量,不含实体类型/domain-range)。
2
+ //
3
+ // 这是 memory-2.2 本体方案里"软封闭世界"的最小落地:只约束**关系名**,不引入实体类型
4
+ // 体系或推理。反思抽取 triples/profile 时优先用词表内关系;词表外关系**仍允许写入**
5
+ // (软策略,不阻断长尾),但会被计数为 relViolation 供观测。等真实数据证明需要,再升级
6
+ // 为完整 ontology.ts(实体 type + domain/range + 逆/传递)。
7
+ //
8
+ // 纯常量 + 纯函数,叶子模块,无 IO。
9
+ /** 项目轨(懂仓库)首选关系:高复用、可结构化的连接。 */
10
+ export const PROJECT_RELATIONS = [
11
+ 'depends_on',
12
+ 'uses',
13
+ 'used_by',
14
+ 'calls',
15
+ 'part_of',
16
+ 'implemented_in',
17
+ 'reads',
18
+ 'writes',
19
+ 'configured_by',
20
+ 'runs_on',
21
+ 'listens_on',
22
+ 'tests',
23
+ 'documented_in',
24
+ 'decided_by',
25
+ 'conflicts_with',
26
+ 'related_to',
27
+ ];
28
+ /** 用户轨(懂用户)画像关系:挂 person-primary。 */
29
+ export const PROFILE_RELATIONS = [
30
+ 'named',
31
+ 'has_role',
32
+ 'works_at',
33
+ 'located_in',
34
+ 'speaks',
35
+ 'verbosity',
36
+ 'communication_style',
37
+ 'prefers',
38
+ 'avoid',
39
+ 'avoids',
40
+ 'current_goal',
41
+ 'constraint',
42
+ 'has_hobby',
43
+ 'skill_level',
44
+ 'related_to',
45
+ ];
46
+ /** 常见同义/拼写漂移 → 规范关系(反思落库前归范)。 */
47
+ export const RELATION_ALIASES = {
48
+ employed_by: 'works_at',
49
+ employer: 'works_at',
50
+ lives_in: 'located_in',
51
+ based_in: 'located_in',
52
+ location: 'located_in',
53
+ hobby: 'has_hobby',
54
+ role: 'has_role',
55
+ is_a: 'related_to',
56
+ uses_port: 'listens_on',
57
+ port: 'listens_on',
58
+ depends: 'depends_on',
59
+ dependency: 'depends_on',
60
+ implements: 'implemented_in',
61
+ impl: 'implemented_in',
62
+ decides: 'decided_by',
63
+ conflict: 'conflicts_with',
64
+ configure: 'configured_by',
65
+ };
66
+ const PROJECT_SET = new Set(PROJECT_RELATIONS);
67
+ const PROFILE_SET = new Set(PROFILE_RELATIONS);
68
+ /** 关系名归一:lowercase + 空白转下划线 + 别名映射。 */
69
+ export function normalizeRelation(raw) {
70
+ const base = String(raw ?? '')
71
+ .trim()
72
+ .toLowerCase()
73
+ .replace(/\s+/g, '_');
74
+ return RELATION_ALIASES[base] ?? base;
75
+ }
76
+ /** 关系是否在对应 scope 的首选词表内(先归一再判定)。 */
77
+ export function isKnownRelation(relation, scope) {
78
+ const r = normalizeRelation(relation);
79
+ return scope === 'global' ? PROFILE_SET.has(r) : PROJECT_SET.has(r);
80
+ }
81
+ /** 注入反思提示词的紧凑词表(按 scope)。 */
82
+ export function relationCheatSheet(scope) {
83
+ return scope === 'global' ? PROFILE_RELATIONS.join(', ') : PROJECT_RELATIONS.join(', ');
84
+ }
@@ -57,6 +57,9 @@ export function buildPreset(input) {
57
57
  const contextWindow = typeof model.limit?.context === 'number' && model.limit.context > 0
58
58
  ? Math.floor(model.limit.context)
59
59
  : FALLBACK_CONTEXT_WINDOW;
60
+ // 单次输出上限:目录有 limit.output 就带上(避免吃网关小默认导致长 write_file 被截断);
61
+ // 目录缺失则留 undefined=不下发,绝不硬编码大值(超过模型真实上限会被后端 400 拒整请求)。
62
+ const maxOutput = typeof model.limit?.output === 'number' && model.limit.output > 0 ? Math.floor(model.limit.output) : undefined;
60
63
  return {
61
64
  name: input.name ?? defaultPresetName(provider.id, model.id),
62
65
  provider: presetProvider,
@@ -64,6 +67,7 @@ export function buildPreset(input) {
64
67
  apiKey: input.apiKey,
65
68
  model: input.modelOverride ?? model.id,
66
69
  contextWindow,
70
+ ...(maxOutput ? { maxTokens: maxOutput } : {}),
67
71
  anthropicPromptCache: presetProvider === 'anthropic',
68
72
  };
69
73
  }
@@ -23,6 +23,8 @@ export function applyPresetConfig(ctx, p) {
23
23
  baseURL: p.baseURL,
24
24
  apiKey: p.apiKey,
25
25
  contextWindowTokens: p.contextWindow,
26
+ // 预设带值用值;不带显式清除(否则旧 MAX_TOKENS 残留会盖过新预设)。
27
+ maxTokens: p.maxTokens ?? null,
26
28
  anthropicPromptCache: p.anthropicPromptCache,
27
29
  ...(p.reasoningEffort ? { reasoningEffort: p.reasoningEffort } : {}),
28
30
  });
@@ -32,6 +34,7 @@ export function applyPresetConfig(ctx, p) {
32
34
  LLM_API_KEY: p.apiKey,
33
35
  LLM_MODEL: p.model,
34
36
  CONTEXT_WINDOW_TOKENS: String(p.contextWindow),
37
+ MAX_TOKENS: p.maxTokens ? String(p.maxTokens) : null,
35
38
  ANTHROPIC_PROMPT_CACHE: p.anthropicPromptCache ? 'true' : 'false',
36
39
  });
37
40
  // 关键不变量:应用预设即激活——必须同步 .active 指针。否则重启时旧激活预设的优先级
@@ -25,14 +25,15 @@ import { renderHistory } from '../message-format.js';
25
25
  import { MODEL_PRESETS, maskKey } from '../commands.js';
26
26
  import { unhandled, next } from './types.js';
27
27
  /** 决定自动存的预设名:协议、缓存配置和连接四元组都一致时不重复存。 */
28
- function uniquePresetName(desired, provider, baseURL, apiKey, model, contextWindow, anthropicPromptCache) {
28
+ function uniquePresetName(desired, provider, baseURL, apiKey, model, contextWindow, anthropicPromptCache, maxTokens) {
29
29
  const existing = listPresets();
30
30
  const sameEntry = existing.find((p) => p.provider === provider &&
31
31
  p.baseURL === baseURL &&
32
32
  p.apiKey === apiKey &&
33
33
  p.model === model &&
34
34
  p.contextWindow === contextWindow &&
35
- p.anthropicPromptCache === anthropicPromptCache);
35
+ p.anthropicPromptCache === anthropicPromptCache &&
36
+ (p.maxTokens ?? undefined) === maxTokens);
36
37
  if (sameEntry)
37
38
  return null;
38
39
  const sanitized = desired
@@ -73,6 +74,8 @@ export const modelCommands = [
73
74
  baseURL: target.baseURL,
74
75
  apiKey: target.apiKey,
75
76
  contextWindowTokens: target.contextWindow,
77
+ // 预设带值用值;不带则显式清除(否则本窗口/配置文件里的旧 MAX_TOKENS 会一直盖过新预设)。
78
+ maxTokens: target.maxTokens ?? null,
76
79
  anthropicPromptCache: target.anthropicPromptCache,
77
80
  ...(target.reasoningEffort ? { reasoningEffort: target.reasoningEffort } : {}),
78
81
  });
@@ -82,6 +85,8 @@ export const modelCommands = [
82
85
  LLM_API_KEY: target.apiKey,
83
86
  LLM_MODEL: target.model,
84
87
  CONTEXT_WINDOW_TOKENS: String(target.contextWindow),
88
+ // null = 从配置文件删除该键(写不下发=吃后端默认)。
89
+ MAX_TOKENS: target.maxTokens ? String(target.maxTokens) : null,
85
90
  ANTHROPIC_PROMPT_CACHE: target.anthropicPromptCache ? 'true' : 'false',
86
91
  ...(target.reasoningEffort ? { REASONING_EFFORT: target.reasoningEffort } : {}),
87
92
  });
@@ -185,6 +190,7 @@ export const modelCommands = [
185
190
  layout.contentWrite(` ${ui.accent}effort ${ui.reset} ${config.reasoningEffort}` +
186
191
  (recognized ? '\n' : `${ui.dim}(当前模型未识别,参数不会下发)${ui.reset}\n`));
187
192
  layout.contentWrite(` ${ui.accent}窗口 ${ui.reset} ${config.contextWindowTokens} tokens\n`);
193
+ layout.contentWrite(` ${ui.accent}输出上限${ui.reset} ${config.maxTokens ? `${config.maxTokens} tokens` : `${ui.dim}未设置(不下发,吃后端默认)${ui.reset}`}\n`);
188
194
  if (config.provider === 'anthropic') {
189
195
  layout.contentWrite(` ${ui.accent}缓存 ${ui.reset} Prompt Cache ${config.anthropicPromptCache ? 'on' : 'off'}\n`);
190
196
  }
@@ -284,11 +290,14 @@ export const modelCommands = [
284
290
  let apiKey;
285
291
  let model;
286
292
  let window;
293
+ // 单次输出上限;undefined = 不下发(吃后端默认)。quickApply 沿用当前值。
294
+ let maxTokens;
287
295
  if (quickApply) {
288
296
  baseURL = preset.baseURL;
289
297
  apiKey = config.apiKey;
290
298
  model = preset.model;
291
299
  window = preset.window;
300
+ maxTokens = config.maxTokens;
292
301
  }
293
302
  else {
294
303
  // baseURL
@@ -364,6 +373,29 @@ export const modelCommands = [
364
373
  window = Math.floor(n);
365
374
  }
366
375
  }
376
+ // maxTokens(可选):单次回复输出上限。回车采纳当前值;填 0/留空 = 不下发吃后端默认。
377
+ {
378
+ const res = await promptIntervention({
379
+ type: 'input',
380
+ title: 'MAX_TOKENS',
381
+ detail: '单次回复最大输出 token(思考+正文+工具参数)。留空=不下发吃后端默认;' +
382
+ '部分兼容网关默认仅 4k/8k,长文件写入会被截断,建议填该模型官方最大输出(常见 8k-64k)。回车采纳当前值。',
383
+ seed: config.maxTokens ? String(config.maxTokens) : '',
384
+ });
385
+ if (res.action === 'cancelled') {
386
+ return next();
387
+ }
388
+ const v = (res.value ?? '').trim();
389
+ if (v) {
390
+ const n = Number(v);
391
+ if (Number.isFinite(n) && n > 0)
392
+ maxTokens = Math.floor(n);
393
+ // 非法值不阻断:保持 undefined(不下发),与留空同义。
394
+ }
395
+ else {
396
+ maxTokens = undefined;
397
+ }
398
+ }
367
399
  }
368
400
  // 3) 应用协议、连接与缓存配置;Anthropic 原生协议无需重建 OpenAI client,但统一刷新无害。
369
401
  const provider = preset.provider;
@@ -374,6 +406,8 @@ export const modelCommands = [
374
406
  baseURL,
375
407
  apiKey,
376
408
  contextWindowTokens: window,
409
+ // 手填向导里留空 = undefined → 显式清除旧值,不下发 max_tokens。
410
+ maxTokens: maxTokens ?? null,
377
411
  anthropicPromptCache,
378
412
  });
379
413
  writeConfigKeys({
@@ -382,13 +416,14 @@ export const modelCommands = [
382
416
  LLM_API_KEY: apiKey,
383
417
  LLM_MODEL: model,
384
418
  CONTEXT_WINDOW_TOKENS: String(window),
419
+ MAX_TOKENS: maxTokens ? String(maxTokens) : null,
385
420
  ANTHROPIC_PROMPT_CACHE: anthropicPromptCache ? 'true' : 'false',
386
421
  });
387
422
  reconfigureClient();
388
423
  // 3.5) 自动存为命名预设;协议与缓存策略也是去重键的一部分。
389
424
  let savedName = null;
390
425
  try {
391
- const finalName = uniquePresetName(model, provider, baseURL, apiKey, model, window, anthropicPromptCache);
426
+ const finalName = uniquePresetName(model, provider, baseURL, apiKey, model, window, anthropicPromptCache, maxTokens);
392
427
  if (finalName) {
393
428
  savePreset({
394
429
  name: finalName,
@@ -397,6 +432,7 @@ export const modelCommands = [
397
432
  apiKey,
398
433
  model,
399
434
  contextWindow: window,
435
+ ...(maxTokens ? { maxTokens } : {}),
400
436
  anthropicPromptCache,
401
437
  });
402
438
  savedName = finalName;
@@ -24,7 +24,7 @@ import { renderChip } from '../attachments/image.js';
24
24
  import { contextState, appendCurrentSessionRuntimeEvent } from '../session/index.js';
25
25
  import { effectiveSystemPrompt } from '../skills/index.js';
26
26
  import { clearSkillActivation } from '../skills/activation.js';
27
- import { buildMemoryIndexSection, kickoffReflection, drainMemoryBackground, getLastReflectResult, clearLastReflectResult, snapshotTranscript, formatReflectResult, } from '../memory/index.js';
27
+ import { buildMemoryIndexSection, buildAboutYouSection, buildProjectGraphHint, initPersona, kickoffReflection, drainMemoryBackground, getLastReflectResult, clearLastReflectResult, snapshotTranscript, formatReflectResult, } from '../memory/index.js';
28
28
  import { collectQueryHistory } from '../session/query-history.js';
29
29
  import { ToolPolicyController } from '../tools/policy.js';
30
30
  import { routeToolGroups } from '../tools/router.js';
@@ -198,9 +198,18 @@ export async function startRepl(initialHistory, sessionId, sandboxRootOverride,
198
198
  // 与开关联动:① base 用 buildBasePrompt() 取代 config.systemPrompt(后者是启动时一次性
199
199
  // 求值的常量,运行时 /memory_switch 不会刷新);② plan suffix 走 getPlanModeSuffix() 现拼;
200
200
  // ③ AGENTS.md 存在工作区根时由 base 无条件自动导入正文(超长截断,与 memory 开关无关);④ Memory Index 按开关注入。
201
- const buildSystemMessage = (planMode) => effectiveSystemPrompt(buildBasePrompt(currentSessionId) +
202
- (planMode ? getPlanModeSuffix() : '') +
203
- buildMemoryIndexSection(isMemoryEnabled()));
201
+ // 启动期一次性身份归一(幂等,best-effort):把全局图里的用户指称碎片并入
202
+ // person-primary,随后 About you 段才能渲染出完整画像。
203
+ initPersona(isMemoryEnabled());
204
+ // 开关现读:运行时 /memory_switch 重建 history[0] 时,About you 与 Memory Index 同步显隐。
205
+ const buildSystemMessage = (planMode) => {
206
+ const memoryOn = isMemoryEnabled();
207
+ return effectiveSystemPrompt(buildBasePrompt(currentSessionId) +
208
+ buildAboutYouSection(memoryOn) +
209
+ (planMode ? getPlanModeSuffix() : '') +
210
+ buildMemoryIndexSection(memoryOn) +
211
+ buildProjectGraphHint(memoryOn));
212
+ };
204
213
  // 有预加载(--resume)则用它,并把 history[0] 刷成当前 system prompt(config 可能已变);
205
214
  // 否则新会话只塞 system 提示(默认 auto)。
206
215
  const history = initialHistory && initialHistory.length ? initialHistory : [{ role: 'system', content: buildSystemMessage(false) }];
@@ -274,7 +274,7 @@ export class Runtime {
274
274
  }
275
275
  resumeSession(id) {
276
276
  this.assertIdle('resume session');
277
- const loaded = this.context.sessionStore.load(id);
277
+ const loaded = this.context.sessionStore.load(id, { restoreAssets: true });
278
278
  if (!loaded?.history.length)
279
279
  return null;
280
280
  this.context.sessionStore.setCurrentSessionId(loaded.id);