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
@@ -15,6 +15,7 @@ import {
15
15
  import {
16
16
  clearGlobalProjectKnowledge,
17
17
  clearProjectKnowledge,
18
+ isLiveKnowledgeCandidate,
18
19
  PROJECT_KNOWLEDGE_CONTEXT_HEADER,
19
20
  projectKnowledgeEntry,
20
21
  projectKnowledgeStatus,
@@ -69,10 +70,13 @@ import {
69
70
  } from './repository.js';
70
71
  import {
71
72
  isReviewDue,
73
+ isUnconfirmed,
72
74
  memoryTerms,
73
75
  recallFromSnapshot,
76
+ takeTokenBudget,
74
77
  truncateTokenBudget,
75
78
  } from './retrieval.js';
79
+ import { recallQueryFor } from './recallQuery.js';
76
80
  import type {
77
81
  ConversationSearchOptions,
78
82
  MemoryContextBundle,
@@ -397,6 +401,25 @@ function refreshTopicViewAfterMutation(
397
401
  void refreshTopicView(scope, workspaceId).catch(() => undefined);
398
402
  }
399
403
 
404
+ /**
405
+ * Candidates live per workspace, so callers pass the workspace whose candidate
406
+ * file they can reach (a personal record's candidates may sit anywhere; the
407
+ * prompt-side liveness rule covers the rest). The update itself already
408
+ * succeeded: a settle failure is reported, never turned into a failed update.
409
+ */
410
+ export async function settleCandidatesAfterUpdate(
411
+ workspaceId: string,
412
+ updated: VersionedMemoryRecord,
413
+ ): Promise<void> {
414
+ await (await import('./factCandidates.js'))
415
+ .settleFactCandidatesFor(workspaceId, updated.record, updated.version)
416
+ .catch((error) =>
417
+ process.stderr.write(
418
+ `[minimal-agent memory] 记忆已更新,但关联候选暂未结清:${(error as Error).message}\n`,
419
+ ),
420
+ );
421
+ }
422
+
400
423
  function sameTopic(
401
424
  a: MemoryRecordV2,
402
425
  b: Pick<MemoryRecord, 'category' | 'subject'> & {
@@ -551,6 +574,8 @@ export const memoryService = {
551
574
  ): Promise<MemoryRecordV2> {
552
575
  validateMemoryInput(input);
553
576
  await ensureMemoryMigrated();
577
+ // Set when the user said the same thing again; see the refresh below.
578
+ let restated: MemoryRecordV2 | undefined;
554
579
  const commit = () =>
555
580
  withFactBarriers(input.scope, input.workspaceId, async (barriers) => {
556
581
  automatic?.signal?.throwIfAborted();
@@ -582,6 +607,8 @@ export const memoryService = {
582
607
  record: duplicate,
583
608
  result: 'ok',
584
609
  });
610
+ // A queued turn older than the last confirmation confirms nothing new.
611
+ if (!automatic || automatic.startedAt > duplicate.lastConfirmedAt) restated = duplicate;
585
612
  return duplicate;
586
613
  }
587
614
  if (duplicate) {
@@ -614,7 +641,7 @@ export const memoryService = {
614
641
  await release();
615
642
  }
616
643
  });
617
- return enqueueMemoryMutation(() =>
644
+ const stored = await enqueueMemoryMutation(() =>
618
645
  automatic && input.scope === 'personal'
619
646
  ? withFactBarriers(
620
647
  'workspace',
@@ -627,6 +654,32 @@ export const memoryService = {
627
654
  )
628
655
  : commit(),
629
656
  );
657
+ if (!restated) return stored;
658
+ // Saying the same thing again is a fresh confirmation. Returning the old
659
+ // record untouched left `lastConfirmedAt` at the first mention, so a fact
660
+ // the user kept repeating still aged into "待复核" on the first date's clock.
661
+ // Done after the queue/lock are released: the update takes them itself.
662
+ try {
663
+ const current = await this.readWithVersion(restated.id, restated.scope, restated.workspaceId);
664
+ if (!current) return stored;
665
+ const refreshed = await this.updateWithVersion(
666
+ restated.id,
667
+ restated.scope,
668
+ current.version,
669
+ {
670
+ evidence: input.evidence,
671
+ evidenceQuote: input.evidenceQuote,
672
+ sourceSessionId: input.sourceSessionId,
673
+ sourceMessageId: input.sourceMessageId,
674
+ status: 'active',
675
+ },
676
+ restated.workspaceId,
677
+ );
678
+ return refreshed?.record ?? stored;
679
+ } catch {
680
+ // Losing the refresh to a concurrent edit is harmless; the record is intact.
681
+ return stored;
682
+ }
630
683
  },
631
684
 
632
685
  async updateWithVersion(
@@ -748,7 +801,7 @@ export const memoryService = {
748
801
  throw error;
749
802
  }
750
803
  };
751
- return enqueueMemoryMutation(() =>
804
+ const updated = await enqueueMemoryMutation(() =>
752
805
  candidate
753
806
  ? withFactBarriers(
754
807
  'workspace',
@@ -762,6 +815,10 @@ export const memoryService = {
762
815
  )
763
816
  : commit(),
764
817
  );
818
+ const candidatesWorkspace = workspaceId ?? candidate?.sourceWorkspaceId;
819
+ if (updated && candidatesWorkspace)
820
+ await settleCandidatesAfterUpdate(candidatesWorkspace, updated);
821
+ return updated;
765
822
  },
766
823
 
767
824
  async update(
@@ -1011,7 +1068,13 @@ export const memoryService = {
1011
1068
  async buildContextBundle(
1012
1069
  input: string,
1013
1070
  workspaceId?: string,
1014
- options: { includePersona?: boolean; budgetTokens?: number } = {},
1071
+ options: {
1072
+ includePersona?: boolean;
1073
+ budgetTokens?: number;
1074
+ /** The user's previous request: "继续/为什么" alone names no topic, so a
1075
+ * pure follow-up is recalled against the request it continues. */
1076
+ previousInput?: string;
1077
+ } = {},
1015
1078
  ): Promise<MemoryContextBundle> {
1016
1079
  // Every cap below used to be a flat constant that ignored the model's
1017
1080
  // context window — the same bug class as the old flat transient budget one
@@ -1033,6 +1096,8 @@ export const memoryService = {
1033
1096
  const count = (base: number) => Math.max(base, Math.round(base * scale));
1034
1097
  const snapshot = await combinedSnapshot(workspaceId);
1035
1098
  const settings = await this.settings();
1099
+ // Only retrieval widens; `input` still decides what the user is correcting.
1100
+ const query = recallQueryFor(input, options.previousInput);
1036
1101
  const persona =
1037
1102
  options.includePersona === false
1038
1103
  ? null
@@ -1051,49 +1116,61 @@ export const memoryService = {
1051
1116
  Number(b.scope === 'workspace') - Number(a.scope === 'workspace') ||
1052
1117
  b.lastConfirmedAt - a.lastConfirmedAt,
1053
1118
  );
1054
- const relevant = recallFromSnapshot(snapshot, input, settings, count(12))
1119
+ // "Who the user is" is a handful of high-value records that no query
1120
+ // word would ever match ("帮我润色文案" never says 后端工程师), so like
1121
+ // preferences they are injected wholesale rather than by lexical hit.
1122
+ const profile = snapshot.records
1123
+ .filter(
1124
+ (record) =>
1125
+ record.category === 'profile' &&
1126
+ record.sensitivity === 'normal' &&
1127
+ record.status !== 'superseded' &&
1128
+ (!record.validUntil || record.validUntil > Date.now()) &&
1129
+ !currentInputOverrides(record, input),
1130
+ )
1131
+ .sort((a, b) => b.lastConfirmedAt - a.lastConfirmedAt);
1132
+ const relevant = recallFromSnapshot(snapshot, query, settings, count(12))
1055
1133
  .filter(
1056
1134
  (record) =>
1057
1135
  record.sensitivity === 'normal' &&
1058
1136
  record.category !== 'preference' &&
1137
+ record.category !== 'profile' &&
1059
1138
  !currentInputOverrides(record, input),
1060
1139
  )
1061
1140
  .slice(0, count(5));
1062
1141
  const reviewCount = snapshot.records.filter(
1063
1142
  (record) =>
1064
- record.status !== 'superseded' && isReviewDue(record, settings),
1143
+ record.status !== 'superseded' && isUnconfirmed(record, settings),
1065
1144
  ).length;
1066
1145
  const sections: string[] = [];
1067
1146
  const contextSections: MemoryContextBundle['sections'] = [];
1068
1147
  const includedIds: string[] = [];
1069
1148
  let conflictWarning: string | undefined;
1149
+ const candidateModule = await import('./factCandidates.js');
1150
+ // Settled (corrected or forgotten) candidates are dropped here, before they
1151
+ // can flag the current record as unconfirmed or bring old text back.
1152
+ const statements = new Map(
1153
+ snapshot.records.map((record) => [record.id, record.statement]),
1154
+ );
1070
1155
  const conflicts = workspaceId
1071
- ? await (await import('./factCandidates.js'))
1072
- .factCandidates(workspaceId)
1073
- .catch((error) => {
1156
+ ? (
1157
+ await candidateModule.factCandidates(workspaceId).catch((error) => {
1074
1158
  conflictWarning = `【记忆健康提示】冲突候选暂不可读:${(error as Error).message.slice(0, 300)}`;
1075
1159
  return [];
1076
1160
  })
1161
+ ).filter((item) => candidateModule.isLiveFactCandidate(item, statements))
1077
1162
  : [];
1078
- const pendingIds = new Set(
1079
- conflicts
1080
- .filter((item) => item.status === 'open')
1081
- .map((item) => item.old?.id),
1082
- );
1083
- const queryTerms = memoryTerms(input);
1163
+ const pendingIds = new Set(conflicts.map((item) => item.old?.id));
1164
+ const queryTerms = memoryTerms(query);
1084
1165
  // Evaluated after records are injected: a candidate whose old record is in
1085
1166
  // this prompt must travel with it. Otherwise the model sees "旧值·待复核"
1086
1167
  // with no new value — the user said "以后改用 pnpm" and the only thing the
1087
1168
  // next turn learns is that npm might be stale. Preferences are injected
1088
1169
  // wholesale, so relying on query overlap alone dropped exactly those.
1089
- const liveRecordIds = new Set(snapshot.records.map((record) => record.id));
1090
1170
  const surfacedFactCandidates = () =>
1091
1171
  conflicts
1092
1172
  .filter(
1093
1173
  (item) =>
1094
- item.status === 'open' &&
1095
- // Orphaned by a forget: its old text must never come back.
1096
- (!item.old || liveRecordIds.has(item.old.id)) &&
1097
1174
  ((item.old && includedIds.includes(item.old.id)) ||
1098
1175
  queryTerms.some((term) =>
1099
1176
  JSON.stringify([
@@ -1134,9 +1211,9 @@ export const memoryService = {
1134
1211
  );
1135
1212
  }
1136
1213
  const describe = (record: MemoryRecordV2) =>
1137
- `- ${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}`;
1214
+ `- ${record.scope}/${record.category}/${record.subject} [${pendingIds.has(record.id) || isUnconfirmed(record, settings) ? '待复核,不能当作当前已确认事实' : '已记录'};确认 ${new Date(record.lastConfirmedAt).toISOString().slice(0, 10)}]: ${record.statement}`;
1138
1215
  const addRecords = (
1139
- id: 'preferences' | 'relevant' | 'projectKnowledge' | 'experiences',
1216
+ id: 'preferences' | 'profile' | 'relevant' | 'projectKnowledge' | 'experiences',
1140
1217
  header: string,
1141
1218
  entries: { id: string; text: string }[],
1142
1219
  sectionBudget: number,
@@ -1166,6 +1243,12 @@ export const memoryService = {
1166
1243
  preferences.map((record) => ({ id: record.id, text: describe(record) })),
1167
1244
  share(550),
1168
1245
  );
1246
+ addRecords(
1247
+ 'profile',
1248
+ '【用户画像|稳定背景】只在与任务相关时使用,不要主动复述;当前消息优先。',
1249
+ profile.map((record) => ({ id: record.id, text: describe(record) })),
1250
+ share(400),
1251
+ );
1169
1252
  addRecords(
1170
1253
  'relevant',
1171
1254
  '【相关本机记忆|低优先级、可纠正】当前消息优先;来源见 RecallMemory。',
@@ -1176,7 +1259,7 @@ export const memoryService = {
1176
1259
  try {
1177
1260
  const knowledge = await recallProjectKnowledge(
1178
1261
  workspaceId,
1179
- input,
1262
+ query,
1180
1263
  count(4),
1181
1264
  );
1182
1265
  // Header once via addRecords, not re-rendered per record: the old
@@ -1192,12 +1275,11 @@ export const memoryService = {
1192
1275
  })),
1193
1276
  share(800),
1194
1277
  );
1195
- const knowledgeCandidates = (
1196
- await projectKnowledgeStatus(workspaceId)
1197
- ).candidates
1278
+ const knowledgeStatus = await projectKnowledgeStatus(workspaceId);
1279
+ const knowledgeCandidates = knowledgeStatus.candidates
1198
1280
  .filter(
1199
1281
  (item) =>
1200
- item.status === 'open' &&
1282
+ isLiveKnowledgeCandidate(item, knowledgeStatus.records) &&
1201
1283
  // Same rule as fact candidates: travels with its injected old record.
1202
1284
  (includedIds.includes(item.oldId) ||
1203
1285
  queryTerms.some((term) =>
@@ -1215,7 +1297,7 @@ export const memoryService = {
1215
1297
  if (relevantFactCandidates.length || knowledgeCandidates.length)
1216
1298
  pushSection(
1217
1299
  'health',
1218
- `【相关知识变化待处理】旧值与新候选并存,候选不是现行事实。仅本轮用户明确纠正可直接作为本轮要求;历史未决候选不能自动替代旧值。影响当前任务时核对来源,无法确定再询问。\n${[
1300
+ `【相关知识变化待处理】旧值与新候选并存,候选不是现行事实。仅本轮用户明确纠正可直接作为本轮要求;历史候选不会自动替代旧值。影响当前任务时向用户确认哪个有效,并按确认结果结清候选(改写记录后候选自动失效);项目事实先用读取或测试核对。\n${[
1219
1301
  ...relevantFactCandidates.map(
1220
1302
  (item) =>
1221
1303
  `- fact candidate=${item.id}:旧=${item.old?.statement ?? '无'};新=${item.proposed.statement}`,
@@ -1235,7 +1317,7 @@ export const memoryService = {
1235
1317
  try {
1236
1318
  const experiences = await recallExperiences(
1237
1319
  workspaceId,
1238
- input,
1320
+ query,
1239
1321
  count(3),
1240
1322
  );
1241
1323
  addRecords(
@@ -1254,12 +1336,34 @@ export const memoryService = {
1254
1336
  );
1255
1337
  }
1256
1338
  }
1339
+ // RecallMemory searches blind: the model cannot ask for what it does not
1340
+ // know exists. A subject-only index of the records not injected above
1341
+ // (no bodies, so it stays cheap) lets it decide what is worth reading.
1342
+ const injected = new Set(includedIds);
1343
+ const indexLines = snapshot.records
1344
+ .filter(
1345
+ (record) =>
1346
+ !injected.has(record.id) &&
1347
+ record.sensitivity === 'normal' &&
1348
+ record.status !== 'superseded' &&
1349
+ (!record.validUntil || record.validUntil > Date.now()),
1350
+ )
1351
+ .sort((a, b) => b.lastConfirmedAt - a.lastConfirmedAt)
1352
+ .map((record) => `- ${record.scope}/${record.category}/${record.subject}`);
1353
+ if (indexLines.length) {
1354
+ const shown = takeTokenBudget(indexLines, share(400)).slice(0, count(60));
1355
+ if (shown.length)
1356
+ pushSection(
1357
+ 'index',
1358
+ `【其它已存记忆索引|仅主题,正文未载入】需要其中某条细节时用 RecallMemory 读取${shown.length < indexLines.length ? `(另有 ${indexLines.length - shown.length} 条未列出)` : ''}。\n${shown.join('\n')}`,
1359
+ );
1360
+ }
1257
1361
  const settingsWarning = settingsWarnings.get(settingsFilePath());
1258
1362
  if (workspaceId) {
1259
1363
  try {
1260
1364
  const facts = await (await import('./factLearning.js')).factLearningStatus(workspaceId);
1261
1365
  const experiences = await (await import('./experienceStore.js')).readExperienceStore(workspaceId);
1262
- const paused = [...facts.pending, ...experiences.pending].filter(turn => turn.failure?.paused).length;
1366
+ const paused = [...facts.pending, ...experiences.pending].filter((await import('./jobPolicy.js')).isPausedJob).length;
1263
1367
  if (paused) pushSection('health', `【记忆健康提示】${paused} 条后台学习资料因重复失败已暂停自动重试,原资料仍保留;不得声称这些资料已完成长期保存。`);
1264
1368
  } catch {
1265
1369
  pushSection('health', '【记忆健康提示】后台学习状态暂不可读,不代表队列为空或已完成。');
@@ -75,6 +75,19 @@ const storeSchema = z.object({
75
75
  records: z.array(recordSchema),
76
76
  candidates: z.array(candidateSchema),
77
77
  });
78
+ /** Matches the `project` review window users already see in memory settings. */
79
+ export const KNOWLEDGE_REVIEW_DAYS = 90;
80
+ /**
81
+ * Knowledge with a dependency snapshot learns when it went stale from the
82
+ * files themselves; a deployment convention or a decision has none, so without
83
+ * this it stayed "active" forever however long ago anyone checked it.
84
+ */
85
+ export function unverifiedDays(item, now = Date.now()) {
86
+ if (item.dependencies)
87
+ return undefined;
88
+ const days = Math.floor((now - (item.lastVerifiedAt ?? item.createdAt)) / 86_400_000);
89
+ return days >= KNOWLEDGE_REVIEW_DAYS ? days : undefined;
90
+ }
78
91
  export const parseProjectKnowledgeSnapshot = (input) => storeSchema.parse(input);
79
92
  const pathFor = (scope, workspaceId) => join(scope === 'global'
80
93
  ? globalMemoryDir()
@@ -320,8 +333,24 @@ export async function recallProjectKnowledge(workspaceId, query, limit = 5) {
320
333
  dependencyWarning: item.dependencies
321
334
  ? await checkDependencies(item.dependencies)
322
335
  : [],
336
+ unverifiedDays: unverifiedDays(item),
323
337
  })));
324
338
  }
339
+ /**
340
+ * Same rule as fact candidates (factCandidates.ts::isLiveFactCandidate): a
341
+ * candidate proposes replacing one exact statement of one live record. Once
342
+ * that record is dismissed, replaced or rewritten, the candidate is history —
343
+ * surfacing it put a dismissed statement back into prompts as "旧=…".
344
+ */
345
+ export function isLiveKnowledgeCandidate(item, records) {
346
+ if (item.status !== 'open')
347
+ return false;
348
+ const current = records.find((record) => record.id === item.oldId);
349
+ return (current !== undefined &&
350
+ current.status !== 'superseded' &&
351
+ current.statement === item.oldStatement &&
352
+ current.statement !== item.proposed.statement);
353
+ }
325
354
  export async function resolveProjectKnowledgeCandidate(workspaceId, id, decision, signal) {
326
355
  const global = await readStore('global');
327
356
  const workspace = await readStore('workspace', workspaceId);
@@ -343,8 +372,14 @@ export async function resolveProjectKnowledgeCandidate(workspaceId, id, decision
343
372
  const current = store.records.find((item) => item.id === candidate.oldId);
344
373
  if (!current ||
345
374
  current.status === 'superseded' ||
346
- current.statement !== candidate.oldStatement)
347
- throw new Error('PROJECT_KNOWLEDGE_VERSION_CONFLICT');
375
+ current.statement !== candidate.oldStatement) {
376
+ // A stale proposal can never be accepted, but refusing to reject it too
377
+ // left it open forever with nothing able to close it.
378
+ if (decision === 'accept')
379
+ throw new Error('PROJECT_KNOWLEDGE_VERSION_CONFLICT');
380
+ candidate.status = 'rejected';
381
+ return;
382
+ }
348
383
  if (decision === 'accept') {
349
384
  current.status = 'superseded';
350
385
  current.revision++;
@@ -511,7 +546,8 @@ export function projectKnowledgeEntry(item) {
511
546
  agent_inferred: '助手推断',
512
547
  })[source.type])),
513
548
  ].join('、');
514
- return `- [${item.id}] ${item.kind}/${item.subject} (${item.scope}${item.status === 'needs_review' ? '; 待复核' : ''}; 来源:${sourceLabel(item)}):${item.statement}\n适用:${item.applicability}${item.dependencyWarning?.length ? `\n依赖已变化:${item.dependencyWarning.join('、')};请读取/测试后更新或确认。` : ''}`;
549
+ return `- [${item.id}] ${item.kind}/${item.subject} (${item.scope}${item.status === 'needs_review' ? '; 待复核' : ''}; 来源:${sourceLabel(item)}):${item.statement}\n适用:${item.applicability}${item.dependencyWarning?.length ? `\n依赖已变化:${item.dependencyWarning.join('、')};请读取/测试后更新或确认。` : ''}${item.unverifiedDays ? `
550
+ 已 ${item.unverifiedDays} 天未验证;依赖它之前先核对现状,核对后可保存更新。` : ''}`;
515
551
  }
516
552
  export function projectKnowledgeContext(items) {
517
553
  if (!items.length)
@@ -91,7 +91,22 @@ const storeSchema = z.object({
91
91
  });
92
92
  export type ProjectKnowledge = z.infer<typeof recordSchema> & {
93
93
  dependencyWarning?: string[];
94
+ /** Whole days since this was last verified, set only once it is overdue. */
95
+ unverifiedDays?: number;
94
96
  };
97
+
98
+ /** Matches the `project` review window users already see in memory settings. */
99
+ export const KNOWLEDGE_REVIEW_DAYS = 90;
100
+ /**
101
+ * Knowledge with a dependency snapshot learns when it went stale from the
102
+ * files themselves; a deployment convention or a decision has none, so without
103
+ * this it stayed "active" forever however long ago anyone checked it.
104
+ */
105
+ export function unverifiedDays(item: ProjectKnowledge, now = Date.now()): number | undefined {
106
+ if (item.dependencies) return undefined;
107
+ const days = Math.floor((now - (item.lastVerifiedAt ?? item.createdAt)) / 86_400_000);
108
+ return days >= KNOWLEDGE_REVIEW_DAYS ? days : undefined;
109
+ }
95
110
  export type ProjectKnowledgeDraft = z.infer<typeof candidateSchema>['proposed'];
96
111
  export type ProjectKnowledgeCandidate = z.infer<typeof candidateSchema>;
97
112
  type Store = z.infer<typeof storeSchema>;
@@ -453,10 +468,31 @@ export async function recallProjectKnowledge(
453
468
  dependencyWarning: item.dependencies
454
469
  ? await checkDependencies(item.dependencies)
455
470
  : [],
471
+ unverifiedDays: unverifiedDays(item),
456
472
  })),
457
473
  );
458
474
  }
459
475
 
476
+ /**
477
+ * Same rule as fact candidates (factCandidates.ts::isLiveFactCandidate): a
478
+ * candidate proposes replacing one exact statement of one live record. Once
479
+ * that record is dismissed, replaced or rewritten, the candidate is history —
480
+ * surfacing it put a dismissed statement back into prompts as "旧=…".
481
+ */
482
+ export function isLiveKnowledgeCandidate(
483
+ item: ProjectKnowledgeCandidate,
484
+ records: readonly Pick<ProjectKnowledge, 'id' | 'status' | 'statement'>[],
485
+ ): boolean {
486
+ if (item.status !== 'open') return false;
487
+ const current = records.find((record) => record.id === item.oldId);
488
+ return (
489
+ current !== undefined &&
490
+ current.status !== 'superseded' &&
491
+ current.statement === item.oldStatement &&
492
+ current.statement !== item.proposed.statement
493
+ );
494
+ }
495
+
460
496
  export async function resolveProjectKnowledgeCandidate(
461
497
  workspaceId: string,
462
498
  id: string,
@@ -487,8 +523,13 @@ export async function resolveProjectKnowledgeCandidate(
487
523
  !current ||
488
524
  current.status === 'superseded' ||
489
525
  current.statement !== candidate.oldStatement
490
- )
491
- throw new Error('PROJECT_KNOWLEDGE_VERSION_CONFLICT');
526
+ ) {
527
+ // A stale proposal can never be accepted, but refusing to reject it too
528
+ // left it open forever with nothing able to close it.
529
+ if (decision === 'accept') throw new Error('PROJECT_KNOWLEDGE_VERSION_CONFLICT');
530
+ candidate.status = 'rejected';
531
+ return;
532
+ }
492
533
  if (decision === 'accept') {
493
534
  current.status = 'superseded';
494
535
  current.revision++;
@@ -730,7 +771,8 @@ export function projectKnowledgeEntry(item: ProjectKnowledge): string {
730
771
  ),
731
772
  ),
732
773
  ].join('、');
733
- return `- [${item.id}] ${item.kind}/${item.subject} (${item.scope}${item.status === 'needs_review' ? '; 待复核' : ''}; 来源:${sourceLabel(item)}):${item.statement}\n适用:${item.applicability}${item.dependencyWarning?.length ? `\n依赖已变化:${item.dependencyWarning.join('、')};请读取/测试后更新或确认。` : ''}`;
774
+ return `- [${item.id}] ${item.kind}/${item.subject} (${item.scope}${item.status === 'needs_review' ? '; 待复核' : ''}; 来源:${sourceLabel(item)}):${item.statement}\n适用:${item.applicability}${item.dependencyWarning?.length ? `\n依赖已变化:${item.dependencyWarning.join('、')};请读取/测试后更新或确认。` : ''}${item.unverifiedDays ? `
775
+ 已 ${item.unverifiedDays} 天未验证;依赖它之前先核对现状,核对后可保存更新。` : ''}`;
734
776
  }
735
777
 
736
778
  export function projectKnowledgeContext(items: ProjectKnowledge[]): string {
@@ -0,0 +1,56 @@
1
+ /**
2
+ * A message that is nothing but a continuation. Anchored at both ends: "为什么
3
+ * Redis 连接超时" or "详细说明部署流程" start the same way but carry their own
4
+ * topic, so treating them as follow-ups would skip them and recall against an
5
+ * older request instead. (The advisor has a looser prefix check of its own; a
6
+ * false positive there is harmless, here it points recall at the wrong topic.)
7
+ */
8
+ const BARE_FOLLOW_UP = /^(?:继续|接着(?:说|来|做)?|然后呢|展开(?:说说|讲讲)?|详细(?:点|说说)?|深入(?:点|说说)?|再说(?:说)?|还有呢|为什么|为啥|怎么说|第[一二三四五六七八九十\d]+[点条项个]|go on|continue|elaborate|more detail|why)[\s,,。.!!??~呢吧啊]*$/i;
9
+ export function isFollowUpRequest(text) {
10
+ return BARE_FOLLOW_UP.test(text.trim());
11
+ }
12
+ /** Enough of the earlier request to carry its topic, not its whole paste. */
13
+ const PREVIOUS_QUERY_CHARS = 500;
14
+ /** "继续" after "继续" still continues the request before it; bound the walk. */
15
+ const MAX_FOLLOW_UP_HOPS = 4;
16
+ function userText(message) {
17
+ if (typeof message.content === 'string')
18
+ return message.content;
19
+ if (!Array.isArray(message.content))
20
+ return '';
21
+ return message.content
22
+ .filter((part) => part.type === 'text')
23
+ .map(part => part.text)
24
+ .join('\n');
25
+ }
26
+ /**
27
+ * The most recent user request before the current one, walking past bare
28
+ * follow-ups so "继续" → "为什么" still resolves to the request that named the
29
+ * topic. Hosts call this with the history as it stood before the new message.
30
+ */
31
+ export function previousUserRequest(history) {
32
+ if (!history)
33
+ return undefined;
34
+ let hops = 0;
35
+ for (let index = history.length - 1; index >= 0; index -= 1) {
36
+ const message = history[index];
37
+ if (message.role !== 'user')
38
+ continue;
39
+ const text = userText(message).trim();
40
+ if (!text)
41
+ continue;
42
+ if (isFollowUpRequest(text) && (hops += 1) < MAX_FOLLOW_UP_HOPS)
43
+ continue;
44
+ return text.slice(0, PREVIOUS_QUERY_CHARS);
45
+ }
46
+ return undefined;
47
+ }
48
+ /**
49
+ * What memory is searched with. A pure follow-up ("继续", "为什么") has no
50
+ * topic words of its own, so recall against the request it continues; any
51
+ * other message is its own query. Recall only — what the current message
52
+ * corrects or overrides is still decided from the current message alone.
53
+ */
54
+ export function recallQueryFor(current, previous) {
55
+ return previous && isFollowUpRequest(current) ? `${previous}\n${current}` : current;
56
+ }
@@ -0,0 +1,58 @@
1
+ import type { Message } from '../types.js';
2
+
3
+ /**
4
+ * A message that is nothing but a continuation. Anchored at both ends: "为什么
5
+ * Redis 连接超时" or "详细说明部署流程" start the same way but carry their own
6
+ * topic, so treating them as follow-ups would skip them and recall against an
7
+ * older request instead. (The advisor has a looser prefix check of its own; a
8
+ * false positive there is harmless, here it points recall at the wrong topic.)
9
+ */
10
+ const BARE_FOLLOW_UP =
11
+ /^(?:继续|接着(?:说|来|做)?|然后呢|展开(?:说说|讲讲)?|详细(?:点|说说)?|深入(?:点|说说)?|再说(?:说)?|还有呢|为什么|为啥|怎么说|第[一二三四五六七八九十\d]+[点条项个]|go on|continue|elaborate|more detail|why)[\s,,。.!!??~呢吧啊]*$/i;
12
+
13
+ export function isFollowUpRequest(text: string): boolean {
14
+ return BARE_FOLLOW_UP.test(text.trim());
15
+ }
16
+
17
+ /** Enough of the earlier request to carry its topic, not its whole paste. */
18
+ const PREVIOUS_QUERY_CHARS = 500;
19
+ /** "继续" after "继续" still continues the request before it; bound the walk. */
20
+ const MAX_FOLLOW_UP_HOPS = 4;
21
+
22
+ function userText(message: Message): string {
23
+ if (typeof message.content === 'string') return message.content;
24
+ if (!Array.isArray(message.content)) return '';
25
+ return message.content
26
+ .filter((part): part is Extract<typeof part, { type: 'text' }> => part.type === 'text')
27
+ .map(part => part.text)
28
+ .join('\n');
29
+ }
30
+
31
+ /**
32
+ * The most recent user request before the current one, walking past bare
33
+ * follow-ups so "继续" → "为什么" still resolves to the request that named the
34
+ * topic. Hosts call this with the history as it stood before the new message.
35
+ */
36
+ export function previousUserRequest(history: readonly Message[] | undefined): string | undefined {
37
+ if (!history) return undefined;
38
+ let hops = 0;
39
+ for (let index = history.length - 1; index >= 0; index -= 1) {
40
+ const message = history[index]!;
41
+ if (message.role !== 'user') continue;
42
+ const text = userText(message).trim();
43
+ if (!text) continue;
44
+ if (isFollowUpRequest(text) && (hops += 1) < MAX_FOLLOW_UP_HOPS) continue;
45
+ return text.slice(0, PREVIOUS_QUERY_CHARS);
46
+ }
47
+ return undefined;
48
+ }
49
+
50
+ /**
51
+ * What memory is searched with. A pure follow-up ("继续", "为什么") has no
52
+ * topic words of its own, so recall against the request it continues; any
53
+ * other message is its own query. Recall only — what the current message
54
+ * corrects or overrides is still decided from the current message alone.
55
+ */
56
+ export function recallQueryFor(current: string, previous: string | undefined): string {
57
+ return previous && isFollowUpRequest(current) ? `${previous}\n${current}` : current;
58
+ }