minimal-agent 0.18.13 → 0.18.14

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 (174) hide show
  1. package/package.json +1 -3
  2. package/plugins/workflow-runner/src/runtime/context.js +1 -1
  3. package/plugins/workflow-runner/src/runtime/context.ts +1 -1
  4. package/plugins/workflow-runner/test/embeddedExecution.test.ts +2 -5
  5. package/plugins/workflow-runner/test/execLifetime.test.ts +15 -2
  6. package/skills/ssh-remote-dev/SKILL.md +61 -0
  7. package/skills/ssh-remote-dev/agents/openai.yaml +4 -0
  8. package/src/advisor/README.md +62 -24
  9. package/src/advisor/advisorNudge.js +199 -0
  10. package/src/advisor/advisorNudge.ts +232 -0
  11. package/src/advisor/deliveryReview.js +35 -12
  12. package/src/advisor/deliveryReview.ts +35 -11
  13. package/src/advisor/presentation.js +7 -10
  14. package/src/advisor/presentation.ts +10 -13
  15. package/src/advisor/reviewContext.js +22 -58
  16. package/src/advisor/reviewContext.ts +24 -67
  17. package/src/cli/configMenu.js +17 -128
  18. package/src/cli/configMenu.tsx +17 -193
  19. package/src/cli/print.js +2 -0
  20. package/src/cli/print.ts +2 -0
  21. package/src/cli/streamJson.js +2 -0
  22. package/src/cli/streamJson.ts +2 -0
  23. package/src/cli/wizards/advisorWizard.js +9 -7
  24. package/src/cli/wizards/advisorWizard.tsx +9 -7
  25. package/src/cli/wizards/contextAdvisorWizard.js +9 -7
  26. package/src/cli/wizards/contextAdvisorWizard.tsx +9 -7
  27. package/src/cli/wizards/providerPresets.js +27 -1
  28. package/src/cli/wizards/providerPresets.ts +44 -2
  29. package/src/cli/wizards/providerWizard.js +51 -10
  30. package/src/cli/wizards/providerWizard.tsx +59 -9
  31. package/src/config/configDefaults.js +1 -1
  32. package/src/config/configDefaults.ts +1 -1
  33. package/src/config/configDiagnostics.js +1 -21
  34. package/src/config/configDiagnostics.ts +3 -23
  35. package/src/config/configFile.js +11 -4
  36. package/src/config/configFile.ts +27 -7
  37. package/src/config/configManagement.js +1 -0
  38. package/src/config/configManagement.ts +2 -0
  39. package/src/config/configService.js +12 -8
  40. package/src/config/configService.ts +16 -11
  41. package/src/config/numericLimits.js +0 -4
  42. package/src/config/numericLimits.ts +0 -4
  43. package/src/config/reasoningEffort.js +16 -0
  44. package/src/config/reasoningEffort.ts +18 -0
  45. package/src/context/budgetPlan.js +1 -1
  46. package/src/context/budgetPlan.ts +1 -1
  47. package/src/context/optimizeConversation.js +1 -1
  48. package/src/context/optimizeConversation.ts +1 -1
  49. package/src/harness/stallGuard.js +8 -13
  50. package/src/harness/stallGuard.ts +8 -12
  51. package/src/llm/client.js +64 -17
  52. package/src/llm/client.ts +62 -7
  53. package/src/llm/providers/openaiFormat.js +14 -3
  54. package/src/llm/providers/openaiFormat.ts +17 -3
  55. package/src/llm/providers/responsesFormat.js +275 -0
  56. package/src/llm/providers/responsesFormat.ts +306 -0
  57. package/src/loop/README.md +18 -3
  58. package/src/loop/liveDiagramGate.js +63 -0
  59. package/src/loop/liveDiagramGate.ts +61 -0
  60. package/src/loop/messages.js +12 -0
  61. package/src/loop/messages.ts +13 -0
  62. package/src/loop/options.ts +7 -2
  63. package/src/loop/toolBatch.js +0 -1
  64. package/src/loop/toolBatch.ts +0 -1
  65. package/src/loop/toolExecutor.js +11 -46
  66. package/src/loop/toolExecutor.ts +12 -55
  67. package/src/loop/toolPresentation.js +2 -13
  68. package/src/loop/toolPresentation.ts +2 -13
  69. package/src/loop/toolSemantics.js +3 -4
  70. package/src/loop/toolSemantics.ts +4 -6
  71. package/src/loop.js +222 -214
  72. package/src/loop.ts +226 -232
  73. package/src/memory/dependencies.js +11 -3
  74. package/src/memory/dependencies.ts +11 -3
  75. package/src/memory/episodicMemory.js +59 -18
  76. package/src/memory/episodicMemory.ts +58 -18
  77. package/src/memory/experienceStore.js +4 -3
  78. package/src/memory/experienceStore.ts +4 -3
  79. package/src/memory/factCandidates.js +53 -1
  80. package/src/memory/factCandidates.ts +54 -1
  81. package/src/memory/factLearning.js +2 -2
  82. package/src/memory/factLearning.ts +2 -2
  83. package/src/memory/jobPolicy.js +24 -5
  84. package/src/memory/jobPolicy.ts +25 -5
  85. package/src/memory/learning.js +2 -2
  86. package/src/memory/learning.ts +2 -2
  87. package/src/memory/learningModel.js +7 -0
  88. package/src/memory/learningModel.ts +7 -0
  89. package/src/memory/managementService.js +8 -7
  90. package/src/memory/managementService.ts +16 -8
  91. package/src/memory/memoryService.js +104 -33
  92. package/src/memory/memoryService.ts +132 -28
  93. package/src/memory/projectKnowledge.js +39 -3
  94. package/src/memory/projectKnowledge.ts +45 -3
  95. package/src/memory/recallQuery.js +56 -0
  96. package/src/memory/recallQuery.ts +58 -0
  97. package/src/memory/retrieval.js +39 -10
  98. package/src/memory/retrieval.ts +46 -11
  99. package/src/memory/turnJournal.js +75 -10
  100. package/src/memory/turnJournal.ts +66 -10
  101. package/src/memory/types.ts +7 -1
  102. package/src/prompts/delegation.js +2 -1
  103. package/src/prompts/delegation.ts +3 -1
  104. package/src/prompts/policyRegistry.js +9 -12
  105. package/src/prompts/policyRegistry.ts +9 -12
  106. package/src/prompts/promptAssembly.js +6 -5
  107. package/src/prompts/promptAssembly.ts +8 -6
  108. package/src/prompts/visualPresentation.js +39 -10
  109. package/src/prompts/visualPresentation.ts +38 -9
  110. package/src/runtime/answerPreparation.js +1 -1
  111. package/src/runtime/answerPreparation.ts +1 -1
  112. package/src/runtime/fileLock.js +19 -3
  113. package/src/runtime/fileLock.ts +19 -3
  114. package/src/runtime/invocationPolicy.js +1 -1
  115. package/src/runtime/invocationPolicy.ts +1 -1
  116. package/src/runtime/outputContract.js +0 -4
  117. package/src/runtime/outputContract.ts +0 -7
  118. package/src/session/runAgentTurn.js +3 -0
  119. package/src/session/runAgentTurn.ts +3 -0
  120. package/src/team/onlineToolContract.js +0 -1
  121. package/src/team/onlineToolContract.ts +0 -1
  122. package/src/tools/advisor/advisor.js +207 -365
  123. package/src/tools/advisor/advisor.ts +211 -383
  124. package/src/tools/agent/agentTypes.js +0 -1
  125. package/src/tools/agent/agentTypes.ts +0 -1
  126. package/src/tools/agent/coordinator.js +4 -1
  127. package/src/tools/agent/coordinator.ts +4 -1
  128. package/src/tools/bash/semantics.js +4 -5
  129. package/src/tools/bash/semantics.ts +4 -5
  130. package/src/tools/capabilities.js +2 -3
  131. package/src/tools/capabilities.ts +2 -4
  132. package/src/tools/index.js +0 -16
  133. package/src/tools/index.ts +0 -16
  134. package/src/tools/memory/maintenance.js +8 -9
  135. package/src/tools/memory/maintenance.ts +13 -11
  136. package/src/tools/memory/memory.js +5 -1
  137. package/src/tools/memory/memory.ts +5 -0
  138. package/src/tools/optimizeConversation/README.md +4 -6
  139. package/src/tools/toolPromptGuides.js +2 -9
  140. package/src/tools/toolPromptGuides.ts +2 -9
  141. package/src/types.ts +28 -105
  142. package/src/ui/App.js +2 -0
  143. package/src/ui/App.tsx +1 -0
  144. package/src/ui/MessageList.js +8 -44
  145. package/src/ui/MessageList.tsx +3 -81
  146. package/src/ui/hooks/useChat.js +2 -0
  147. package/src/ui/hooks/useChat.ts +2 -0
  148. package/src/ui/toolProgress.js +1 -1
  149. package/src/ui/toolProgress.ts +1 -1
  150. package/src/web/protocol.ts +10 -3
  151. package/src/advisor/advisorCoordinator.js +0 -734
  152. package/src/advisor/advisorCoordinator.ts +0 -825
  153. package/src/cli/wizards/sshHostWizard.js +0 -156
  154. package/src/cli/wizards/sshHostWizard.tsx +0 -214
  155. package/src/context/sshHosts.js +0 -290
  156. package/src/context/sshHosts.ts +0 -312
  157. package/src/loop/publication.js +0 -94
  158. package/src/loop/publication.ts +0 -124
  159. package/src/runtime/finalAnswer.js +0 -9
  160. package/src/runtime/finalAnswer.ts +0 -13
  161. package/src/tools/advisor/advisorDecision.js +0 -79
  162. package/src/tools/advisor/advisorDecision.ts +0 -92
  163. package/src/tools/ssh/ssh.js +0 -152
  164. package/src/tools/ssh/ssh.ts +0 -167
  165. package/src/tools/ssh/sshAvailability.js +0 -35
  166. package/src/tools/ssh/sshAvailability.ts +0 -39
  167. package/src/tools/ssh/sshClient.js +0 -177
  168. package/src/tools/ssh/sshClient.ts +0 -189
  169. package/src/tools/ssh/sshDownload.js +0 -77
  170. package/src/tools/ssh/sshDownload.ts +0 -92
  171. package/src/tools/ssh/sshHostsTool.js +0 -36
  172. package/src/tools/ssh/sshHostsTool.ts +0 -48
  173. package/src/tools/ssh/sshUpload.js +0 -72
  174. package/src/tools/ssh/sshUpload.ts +0 -85
