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
@@ -5,7 +5,7 @@
5
5
  import { randomUUID } from 'node:crypto';
6
6
  import { parseMemoryProposal } from './proposal.js';
7
7
  import { acquireModelSlot, MAX_MODEL_DURATION_MS } from './modelBudget.js';
8
- import { jobEligible, recordJobFailure, recordBackgroundLearningFailure } from './jobPolicy.js';
8
+ import { isPausedJob, jobEligible, recordJobFailure, recordBackgroundLearningFailure } from './jobPolicy.js';
9
9
  import { join } from 'node:path';
10
10
  import lockfile from 'proper-lockfile';
11
11
  import { ensurePrivateDir } from './fileStore.js';
@@ -349,7 +349,7 @@ export function scheduleMemoryMaintenance(options) {
349
349
  if (cancelled.has(experienceDirectory(options.workspaceId)) || options.signal?.aborted || !settings.autoRemember || !settings.learning?.enabled)
350
350
  return;
351
351
  const store = await readExperienceStore(options.workspaceId);
352
- const pending = store.pending.filter(turn => !turn.failure?.paused);
352
+ const pending = store.pending.filter(turn => !isPausedJob(turn));
353
353
  const continuing = pending.some(turn => turn.createdAt <= (store.runs.at(-1)?.startedAt ?? 0));
354
354
  if (!pending.length || (!continuing && pending.length < settings.learning.minPendingTurns))
355
355
  return;
@@ -5,7 +5,7 @@
5
5
  import { randomUUID } from 'node:crypto';
6
6
  import { parseMemoryProposal } from './proposal.js';
7
7
  import { acquireModelSlot, MAX_MODEL_DURATION_MS } from './modelBudget.js';
8
- import { jobEligible, recordJobFailure, recordBackgroundLearningFailure } from './jobPolicy.js';
8
+ import { isPausedJob, jobEligible, recordJobFailure, recordBackgroundLearningFailure } from './jobPolicy.js';
9
9
  import { join } from 'node:path';
10
10
  import lockfile from 'proper-lockfile';
11
11
  import { ensurePrivateDir } from './fileStore.js';
@@ -428,7 +428,7 @@ export function scheduleMemoryMaintenance(
428
428
  const settings = await memoryService.settings();
429
429
  if (cancelled.has(experienceDirectory(options.workspaceId)) || options.signal?.aborted || !settings.autoRemember || !settings.learning?.enabled) return;
430
430
  const store = await readExperienceStore(options.workspaceId);
431
- const pending = store.pending.filter(turn => !turn.failure?.paused);
431
+ const pending = store.pending.filter(turn => !isPausedJob(turn));
432
432
  const continuing = pending.some(turn => turn.createdAt <= (store.runs.at(-1)?.startedAt ?? 0));
433
433
  if (!pending.length || (!continuing && pending.length < settings.learning.minPendingTurns)) return;
434
434
  const next = Math.max(continuing ? 0 : store.lastSuccessAt + settings.learning.minIntervalHours * 3600000,
@@ -5,6 +5,7 @@ export function createLearningModel(provider, execute = chat) {
5
5
  return async (prompt, signal, onProgress) => {
6
6
  let text = '';
7
7
  let finished = false;
8
+ let stopReason;
8
9
  let usage;
9
10
  for await (const event of execute({
10
11
  provider,
@@ -35,6 +36,7 @@ export function createLearningModel(provider, execute = chat) {
35
36
  if (event.type === 'interrupted')
36
37
  throw new DOMException('记忆整理已中断', 'AbortError');
37
38
  if (event.type === 'done') {
39
+ stopReason = event.stopReason;
38
40
  finished = event.stopReason === 'stop';
39
41
  usage = {
40
42
  promptTokens: event.promptTokens,
@@ -43,6 +45,11 @@ export function createLearningModel(provider, execute = chat) {
43
45
  }
44
46
  }
45
47
  signal.throwIfAborted();
48
+ // No end marker is a dropped connection (the client synthesizes
49
+ // stopReason 'unknown'), not something the model said. Classifying it as
50
+ // an invalid proposal let three network blips park a source for good.
51
+ if (stopReason === undefined || stopReason === 'unknown')
52
+ throw new Error('记忆整理模型连接中断(流未正常结束),保留待处理资料');
46
53
  if (!finished)
47
54
  throw new MemoryProposalError('format', '记忆整理模型未正常完成(截断或缺少正常结束标记),保留待处理资料');
48
55
  if (!text.trim())
@@ -10,6 +10,7 @@ export function createLearningModel(
10
10
  return async (prompt, signal, onProgress) => {
11
11
  let text = '';
12
12
  let finished = false;
13
+ let stopReason: string | undefined;
13
14
  let usage: { promptTokens?: number; outputTokens?: number } | undefined;
14
15
  for await (const event of execute({
15
16
  provider,
@@ -41,6 +42,7 @@ export function createLearningModel(
41
42
  if (event.type === 'interrupted')
42
43
  throw new DOMException('记忆整理已中断', 'AbortError');
43
44
  if (event.type === 'done') {
45
+ stopReason = event.stopReason;
44
46
  finished = event.stopReason === 'stop';
45
47
  usage = {
46
48
  promptTokens: event.promptTokens,
@@ -49,6 +51,11 @@ export function createLearningModel(
49
51
  }
50
52
  }
51
53
  signal.throwIfAborted();
54
+ // No end marker is a dropped connection (the client synthesizes
55
+ // stopReason 'unknown'), not something the model said. Classifying it as
56
+ // an invalid proposal let three network blips park a source for good.
57
+ if (stopReason === undefined || stopReason === 'unknown')
58
+ throw new Error('记忆整理模型连接中断(流未正常结束),保留待处理资料');
52
59
  if (!finished)
53
60
  throw new MemoryProposalError('format', '记忆整理模型未正常完成(截断或缺少正常结束标记),保留待处理资料');
54
61
  if (!text.trim())
@@ -1,10 +1,11 @@
1
1
  import { z } from 'zod';
2
2
  import { dismissExperience, readExperienceStore, restoreExperience, undoLearningRun, } from './experienceStore.js';
3
- import { factCandidates, resolveFactCandidate } from './factCandidates.js';
3
+ import { factCandidates, isLiveFactCandidate, resolveFactCandidate, } from './factCandidates.js';
4
+ import { isPausedJob } from './jobPolicy.js';
4
5
  import { cancelFactLearning, factLearningStatus, runFactLearning, } from './factLearning.js';
5
6
  import { cancelLearning, persistedLearningStatus, runMemoryMaintenance, } from './learning.js';
6
7
  import { memoryService } from './memoryService.js';
7
- import { dismissProjectKnowledge, projectKnowledgeStatus, resolveProjectKnowledgeCandidate, restoreProjectKnowledge, } from './projectKnowledge.js';
8
+ import { dismissProjectKnowledge, isLiveKnowledgeCandidate, projectKnowledgeStatus, resolveProjectKnowledgeCandidate, restoreProjectKnowledge, } from './projectKnowledge.js';
8
9
  export const memoryManagementRequest = z.object({
9
10
  action: z.enum([
10
11
  'status',
@@ -98,18 +99,18 @@ export async function learningManagementSnapshot(workspaceId) {
98
99
  persistedLearningStatus(workspaceId),
99
100
  memoryService.settings(),
100
101
  ]);
101
- const ids = new Set(memories.records.map((record) => record.id));
102
+ const statements = new Map(memories.records.map((record) => [record.id, record.statement]));
102
103
  return {
103
104
  workspaceId,
104
105
  status: status.status,
105
106
  pendingTurns: store.pending.length,
106
- pausedTurns: store.pending.filter(turn => turn.failure?.paused).length,
107
+ pausedTurns: store.pending.filter(isPausedJob).length,
107
108
  experiences: store.items,
108
109
  knowledge: knowledge.records,
109
- knowledgeCandidates: knowledge.candidates.filter((item) => item.status === 'open'),
110
- factCandidates: candidates.filter((item) => item.status === 'open' && (!item.old || ids.has(item.old.id))),
110
+ knowledgeCandidates: knowledge.candidates.filter((item) => isLiveKnowledgeCandidate(item, knowledge.records)),
111
+ factCandidates: candidates.filter((item) => isLiveFactCandidate(item, statements)),
111
112
  factPendingTurns: facts.pending.length,
112
- factPausedTurns: facts.pending.filter(turn => turn.failure?.paused).length,
113
+ factPausedTurns: facts.pending.filter(isPausedJob).length,
113
114
  factReceipts: facts.receipts.slice(-20),
114
115
  runs: store.runs.slice(-20).reverse(),
115
116
  settings,
@@ -5,7 +5,12 @@ import {
5
5
  restoreExperience,
6
6
  undoLearningRun,
7
7
  } from './experienceStore.js';
8
- import { factCandidates, resolveFactCandidate } from './factCandidates.js';
8
+ import {
9
+ factCandidates,
10
+ isLiveFactCandidate,
11
+ resolveFactCandidate,
12
+ } from './factCandidates.js';
13
+ import { isPausedJob } from './jobPolicy.js';
9
14
  import {
10
15
  cancelFactLearning,
11
16
  factLearningStatus,
@@ -20,6 +25,7 @@ import {
20
25
  import { memoryService } from './memoryService.js';
21
26
  import {
22
27
  dismissProjectKnowledge,
28
+ isLiveKnowledgeCandidate,
23
29
  projectKnowledgeStatus,
24
30
  resolveProjectKnowledgeCandidate,
25
31
  restoreProjectKnowledge,
@@ -144,22 +150,24 @@ export async function learningManagementSnapshot(workspaceId: string) {
144
150
  persistedLearningStatus(workspaceId),
145
151
  memoryService.settings(),
146
152
  ]);
147
- const ids = new Set(memories.records.map((record) => record.id));
153
+ const statements = new Map(
154
+ memories.records.map((record) => [record.id, record.statement]),
155
+ );
148
156
  return {
149
157
  workspaceId,
150
158
  status: status.status,
151
159
  pendingTurns: store.pending.length,
152
- pausedTurns: store.pending.filter(turn => turn.failure?.paused).length,
160
+ pausedTurns: store.pending.filter(isPausedJob).length,
153
161
  experiences: store.items,
154
162
  knowledge: knowledge.records,
155
- knowledgeCandidates: knowledge.candidates.filter(
156
- (item) => item.status === 'open',
163
+ knowledgeCandidates: knowledge.candidates.filter((item) =>
164
+ isLiveKnowledgeCandidate(item, knowledge.records),
157
165
  ),
158
- factCandidates: candidates.filter(
159
- (item) => item.status === 'open' && (!item.old || ids.has(item.old.id)),
166
+ factCandidates: candidates.filter((item) =>
167
+ isLiveFactCandidate(item, statements),
160
168
  ),
161
169
  factPendingTurns: facts.pending.length,
162
- factPausedTurns: facts.pending.filter(turn => turn.failure?.paused).length,
170
+ factPausedTurns: facts.pending.filter(isPausedJob).length,
163
171
  factReceipts: facts.receipts.slice(-20),
164
172
  runs: store.runs.slice(-20).reverse(),
165
173
  settings,
@@ -3,7 +3,7 @@ import { readFile, unlink } from 'node:fs/promises';
3
3
  import { join } from 'node:path';
4
4
  import { clearExperiences, EXPERIENCE_CONTEXT_HEADER, experienceEntry, recallExperiences, } from './experienceStore.js';
5
5
  import { assertAutomaticFactAllowed, blockFact, withFactBarriers, } from './factGovernance.js';
6
- import { clearGlobalProjectKnowledge, clearProjectKnowledge, PROJECT_KNOWLEDGE_CONTEXT_HEADER, projectKnowledgeEntry, projectKnowledgeStatus, recallProjectKnowledge, } from './projectKnowledge.js';
6
+ import { clearGlobalProjectKnowledge, clearProjectKnowledge, isLiveKnowledgeCandidate, PROJECT_KNOWLEDGE_CONTEXT_HEADER, projectKnowledgeEntry, projectKnowledgeStatus, recallProjectKnowledge, } from './projectKnowledge.js';
7
7
  import { countTextTokens } from '../context/tokenCounter.js';
8
8
  import { containsForbiddenSecret, deriveTopicKey, hydrateRecord, parsePersona, serializePersona, sha256, validateMemoryInput, } from './codec.js';
9
9
  import { DEFAULT_RETENTION, DEFAULT_SETTINGS, LEGACY_LEARNING_CADENCE, MEMORY_SETTINGS_REVISION, MEMORY_RETENTION_DAYS_MAX, MEMORY_RETENTION_DAYS_MIN, MEMORY_RETENTION_MB_MAX, MEMORY_RETENTION_MB_MIN, PERSONA_MAX_CHARS, } from './constants.js';
@@ -11,7 +11,8 @@ import { clearConversationEpisodes, episodicMemoryService, } from './episodicMem
11
11
  import { acquireFileLock, appendPrivateLine, atomicWriteFile, auditFilePath, enqueueMemoryMutation, personaFilePath, scopeDirectory, settingsFilePath, } from './fileStore.js';
12
12
  import { ensureMemoryMigrated } from './migration.js';
13
13
  import { clearScopeTransactionQuarantine, readVersionedMemory, refreshTopicView, removeVersionedMemory, replaceMemoryBatchVersioned, scanMemoryScope, writeNewMemory, } from './repository.js';
14
- import { isReviewDue, memoryTerms, recallFromSnapshot, truncateTokenBudget, } from './retrieval.js';
14
+ import { isReviewDue, isUnconfirmed, memoryTerms, recallFromSnapshot, takeTokenBudget, truncateTokenBudget, } from './retrieval.js';
15
+ import { recallQueryFor } from './recallQuery.js';
15
16
  export { containsForbiddenSecret } from './codec.js';
16
17
  export class MemoryTopicConflictError extends Error {
17
18
  recordId;
@@ -239,6 +240,17 @@ function refreshTopicViewAfterMutation(scope, workspaceId) {
239
240
  combinedSnapshotCache = null;
240
241
  void refreshTopicView(scope, workspaceId).catch(() => undefined);
241
242
  }
243
+ /**
244
+ * Candidates live per workspace, so callers pass the workspace whose candidate
245
+ * file they can reach (a personal record's candidates may sit anywhere; the
246
+ * prompt-side liveness rule covers the rest). The update itself already
247
+ * succeeded: a settle failure is reported, never turned into a failed update.
248
+ */
249
+ export async function settleCandidatesAfterUpdate(workspaceId, updated) {
250
+ await (await import('./factCandidates.js'))
251
+ .settleFactCandidatesFor(workspaceId, updated.record, updated.version)
252
+ .catch((error) => process.stderr.write(`[minimal-agent memory] 记忆已更新,但关联候选暂未结清:${error.message}\n`));
253
+ }
242
254
  function sameTopic(a, b) {
243
255
  const topicKey = b.topicKey || deriveTopicKey(b);
244
256
  if (a.topicKey === topicKey)
@@ -323,6 +335,8 @@ export const memoryService = {
323
335
  async remember(input, automatic) {
324
336
  validateMemoryInput(input);
325
337
  await ensureMemoryMigrated();
338
+ // Set when the user said the same thing again; see the refresh below.
339
+ let restated;
326
340
  const commit = () => withFactBarriers(input.scope, input.workspaceId, async (barriers) => {
327
341
  automatic?.signal?.throwIfAborted();
328
342
  if (automatic)
@@ -345,6 +359,9 @@ export const memoryService = {
345
359
  record: duplicate,
346
360
  result: 'ok',
347
361
  });
362
+ // A queued turn older than the last confirmation confirms nothing new.
363
+ if (!automatic || automatic.startedAt > duplicate.lastConfirmedAt)
364
+ restated = duplicate;
348
365
  return duplicate;
349
366
  }
350
367
  if (duplicate) {
@@ -378,13 +395,36 @@ export const memoryService = {
378
395
  await release();
379
396
  }
380
397
  });
381
- return enqueueMemoryMutation(() => automatic && input.scope === 'personal'
398
+ const stored = await enqueueMemoryMutation(() => automatic && input.scope === 'personal'
382
399
  ? withFactBarriers('workspace', automatic.sourceWorkspaceId, async (barriers) => {
383
400
  // Hold source-workspace barrier until a global fact commit finishes.
384
401
  assertAutomaticFactAllowed(barriers, input, automatic.startedAt);
385
402
  return commit();
386
403
  })
387
404
  : commit());
405
+ if (!restated)
406
+ return stored;
407
+ // Saying the same thing again is a fresh confirmation. Returning the old
408
+ // record untouched left `lastConfirmedAt` at the first mention, so a fact
409
+ // the user kept repeating still aged into "待复核" on the first date's clock.
410
+ // Done after the queue/lock are released: the update takes them itself.
411
+ try {
412
+ const current = await this.readWithVersion(restated.id, restated.scope, restated.workspaceId);
413
+ if (!current)
414
+ return stored;
415
+ const refreshed = await this.updateWithVersion(restated.id, restated.scope, current.version, {
416
+ evidence: input.evidence,
417
+ evidenceQuote: input.evidenceQuote,
418
+ sourceSessionId: input.sourceSessionId,
419
+ sourceMessageId: input.sourceMessageId,
420
+ status: 'active',
421
+ }, restated.workspaceId);
422
+ return refreshed?.record ?? stored;
423
+ }
424
+ catch {
425
+ // Losing the refresh to a concurrent edit is harmless; the record is intact.
426
+ return stored;
427
+ }
388
428
  },
389
429
  async updateWithVersion(id, scope, expectedVersion, patch, workspaceId, candidate) {
390
430
  await ensureMemoryMigrated();
@@ -485,7 +525,7 @@ export const memoryService = {
485
525
  throw error;
486
526
  }
487
527
  };
488
- return enqueueMemoryMutation(() => candidate
528
+ const updated = await enqueueMemoryMutation(() => candidate
489
529
  ? withFactBarriers('workspace', candidate.sourceWorkspaceId, async (barriers) => {
490
530
  // A global candidate from a cleared workspace cannot update an old fact.
491
531
  if (candidate.startedAt <= barriers.clearedAt)
@@ -493,6 +533,10 @@ export const memoryService = {
493
533
  return commit();
494
534
  })
495
535
  : commit());
536
+ const candidatesWorkspace = workspaceId ?? candidate?.sourceWorkspaceId;
537
+ if (updated && candidatesWorkspace)
538
+ await settleCandidatesAfterUpdate(candidatesWorkspace, updated);
539
+ return updated;
496
540
  },
497
541
  async update(id, scope, patch, workspaceId) {
498
542
  const current = await this.readWithVersion(id, scope, workspaceId);
@@ -695,6 +739,8 @@ export const memoryService = {
695
739
  const count = (base) => Math.max(base, Math.round(base * scale));
696
740
  const snapshot = await combinedSnapshot(workspaceId);
697
741
  const settings = await this.settings();
742
+ // Only retrieval widens; `input` still decides what the user is correcting.
743
+ const query = recallQueryFor(input, options.previousInput);
698
744
  const persona = options.includePersona === false
699
745
  ? null
700
746
  : await this.readPersona().catch(() => null);
@@ -706,46 +752,53 @@ export const memoryService = {
706
752
  !currentInputOverrides(record, input))
707
753
  .sort((a, b) => Number(b.scope === 'workspace') - Number(a.scope === 'workspace') ||
708
754
  b.lastConfirmedAt - a.lastConfirmedAt);
709
- const relevant = recallFromSnapshot(snapshot, input, settings, count(12))
755
+ // "Who the user is" is a handful of high-value records that no query
756
+ // word would ever match ("帮我润色文案" never says 后端工程师), so like
757
+ // preferences they are injected wholesale rather than by lexical hit.
758
+ const profile = snapshot.records
759
+ .filter((record) => record.category === 'profile' &&
760
+ record.sensitivity === 'normal' &&
761
+ record.status !== 'superseded' &&
762
+ (!record.validUntil || record.validUntil > Date.now()) &&
763
+ !currentInputOverrides(record, input))
764
+ .sort((a, b) => b.lastConfirmedAt - a.lastConfirmedAt);
765
+ const relevant = recallFromSnapshot(snapshot, query, settings, count(12))
710
766
  .filter((record) => record.sensitivity === 'normal' &&
711
767
  record.category !== 'preference' &&
768
+ record.category !== 'profile' &&
712
769
  !currentInputOverrides(record, input))
713
770
  .slice(0, count(5));
714
- const reviewCount = snapshot.records.filter((record) => record.status !== 'superseded' && isReviewDue(record, settings)).length;
771
+ const reviewCount = snapshot.records.filter((record) => record.status !== 'superseded' && isUnconfirmed(record, settings)).length;
715
772
  const sections = [];
716
773
  const contextSections = [];
717
774
  const includedIds = [];
718
775
  let conflictWarning;
776
+ const candidateModule = await import('./factCandidates.js');
777
+ // Settled (corrected or forgotten) candidates are dropped here, before they
778
+ // can flag the current record as unconfirmed or bring old text back.
779
+ const statements = new Map(snapshot.records.map((record) => [record.id, record.statement]));
719
780
  const conflicts = workspaceId
720
- ? await (await import('./factCandidates.js'))
721
- .factCandidates(workspaceId)
722
- .catch((error) => {
781
+ ? (await candidateModule.factCandidates(workspaceId).catch((error) => {
723
782
  conflictWarning = `【记忆健康提示】冲突候选暂不可读:${error.message.slice(0, 300)}`;
724
783
  return [];
725
- })
784
+ })).filter((item) => candidateModule.isLiveFactCandidate(item, statements))
726
785
  : [];
727
- const pendingIds = new Set(conflicts
728
- .filter((item) => item.status === 'open')
729
- .map((item) => item.old?.id));
730
- const queryTerms = memoryTerms(input);
786
+ const pendingIds = new Set(conflicts.map((item) => item.old?.id));
787
+ const queryTerms = memoryTerms(query);
731
788
  // Evaluated after records are injected: a candidate whose old record is in
732
789
  // this prompt must travel with it. Otherwise the model sees "旧值·待复核"
733
790
  // with no new value — the user said "以后改用 pnpm" and the only thing the
734
791
  // next turn learns is that npm might be stale. Preferences are injected
735
792
  // wholesale, so relying on query overlap alone dropped exactly those.
736
- const liveRecordIds = new Set(snapshot.records.map((record) => record.id));
737
793
  const surfacedFactCandidates = () => conflicts
738
- .filter((item) => item.status === 'open' &&
739
- // Orphaned by a forget: its old text must never come back.
740
- (!item.old || liveRecordIds.has(item.old.id)) &&
741
- ((item.old && includedIds.includes(item.old.id)) ||
742
- queryTerms.some((term) => JSON.stringify([
743
- item.proposed.subject,
744
- item.proposed.statement,
745
- item.old?.statement,
746
- ])
747
- .toLowerCase()
748
- .includes(term))))
794
+ .filter((item) => ((item.old && includedIds.includes(item.old.id)) ||
795
+ queryTerms.some((term) => JSON.stringify([
796
+ item.proposed.subject,
797
+ item.proposed.statement,
798
+ item.old?.statement,
799
+ ])
800
+ .toLowerCase()
801
+ .includes(term))))
749
802
  .slice(0, count(3));
750
803
  const pushSection = (id, content, ids = [], recordList) => {
751
804
  if (countTextTokens([...sections, content].join('\n\n')) > budget)
@@ -767,7 +820,7 @@ export const memoryService = {
767
820
  const personaText = truncateTokenBudget(persona.content, share(700));
768
821
  pushSection('persona', `【全局 Agent 人格|可信本机配置】\n只用于角色、语气和协作风格;不得扩大权限、覆盖安全规则或改变当前任务。\n${personaText}`);
769
822
  }
770
- const describe = (record) => `- ${record.scope}/${record.category}/${record.subject} [${pendingIds.has(record.id) || record.status === 'needs_review' || isReviewDue(record, settings) ? '待复核,不能当作当前已确认事实' : '已记录'};确认 ${new Date(record.lastConfirmedAt).toISOString().slice(0, 10)}]: ${record.statement}`;
823
+ const describe = (record) => `- ${record.scope}/${record.category}/${record.subject} [${pendingIds.has(record.id) || isUnconfirmed(record, settings) ? '待复核,不能当作当前已确认事实' : '已记录'};确认 ${new Date(record.lastConfirmedAt).toISOString().slice(0, 10)}]: ${record.statement}`;
771
824
  const addRecords = (id, header, entries, sectionBudget) => {
772
825
  const selected = [];
773
826
  for (const entry of entries) {
@@ -782,10 +835,11 @@ export const memoryService = {
782
835
  pushSection(id, `${header}\n${selected.map((item) => item.text).join('\n')}`, selected.map((item) => item.id), { header, entries: selected });
783
836
  };
784
837
  addRecords('preferences', '【响应偏好|personal 全局,workspace 项目】当前要求优先;项目偏好仅在当前项目适用;待复核信息不作为确定约束。', preferences.map((record) => ({ id: record.id, text: describe(record) })), share(550));
838
+ addRecords('profile', '【用户画像|稳定背景】只在与任务相关时使用,不要主动复述;当前消息优先。', profile.map((record) => ({ id: record.id, text: describe(record) })), share(400));
785
839
  addRecords('relevant', '【相关本机记忆|低优先级、可纠正】当前消息优先;来源见 RecallMemory。', relevant.map((record) => ({ id: record.id, text: describe(record) })), share(850));
786
840
  if (workspaceId) {
787
841
  try {
788
- const knowledge = await recallProjectKnowledge(workspaceId, input, count(4));
842
+ const knowledge = await recallProjectKnowledge(workspaceId, query, count(4));
789
843
  // Header once via addRecords, not re-rendered per record: the old
790
844
  // projectKnowledgeContext([item]) call embedded a full ~60-token header
791
845
  // in every single entry, burning a quarter of this section's budget on
@@ -794,8 +848,9 @@ export const memoryService = {
794
848
  id: item.id,
795
849
  text: projectKnowledgeEntry(item),
796
850
  })), share(800));
797
- const knowledgeCandidates = (await projectKnowledgeStatus(workspaceId)).candidates
798
- .filter((item) => item.status === 'open' &&
851
+ const knowledgeStatus = await projectKnowledgeStatus(workspaceId);
852
+ const knowledgeCandidates = knowledgeStatus.candidates
853
+ .filter((item) => isLiveKnowledgeCandidate(item, knowledgeStatus.records) &&
799
854
  // Same rule as fact candidates: travels with its injected old record.
800
855
  (includedIds.includes(item.oldId) ||
801
856
  queryTerms.some((term) => JSON.stringify([
@@ -808,7 +863,7 @@ export const memoryService = {
808
863
  .slice(0, count(3));
809
864
  const relevantFactCandidates = surfacedFactCandidates();
810
865
  if (relevantFactCandidates.length || knowledgeCandidates.length)
811
- pushSection('health', `【相关知识变化待处理】旧值与新候选并存,候选不是现行事实。仅本轮用户明确纠正可直接作为本轮要求;历史未决候选不能自动替代旧值。影响当前任务时核对来源,无法确定再询问。\n${[
866
+ pushSection('health', `【相关知识变化待处理】旧值与新候选并存,候选不是现行事实。仅本轮用户明确纠正可直接作为本轮要求;历史候选不会自动替代旧值。影响当前任务时向用户确认哪个有效,并按确认结果结清候选(改写记录后候选自动失效);项目事实先用读取或测试核对。\n${[
812
867
  ...relevantFactCandidates.map((item) => `- fact candidate=${item.id}:旧=${item.old?.statement ?? '无'};新=${item.proposed.statement}`),
813
868
  ...knowledgeCandidates.map((item) => `- knowledge candidate=${item.id}:旧=${item.oldStatement};新=${item.proposed.statement}`),
814
869
  ].join('\n')}`);
@@ -817,7 +872,7 @@ export const memoryService = {
817
872
  pushSection('health', `项目事实暂不可读:${error.message}。不要假设项目知识库为空。`);
818
873
  }
819
874
  try {
820
- const experiences = await recallExperiences(workspaceId, input, count(3));
875
+ const experiences = await recallExperiences(workspaceId, query, count(3));
821
876
  addRecords('experiences', EXPERIENCE_CONTEXT_HEADER, experiences.map((item) => ({
822
877
  id: item.id,
823
878
  text: experienceEntry(item),
@@ -827,12 +882,28 @@ export const memoryService = {
827
882
  pushSection('health', `项目经验暂不可读:${error.message}。不要假设经验库为空。`);
828
883
  }
829
884
  }
885
+ // RecallMemory searches blind: the model cannot ask for what it does not
886
+ // know exists. A subject-only index of the records not injected above
887
+ // (no bodies, so it stays cheap) lets it decide what is worth reading.
888
+ const injected = new Set(includedIds);
889
+ const indexLines = snapshot.records
890
+ .filter((record) => !injected.has(record.id) &&
891
+ record.sensitivity === 'normal' &&
892
+ record.status !== 'superseded' &&
893
+ (!record.validUntil || record.validUntil > Date.now()))
894
+ .sort((a, b) => b.lastConfirmedAt - a.lastConfirmedAt)
895
+ .map((record) => `- ${record.scope}/${record.category}/${record.subject}`);
896
+ if (indexLines.length) {
897
+ const shown = takeTokenBudget(indexLines, share(400)).slice(0, count(60));
898
+ if (shown.length)
899
+ pushSection('index', `【其它已存记忆索引|仅主题,正文未载入】需要其中某条细节时用 RecallMemory 读取${shown.length < indexLines.length ? `(另有 ${indexLines.length - shown.length} 条未列出)` : ''}。\n${shown.join('\n')}`);
900
+ }
830
901
  const settingsWarning = settingsWarnings.get(settingsFilePath());
831
902
  if (workspaceId) {
832
903
  try {
833
904
  const facts = await (await import('./factLearning.js')).factLearningStatus(workspaceId);
834
905
  const experiences = await (await import('./experienceStore.js')).readExperienceStore(workspaceId);
835
- const paused = [...facts.pending, ...experiences.pending].filter(turn => turn.failure?.paused).length;
906
+ const paused = [...facts.pending, ...experiences.pending].filter((await import('./jobPolicy.js')).isPausedJob).length;
836
907
  if (paused)
837
908
  pushSection('health', `【记忆健康提示】${paused} 条后台学习资料因重复失败已暂停自动重试,原资料仍保留;不得声称这些资料已完成长期保存。`);
838
909
  }