@@ -1,5 +1,5 @@
1
1
  import { readFile, stat } from 'node:fs/promises';
2
- import { isAbsolute, relative, resolve } from 'node:path';
2
+ import { isAbsolute, relative, resolve, sep } from 'node:path';
3
3
  import { z } from 'zod';
4
4
  import { sha256 } from './codec.js';
5
5
  export const dependencySchema = z.object({
@@ -40,14 +40,22 @@ async function fingerprint(path) {
40
40
  : 'unavailable';
41
41
  }
42
42
  }
43
+ /** Bindings captured on Windows before paths were stored in portable form. */
44
+ export function portableDependencyPath(path, platform) {
45
+ return platform === 'win32' ? path.replaceAll('\\', '/') : path;
46
+ }
43
47
  export async function captureDependencies(cwd, paths) {
44
48
  const root = resolve(cwd);
45
49
  const files = [];
46
50
  const unavailable = [];
47
51
  for (const input of [...new Set([...paths.slice(0, 24), ...manifests])]) {
48
- const path = relative(root, resolve(root, input));
49
- if (!path || path.startsWith('..') || isAbsolute(path))
52
+ const native = relative(root, resolve(root, input));
53
+ if (!native || native.startsWith('..') || isAbsolute(native))
50
54
  continue;
55
+ // Stored in portable form: Windows `relative()` yields backslashes, which
56
+ // broke comparisons against `/`-joined tool paths and data moved between
57
+ // machines (portable MINIMAL_AGENT_HOME). resolve() accepts both on Windows.
58
+ const path = native.split(sep).join('/');
51
59
  const hash = await fingerprint(resolve(root, path));
52
60
  if (hash === 'unavailable')
53
61
  unavailable.push(path);
@@ -1,5 +1,5 @@
1
1
  import { readFile, stat } from 'node:fs/promises';
2
- import { isAbsolute, relative, resolve } from 'node:path';
2
+ import { isAbsolute, relative, resolve, sep } from 'node:path';
3
3
  import { z } from 'zod';
4
4
  import { sha256 } from './codec.js';
5
5
 
@@ -42,6 +42,10 @@ async function fingerprint(path: string): Promise<string> {
42
42
  : 'unavailable';
43
43
  }
44
44
  }
45
+ /** Bindings captured on Windows before paths were stored in portable form. */
46
+ export function portableDependencyPath(path: string, platform: string): string {
47
+ return platform === 'win32' ? path.replaceAll('\\', '/') : path;
48
+ }
45
49
  export async function captureDependencies(
46
50
  cwd: string,
47
51
  paths: string[],
@@ -50,8 +54,12 @@ export async function captureDependencies(
50
54
  const files: Dependencies['files'] = [];
51
55
  const unavailable: string[] = [];
52
56
  for (const input of [...new Set([...paths.slice(0, 24), ...manifests])]) {
53
- const path = relative(root, resolve(root, input));
54
- if (!path || path.startsWith('..') || isAbsolute(path)) continue;
57
+ const native = relative(root, resolve(root, input));
58
+ if (!native || native.startsWith('..') || isAbsolute(native)) continue;
59
+ // Stored in portable form: Windows `relative()` yields backslashes, which
60
+ // broke comparisons against `/`-joined tool paths and data moved between
61
+ // machines (portable MINIMAL_AGENT_HOME). resolve() accepts both on Windows.
62
+ const path = native.split(sep).join('/');
55
63
  const hash = await fingerprint(resolve(root, path));
56
64
  if (hash === 'unavailable') unavailable.push(path);
57
65
  else files.push({ path, hash });
@@ -8,6 +8,8 @@ import { memoryTerms } from './retrieval.js';
8
8
  import { indexEpisodeTimes } from './episodeTimeIndex.js';
9
9
  import { BM25_DOCUMENT_THRESHOLD, Bm25Index, recordSearchIndexBuild, recordSearchIndexCacheHit, recordSearchIndexFallback, recordSearchStrategy, } from './searchIndex.js';
10
10
  const MAX_EPISODE_CHARS = 8_000;
11
+ /** A longer message keeps this many leading and trailing episodes, never the whole. */
12
+ const KEPT_EDGE_PARTS = 2;
11
13
  const MAX_SEARCH_FILES = 200;
12
14
  const MAX_SEARCH_LINES = 20_000;
13
15
  const MAX_SNIPPET_CHARS = 500;
@@ -86,37 +88,72 @@ function backfillMarkerPath(workspaceId) {
86
88
  function workspaceJournalLockPath(workspaceId) {
87
89
  return join(episodeRoot(), `.workspace-${validPart(workspaceId)}.journal`);
88
90
  }
89
- export function sanitizeEpisodeContent(content) {
91
+ export function sanitizeEpisodeContent(content, maxChars = MAX_EPISODE_CHARS) {
90
92
  const normalized = content
91
93
  .replace(/data:[^;\s]+;base64,[A-Za-z0-9+/=]{80,}/g, '[binary omitted]')
92
94
  .replace(/-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/gi, '[secret omitted]')
93
95
  .trim()
94
- .slice(0, MAX_EPISODE_CHARS);
96
+ .slice(0, maxChars);
95
97
  if (!normalized || containsForbiddenSecret(normalized))
96
98
  return null;
97
99
  return normalized;
98
100
  }
101
+ /**
102
+ * One message → consecutive episodes of at most MAX_EPISODE_CHARS each. The
103
+ * per-episode cap is what bounds search memory (the file cache is counted in
104
+ * episodes), so it stays; but cutting the message itself at the cap lost the
105
+ * end of long discussions, which is where the decision, the correction and the
106
+ * acceptance conclusion are. A message longer than the kept edges stores its
107
+ * head and its tail and says how much of the middle is gone.
108
+ */
109
+ export function splitEpisodeContent(content) {
110
+ if (content.length <= MAX_EPISODE_CHARS)
111
+ return [{ text: content }];
112
+ const parts = [];
113
+ for (let at = 0; at < content.length; at += MAX_EPISODE_CHARS)
114
+ parts.push(content.slice(at, at + MAX_EPISODE_CHARS));
115
+ const edges = KEPT_EDGE_PARTS * 2;
116
+ if (parts.length <= edges)
117
+ return parts.map((text, index) => ({ text, part: { index, total: parts.length } }));
118
+ const kept = [...parts.slice(0, KEPT_EDGE_PARTS), ...parts.slice(-KEPT_EDGE_PARTS)];
119
+ const omittedChars = content.length - kept.reduce((sum, text) => sum + text.length, 0);
120
+ return kept.map((text, index) => ({
121
+ text,
122
+ part: { index, total: kept.length, ...(index === KEPT_EDGE_PARTS ? { omittedChars } : {}) },
123
+ }));
124
+ }
125
+ /** Keeps the head and tail of a pathologically large message for the splitter. */
126
+ const MAX_MESSAGE_INTAKE_CHARS = 2_000_000;
99
127
  export async function appendConversationEpisode(input, signal) {
100
128
  signal?.throwIfAborted();
101
- const content = sanitizeEpisodeContent(input.content);
129
+ // Head + tail of an absurd paste are enough for the splitter to keep both edges.
130
+ const raw = input.content.length > MAX_MESSAGE_INTAKE_CHARS
131
+ ? `${input.content.slice(0, MAX_MESSAGE_INTAKE_CHARS / 2)}${input.content.slice(-MAX_MESSAGE_INTAKE_CHARS / 2)}`
132
+ : input.content;
133
+ const content = sanitizeEpisodeContent(raw, Number.POSITIVE_INFINITY);
102
134
  if (!content)
103
135
  return null;
104
136
  const createdAt = input.createdAt ?? Date.now();
105
137
  const messageId = input.messageId || randomUUID();
106
- const episode = {
107
- id: randomUUID(),
108
- workspaceId: input.workspaceId,
109
- sessionId: input.sessionId,
110
- messageId,
111
- role: input.role,
112
- content,
113
- createdAt,
114
- contentHash: sha256(`${input.role}\0${content}`),
115
- };
116
138
  const path = episodePath(input.workspaceId, input.sessionId);
117
139
  await ensurePrivateDir(dirname(path));
118
- await appendPrivateLine(path, JSON.stringify(episode), { signal });
119
- return episode;
140
+ let first = null;
141
+ for (const { text, part } of splitEpisodeContent(content)) {
142
+ const episode = {
143
+ id: randomUUID(),
144
+ workspaceId: input.workspaceId,
145
+ sessionId: input.sessionId,
146
+ messageId,
147
+ role: input.role,
148
+ content: text,
149
+ createdAt,
150
+ contentHash: sha256(`${input.role}\0${text}`),
151
+ ...(part ? { part } : {}),
152
+ };
153
+ await appendPrivateLine(path, JSON.stringify(episode), { signal });
154
+ first ??= episode;
155
+ }
156
+ return first;
120
157
  }
121
158
  /** 删除某工作区(缺省为全部)的会话原文日志。供 Desktop「清空记忆」一并调用。 */
122
159
  export async function clearConversationEpisodes(workspaceId) {
@@ -737,9 +774,13 @@ function rankConversationEpisodes(episodes, query, options, signature, batchIden
737
774
  const bm25 = scores.get(episode.id) ?? 0;
738
775
  const phrase = normalized && episode.content.toLocaleLowerCase().includes(normalized) ? 35 : 0;
739
776
  const roleWeight = episode.role === 'user' ? 4 : 0;
740
- return { episode, bm25, score: bm25 * 10 + phrase + roleWeight + episode.createdAt / 1e13 };
777
+ // Same rule as memory records: BM25 matches whole tokens only, so a
778
+ // substring hit (`postgres` in `postgresql`) that the small-corpus
779
+ // path finds must not vanish once the corpus passes the threshold.
780
+ const substring = bm25 > 0 ? 0 : searchScore(episode, query);
781
+ return { episode, bm25, substring, score: bm25 * 10 + phrase + roleWeight + episode.createdAt / 1e13 + substring };
741
782
  })
742
- .filter((item) => item.bm25 > 0)
783
+ .filter((item) => item.bm25 > 0 || item.substring >= 6)
743
784
  .sort((a, b) => b.score - a.score)
744
785
  .map(({ episode, score }) => conversationResult(episode, score, query));
745
786
  }
@@ -784,7 +825,7 @@ export async function readConversationEpisode(workspaceId, sessionId, episodeId,
784
825
  previousId: file.episodes[index - 1]?.id ?? null,
785
826
  nextId: file.episodes[index + 1]?.id ?? null,
786
827
  sourceKind: 'persisted-conversation-text',
787
- limitation: `这是持久化会话文本,不含工具和图片原文;每条最多保存 ${MAX_EPISODE_CHARS} 字符,不保证完整原始消息。`,
828
+ limitation: `这是持久化会话文本,不含工具和图片原文;超过 ${MAX_EPISODE_CHARS} 字符的消息会拆成相邻的多段(episode.part 标出序号),过长时只保留开头和结尾、part.omittedChars 标出丢掉的中段;不保证完整原始消息。`,
788
829
  };
789
830
  }
790
831
  export const episodicMemoryService = {
@@ -29,6 +29,8 @@ import type {
29
29
  } from './types.js';
30
30
 
31
31
  const MAX_EPISODE_CHARS = 8_000;
32
+ /** A longer message keeps this many leading and trailing episodes, never the whole. */
33
+ const KEPT_EDGE_PARTS = 2;
32
34
  const MAX_SEARCH_FILES = 200;
33
35
  const MAX_SEARCH_LINES = 20_000;
34
36
  const MAX_SNIPPET_CHARS = 500;
@@ -142,7 +144,7 @@ function workspaceJournalLockPath(workspaceId: string): string {
142
144
  return join(episodeRoot(), `.workspace-${validPart(workspaceId)}.journal`);
143
145
  }
144
146
 
145
- export function sanitizeEpisodeContent(content: string): string | null {
147
+ export function sanitizeEpisodeContent(content: string, maxChars = MAX_EPISODE_CHARS): string | null {
146
148
  const normalized = content
147
149
  .replace(/data:[^;\s]+;base64,[A-Za-z0-9+/=]{80,}/g, '[binary omitted]')
148
150
  .replace(
@@ -150,11 +152,36 @@ export function sanitizeEpisodeContent(content: string): string | null {
150
152
  '[secret omitted]',
151
153
  )
152
154
  .trim()
153
- .slice(0, MAX_EPISODE_CHARS);
155
+ .slice(0, maxChars);
154
156
  if (!normalized || containsForbiddenSecret(normalized)) return null;
155
157
  return normalized;
156
158
  }
157
159
 
160
+ /**
161
+ * One message → consecutive episodes of at most MAX_EPISODE_CHARS each. The
162
+ * per-episode cap is what bounds search memory (the file cache is counted in
163
+ * episodes), so it stays; but cutting the message itself at the cap lost the
164
+ * end of long discussions, which is where the decision, the correction and the
165
+ * acceptance conclusion are. A message longer than the kept edges stores its
166
+ * head and its tail and says how much of the middle is gone.
167
+ */
168
+ export function splitEpisodeContent(content: string): { text: string; part?: ConversationEpisode['part'] }[] {
169
+ if (content.length <= MAX_EPISODE_CHARS) return [{ text: content }];
170
+ const parts: string[] = [];
171
+ for (let at = 0; at < content.length; at += MAX_EPISODE_CHARS) parts.push(content.slice(at, at + MAX_EPISODE_CHARS));
172
+ const edges = KEPT_EDGE_PARTS * 2;
173
+ if (parts.length <= edges) return parts.map((text, index) => ({ text, part: { index, total: parts.length } }));
174
+ const kept = [...parts.slice(0, KEPT_EDGE_PARTS), ...parts.slice(-KEPT_EDGE_PARTS)];
175
+ const omittedChars = content.length - kept.reduce((sum, text) => sum + text.length, 0);
176
+ return kept.map((text, index) => ({
177
+ text,
178
+ part: { index, total: kept.length, ...(index === KEPT_EDGE_PARTS ? { omittedChars } : {}) },
179
+ }));
180
+ }
181
+
182
+ /** Keeps the head and tail of a pathologically large message for the splitter. */
183
+ const MAX_MESSAGE_INTAKE_CHARS = 2_000_000;
184
+
158
185
  export async function appendConversationEpisode(input: {
159
186
  workspaceId: string;
160
187
  sessionId: string;
@@ -164,24 +191,33 @@ export async function appendConversationEpisode(input: {
164
191
  createdAt?: number;
165
192
  }, signal?: AbortSignal): Promise<ConversationEpisode | null> {
166
193
  signal?.throwIfAborted();
167
- const content = sanitizeEpisodeContent(input.content);
194
+ // Head + tail of an absurd paste are enough for the splitter to keep both edges.
195
+ const raw = input.content.length > MAX_MESSAGE_INTAKE_CHARS
196
+ ? `${input.content.slice(0, MAX_MESSAGE_INTAKE_CHARS / 2)}${input.content.slice(-MAX_MESSAGE_INTAKE_CHARS / 2)}`
197
+ : input.content;
198
+ const content = sanitizeEpisodeContent(raw, Number.POSITIVE_INFINITY);
168
199
  if (!content) return null;
169
200
  const createdAt = input.createdAt ?? Date.now();
170
201
  const messageId = input.messageId || randomUUID();
171
- const episode: ConversationEpisode = {
172
- id: randomUUID(),
173
- workspaceId: input.workspaceId,
174
- sessionId: input.sessionId,
175
- messageId,
176
- role: input.role,
177
- content,
178
- createdAt,
179
- contentHash: sha256(`${input.role}\0${content}`),
180
- };
181
202
  const path = episodePath(input.workspaceId, input.sessionId);
182
203
  await ensurePrivateDir(dirname(path));
183
- await appendPrivateLine(path, JSON.stringify(episode), { signal });
184
- return episode;
204
+ let first: ConversationEpisode | null = null;
205
+ for (const { text, part } of splitEpisodeContent(content)) {
206
+ const episode: ConversationEpisode = {
207
+ id: randomUUID(),
208
+ workspaceId: input.workspaceId,
209
+ sessionId: input.sessionId,
210
+ messageId,
211
+ role: input.role,
212
+ content: text,
213
+ createdAt,
214
+ contentHash: sha256(`${input.role}\0${text}`),
215
+ ...(part ? { part } : {}),
216
+ };
217
+ await appendPrivateLine(path, JSON.stringify(episode), { signal });
218
+ first ??= episode;
219
+ }
220
+ return first;
185
221
  }
186
222
 
187
223
  /** 删除某工作区(缺省为全部)的会话原文日志。供 Desktop「清空记忆」一并调用。 */
@@ -858,9 +894,13 @@ function rankConversationEpisodes(episodes: ConversationEpisode[], query: string
858
894
  const bm25 = scores.get(episode.id) ?? 0;
859
895
  const phrase = normalized && episode.content.toLocaleLowerCase().includes(normalized) ? 35 : 0;
860
896
  const roleWeight = episode.role === 'user' ? 4 : 0;
861
- return { episode, bm25, score: bm25 * 10 + phrase + roleWeight + episode.createdAt / 1e13 };
897
+ // Same rule as memory records: BM25 matches whole tokens only, so a
898
+ // substring hit (`postgres` in `postgresql`) that the small-corpus
899
+ // path finds must not vanish once the corpus passes the threshold.
900
+ const substring = bm25 > 0 ? 0 : searchScore(episode, query);
901
+ return { episode, bm25, substring, score: bm25 * 10 + phrase + roleWeight + episode.createdAt / 1e13 + substring };
862
902
  })
863
- .filter((item) => item.bm25 > 0)
903
+ .filter((item) => item.bm25 > 0 || item.substring >= 6)
864
904
  .sort((a, b) => b.score - a.score)
865
905
  .map(({ episode, score }) => conversationResult(episode, score, query));
866
906
  } catch {
@@ -908,7 +948,7 @@ export async function readConversationEpisode(workspaceId: string, sessionId: st
908
948
  previousId: file.episodes[index - 1]?.id ?? null,
909
949
  nextId: file.episodes[index + 1]?.id ?? null,
910
950
  sourceKind: 'persisted-conversation-text' as const,
911
- limitation: `这是持久化会话文本,不含工具和图片原文;每条最多保存 ${MAX_EPISODE_CHARS} 字符,不保证完整原始消息。`,
951
+ limitation: `这是持久化会话文本,不含工具和图片原文;超过 ${MAX_EPISODE_CHARS} 字符的消息会拆成相邻的多段(episode.part 标出序号),过长时只保留开头和结尾、part.omittedChars 标出丢掉的中段;不保证完整原始消息。`,
912
952
  };
913
953
  }
914
954
 
@@ -8,7 +8,7 @@ import { readFile, readdir, rm } from 'node:fs/promises';
8
8
  import { join } from 'node:path';
9
9
  import { z } from 'zod';
10
10
  import { containsForbiddenSecret, sha256 } from './codec.js';
11
- import { checkDependencies, dependencySchema } from './dependencies.js';
11
+ import { checkDependencies, dependencySchema, portableDependencyPath } from './dependencies.js';
12
12
  import { acquireFileLock, atomicWriteFile, scopeDirectory, } from './fileStore.js';
13
13
  import { parseProjectKnowledgeSnapshot, projectKnowledgeKindSchema, restoreProjectKnowledgeSnapshot, saveProjectKnowledgeBatch, snapshotProjectKnowledge, } from './projectKnowledge.js';
14
14
  import { memoryTerms, takeTokenBudget } from './retrieval.js';
@@ -693,9 +693,10 @@ export async function recallExperiences(workspaceId, query, limit = 5) {
693
693
  const groups = new Map();
694
694
  for (const binding of bindings) {
695
695
  const previous = groups.get(binding.root);
696
- const files = new Map(previous?.files.map((file) => [file.path, file]) ?? []);
696
+ const key = (path) => portableDependencyPath(path, binding.platform);
697
+ const files = new Map(previous?.files.map((file) => [key(file.path), file]) ?? []);
697
698
  for (const file of binding.files)
698
- files.set(file.path, file);
699
+ files.set(key(file.path), file);
699
700
  groups.set(binding.root, { ...binding, files: [...files.values()] });
700
701
  }
701
702
  return {
@@ -8,7 +8,7 @@ import { readFile, readdir, rm } from 'node:fs/promises';
8
8
  import { join } from 'node:path';
9
9
  import { z } from 'zod';
10
10
  import { containsForbiddenSecret, sha256 } from './codec.js';
11
- import { checkDependencies, dependencySchema } from './dependencies.js';
11
+ import { checkDependencies, dependencySchema, portableDependencyPath } from './dependencies.js';
12
12
  import {
13
13
  acquireFileLock,
14
14
  atomicWriteFile,
@@ -873,10 +873,11 @@ export async function recallExperiences(
873
873
  const groups = new Map<string, (typeof bindings)[number]>();
874
874
  for (const binding of bindings) {
875
875
  const previous = groups.get(binding.root);
876
+ const key = (path: string) => portableDependencyPath(path, binding.platform);
876
877
  const files = new Map(
877
- previous?.files.map((file) => [file.path, file]) ?? [],
878
+ previous?.files.map((file) => [key(file.path), file]) ?? [],
878
879
  );
879
- for (const file of binding.files) files.set(file.path, file);
880
+ for (const file of binding.files) files.set(key(file.path), file);
880
881
  groups.set(binding.root, { ...binding, files: [...files.values()] });
881
882
  }
882
883
  return {
@@ -75,6 +75,56 @@ export async function retainFactCandidate(workspaceId, proposed, reason, old, st
75
75
  return persist();
76
76
  });
77
77
  }
78
+ /**
79
+ * A change candidate proposes replacing one exact statement. Once the record no
80
+ * longer says that — the user corrected it (to this proposal or anything else)
81
+ * or forgot it — the candidate is settled history, not a pending change.
82
+ * Surfacing it anyway kept "旧=npm" in every prompt and flagged the corrected
83
+ * record as unconfirmed. This is the single liveness rule for prompts, status
84
+ * and management views: it holds even when the correction happened in another
85
+ * workspace, where the candidate file itself cannot be reached.
86
+ */
87
+ export function isLiveFactCandidate(item, statements) {
88
+ if (item.status !== 'open')
89
+ return false;
90
+ if (!item.old)
91
+ return true;
92
+ const current = statements.get(item.old.id);
93
+ return (current !== undefined &&
94
+ current === item.old.statement &&
95
+ current !== item.proposed.statement);
96
+ }
97
+ /**
98
+ * Persist the rule above after a record changed. A reconfirmation that keeps
99
+ * the statement only moves the version: refresh it so accept still works.
100
+ */
101
+ export async function settleFactCandidatesFor(workspaceId, record, version) {
102
+ const release = await acquireFileLock(pathFor(workspaceId));
103
+ try {
104
+ const items = await factCandidates(workspaceId);
105
+ let changed = false;
106
+ for (const item of items) {
107
+ if (item.status !== 'open' || item.old?.id !== record.id)
108
+ continue;
109
+ if (record.statement === item.proposed.statement)
110
+ item.status = 'accepted';
111
+ else if (record.statement !== item.old.statement) {
112
+ item.status = 'rejected';
113
+ item.reason = `${item.reason};原记录已被改写为其他内容,候选失效`;
114
+ }
115
+ else if (item.old.version !== version)
116
+ item.old.version = version;
117
+ else
118
+ continue;
119
+ changed = true;
120
+ }
121
+ if (changed)
122
+ await atomicWriteFile(pathFor(workspaceId), JSON.stringify(items) + '\n');
123
+ }
124
+ finally {
125
+ await release();
126
+ }
127
+ }
78
128
  /**
79
129
  * Forgetting a record must take every candidate hanging off it too — open or
80
130
  * resolved — because each one stores the record's statement in plaintext.
@@ -125,7 +175,9 @@ export async function resolveFactCandidate(workspaceId, id, decision, signal) {
125
175
  current.record.sourceMessageId === item.proposed.sourceMessageId &&
126
176
  current.record.validUntil === item.proposed.validUntil;
127
177
  if (!applied) {
128
- if (current.version !== item.old.version)
178
+ // A candidate replaces a statement, not a version: reconfirming the
179
+ // same statement elsewhere must not make the proposal unacceptable.
180
+ if (current.record.statement !== item.old.statement)
129
181
  throw new Error('MEMORY_VERSION_CONFLICT:原事实已改变,请重新评估');
130
182
  const updated = await memoryService.updateWithVersion(current.record.id, current.record.scope, current.version, {
131
183
  ...item.proposed,
@@ -103,6 +103,57 @@ export async function retainFactCandidate(
103
103
  return persist();
104
104
  });
105
105
  }
106
+ /**
107
+ * A change candidate proposes replacing one exact statement. Once the record no
108
+ * longer says that — the user corrected it (to this proposal or anything else)
109
+ * or forgot it — the candidate is settled history, not a pending change.
110
+ * Surfacing it anyway kept "旧=npm" in every prompt and flagged the corrected
111
+ * record as unconfirmed. This is the single liveness rule for prompts, status
112
+ * and management views: it holds even when the correction happened in another
113
+ * workspace, where the candidate file itself cannot be reached.
114
+ */
115
+ export function isLiveFactCandidate(
116
+ item: FactCandidate,
117
+ statements: ReadonlyMap<string, string>,
118
+ ): boolean {
119
+ if (item.status !== 'open') return false;
120
+ if (!item.old) return true;
121
+ const current = statements.get(item.old.id);
122
+ return (
123
+ current !== undefined &&
124
+ current === item.old.statement &&
125
+ current !== item.proposed.statement
126
+ );
127
+ }
128
+ /**
129
+ * Persist the rule above after a record changed. A reconfirmation that keeps
130
+ * the statement only moves the version: refresh it so accept still works.
131
+ */
132
+ export async function settleFactCandidatesFor(
133
+ workspaceId: string,
134
+ record: { id: string; statement: string },
135
+ version: string,
136
+ ) {
137
+ const release = await acquireFileLock(pathFor(workspaceId));
138
+ try {
139
+ const items = await factCandidates(workspaceId);
140
+ let changed = false;
141
+ for (const item of items) {
142
+ if (item.status !== 'open' || item.old?.id !== record.id) continue;
143
+ if (record.statement === item.proposed.statement) item.status = 'accepted';
144
+ else if (record.statement !== item.old.statement) {
145
+ item.status = 'rejected';
146
+ item.reason = `${item.reason};原记录已被改写为其他内容,候选失效`;
147
+ } else if (item.old.version !== version) item.old.version = version;
148
+ else continue;
149
+ changed = true;
150
+ }
151
+ if (changed)
152
+ await atomicWriteFile(pathFor(workspaceId), JSON.stringify(items) + '\n');
153
+ } finally {
154
+ await release();
155
+ }
156
+ }
106
157
  /**
107
158
  * Forgetting a record must take every candidate hanging off it too — open or
108
159
  * resolved — because each one stores the record's statement in plaintext.
@@ -165,7 +216,9 @@ export async function resolveFactCandidate(
165
216
  current.record.sourceMessageId === item.proposed.sourceMessageId &&
166
217
  current.record.validUntil === item.proposed.validUntil;
167
218
  if (!applied) {
168
- if (current.version !== item.old.version)
219
+ // A candidate replaces a statement, not a version: reconfirming the
220
+ // same statement elsewhere must not make the proposal unacceptable.
221
+ if (current.record.statement !== item.old.statement)
169
222
  throw new Error(
170
223
  'MEMORY_VERSION_CONFLICT:原事实已改变,请重新评估',
171
224
  );
@@ -2,7 +2,7 @@
2
2
  * its source material. A durable receipt makes retries idempotent. */
3
3
  import { readFile } from 'node:fs/promises';
4
4
  import { parseMemoryProposal } from './proposal.js';
5
- import { failureSchema, jobEligible, recordJobFailure, recordBackgroundLearningFailure } from './jobPolicy.js';
5
+ import { failureSchema, isPausedJob, jobEligible, recordJobFailure, recordBackgroundLearningFailure } from './jobPolicy.js';
6
6
  import { join } from 'node:path';
7
7
  import lockfile from 'proper-lockfile';
8
8
  import { z } from 'zod';
@@ -446,7 +446,7 @@ export function scheduleFactLearning(options) {
446
446
  !settings.autoRemember ||
447
447
  !settings.factLearning)
448
448
  return;
449
- const pending = (await factLearningStatus(options.workspaceId)).pending.filter(turn => !turn.failure?.paused);
449
+ const pending = (await factLearningStatus(options.workspaceId)).pending.filter(turn => !isPausedJob(turn));
450
450
  if (cancelled.has(directory(options.workspaceId)) ||
451
451
  running.has(directory(options.workspaceId)))
452
452
  return;
@@ -2,7 +2,7 @@
2
2
  * its source material. A durable receipt makes retries idempotent. */
3
3
  import { readFile } from 'node:fs/promises';
4
4
  import { parseMemoryProposal } from './proposal.js';
5
- import { failureSchema, jobEligible, recordJobFailure, recordBackgroundLearningFailure } from './jobPolicy.js';
5
+ import { failureSchema, isPausedJob, jobEligible, recordJobFailure, recordBackgroundLearningFailure } from './jobPolicy.js';
6
6
  import { join } from 'node:path';
7
7
  import lockfile from 'proper-lockfile';
8
8
  import { z } from 'zod';
@@ -585,7 +585,7 @@ export function scheduleFactLearning(
585
585
  !settings.factLearning
586
586
  )
587
587
  return;
588
- const pending = (await factLearningStatus(options.workspaceId)).pending.filter(turn => !turn.failure?.paused);
588
+ const pending = (await factLearningStatus(options.workspaceId)).pending.filter(turn => !isPausedJob(turn));
589
589
  if (
590
590
  cancelled.has(directory(options.workspaceId)) ||
591
591
  running.has(directory(options.workspaceId))
@@ -4,24 +4,43 @@ import { retryAt } from './maintenanceQueue.js';
4
4
  export const failureSchema = z.object({
5
5
  kind: z.enum(['proposal', 'transient', 'conflict', 'unknown']),
6
6
  consecutive: z.number().int().nonnegative(),
7
+ /** Consecutive invalid proposals only; the pause looks at this, not `consecutive`. */
8
+ proposalStreak: z.number().int().nonnegative().optional(),
7
9
  paused: z.boolean(),
8
10
  });
9
- /** Legacy attempt counters are retained, but do not silently pause old work. */
11
+ /**
12
+ * Only a source that keeps producing invalid proposals — three in a row — is
13
+ * parked: that failure reproduces with the same input, so retrying burns calls
14
+ * without progress. Other failures interleaved in the streak reset it; an
15
+ * outage followed by one bad answer is not a poisoned source.
16
+ * Timeouts, rate limits, network, conflicts and unknown errors only back off
17
+ * (capped at one hour by retryAt) and never pause. Pausing them used to recreate
18
+ * the incident the attempt cap caused: a ~16-minute provider outage parked
19
+ * every pending source until someone ran maintenance by hand, and TUI users
20
+ * get no terminal notice. Legacy attempt counters never pause old work.
21
+ */
10
22
  export function recordJobFailure(job, error) {
11
23
  const message = error instanceof Error ? error.message : '';
12
24
  const kind = error instanceof MemoryProposalError ? 'proposal'
13
25
  : /VERSION_CONFLICT|SOURCE_CHANGED/.test(message) ? 'conflict'
14
26
  : /timeout|超时|429|503|fetch|network|ECONN|连接/i.test(message) ? 'transient'
15
27
  : 'unknown';
16
- // Changing failure categories is not recovery. Otherwise alternating provider
17
- // and proposal failures can keep a source retrying forever.
28
+ // Changing failure categories is not recovery: backoff keeps growing across
29
+ // kinds, so alternating failures cannot reset it.
18
30
  const consecutive = (job.failure?.consecutive ?? 0) + 1;
31
+ const previous = job.failure?.proposalStreak ?? (job.failure?.kind === 'proposal' ? 1 : 0);
32
+ const proposalStreak = kind === 'proposal' ? previous + 1 : 0;
19
33
  job.attempts = (job.attempts ?? 0) + 1;
20
- job.failure = { kind, consecutive, paused: Boolean(job.failure?.paused) || consecutive >= (kind === 'proposal' ? 3 : 6) };
34
+ job.failure = { kind, consecutive, proposalStreak, paused: proposalStreak >= 3 };
21
35
  job.nextAttemptAt = retryAt(consecutive);
22
36
  }
37
+ /** Honors only proposal pauses, so sources parked by the old policy for
38
+ * transient errors resume on their own after upgrade. */
39
+ export function isPausedJob(job) {
40
+ return job.failure?.paused === true && job.failure.kind === 'proposal';
41
+ }
23
42
  export function jobEligible(job, force = false, now = Date.now()) {
24
- return force || (!job.failure?.paused && (job.nextAttemptAt ?? 0) <= now);
43
+ return force || (!isPausedJob(job) && (job.nextAttemptAt ?? 0) <= now);
25
44
  }
26
45
  const health = new Map();
27
46
  /** Background services never write over a host's TUI. Durable queues retain
@@ -5,27 +5,47 @@ import { retryAt } from './maintenanceQueue.js';
5
5
  export const failureSchema = z.object({
6
6
  kind: z.enum(['proposal', 'transient', 'conflict', 'unknown']),
7
7
  consecutive: z.number().int().nonnegative(),
8
+ /** Consecutive invalid proposals only; the pause looks at this, not `consecutive`. */
9
+ proposalStreak: z.number().int().nonnegative().optional(),
8
10
  paused: z.boolean(),
9
11
  });
10
12
  type Job = { attempts?: number; nextAttemptAt?: number; failure?: z.infer<typeof failureSchema> };
11
13
 
12
- /** Legacy attempt counters are retained, but do not silently pause old work. */
14
+ /**
15
+ * Only a source that keeps producing invalid proposals — three in a row — is
16
+ * parked: that failure reproduces with the same input, so retrying burns calls
17
+ * without progress. Other failures interleaved in the streak reset it; an
18
+ * outage followed by one bad answer is not a poisoned source.
19
+ * Timeouts, rate limits, network, conflicts and unknown errors only back off
20
+ * (capped at one hour by retryAt) and never pause. Pausing them used to recreate
21
+ * the incident the attempt cap caused: a ~16-minute provider outage parked
22
+ * every pending source until someone ran maintenance by hand, and TUI users
23
+ * get no terminal notice. Legacy attempt counters never pause old work.
24
+ */
13
25
  export function recordJobFailure(job: Job, error: unknown): void {
14
26
  const message = error instanceof Error ? error.message : '';
15
27
  const kind = error instanceof MemoryProposalError ? 'proposal'
16
28
  : /VERSION_CONFLICT|SOURCE_CHANGED/.test(message) ? 'conflict'
17
29
  : /timeout|超时|429|503|fetch|network|ECONN|连接/i.test(message) ? 'transient'
18
30
  : 'unknown';
19
- // Changing failure categories is not recovery. Otherwise alternating provider
20
- // and proposal failures can keep a source retrying forever.
31
+ // Changing failure categories is not recovery: backoff keeps growing across
32
+ // kinds, so alternating failures cannot reset it.
21
33
  const consecutive = (job.failure?.consecutive ?? 0) + 1;
34
+ const previous = job.failure?.proposalStreak ?? (job.failure?.kind === 'proposal' ? 1 : 0);
35
+ const proposalStreak = kind === 'proposal' ? previous + 1 : 0;
22
36
  job.attempts = (job.attempts ?? 0) + 1;
23
- job.failure = { kind, consecutive, paused: Boolean(job.failure?.paused) || consecutive >= (kind === 'proposal' ? 3 : 6) };
37
+ job.failure = { kind, consecutive, proposalStreak, paused: proposalStreak >= 3 };
24
38
  job.nextAttemptAt = retryAt(consecutive);
25
39
  }
26
40
 
41
+ /** Honors only proposal pauses, so sources parked by the old policy for
42
+ * transient errors resume on their own after upgrade. */
43
+ export function isPausedJob(job: Job): boolean {
44
+ return job.failure?.paused === true && job.failure.kind === 'proposal';
45
+ }
46
+
27
47
  export function jobEligible(job: Job, force = false, now = Date.now()): boolean {
28
- return force || (!job.failure?.paused && (job.nextAttemptAt ?? 0) <= now);
48
+ return force || (!isPausedJob(job) && (job.nextAttemptAt ?? 0) <= now);
29
49
  }
30
50
 
31
51
  type Partition = 'facts' | 'experience';