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.
- package/package.json +1 -3
- package/plugins/workflow-runner/src/runtime/context.js +1 -1
- package/plugins/workflow-runner/src/runtime/context.ts +1 -1
- package/plugins/workflow-runner/test/embeddedExecution.test.ts +2 -5
- package/plugins/workflow-runner/test/execLifetime.test.ts +15 -2
- package/skills/ssh-remote-dev/SKILL.md +61 -0
- package/skills/ssh-remote-dev/agents/openai.yaml +4 -0
- package/src/advisor/README.md +62 -24
- package/src/advisor/advisorNudge.js +199 -0
- package/src/advisor/advisorNudge.ts +232 -0
- package/src/advisor/deliveryReview.js +35 -12
- package/src/advisor/deliveryReview.ts +35 -11
- package/src/advisor/presentation.js +7 -10
- package/src/advisor/presentation.ts +10 -13
- package/src/advisor/reviewContext.js +22 -58
- package/src/advisor/reviewContext.ts +24 -67
- package/src/cli/configMenu.js +17 -128
- package/src/cli/configMenu.tsx +17 -193
- package/src/cli/print.js +2 -0
- package/src/cli/print.ts +2 -0
- package/src/cli/streamJson.js +2 -0
- package/src/cli/streamJson.ts +2 -0
- package/src/cli/wizards/advisorWizard.js +9 -7
- package/src/cli/wizards/advisorWizard.tsx +9 -7
- package/src/cli/wizards/contextAdvisorWizard.js +9 -7
- package/src/cli/wizards/contextAdvisorWizard.tsx +9 -7
- package/src/cli/wizards/providerPresets.js +27 -1
- package/src/cli/wizards/providerPresets.ts +44 -2
- package/src/cli/wizards/providerWizard.js +51 -10
- package/src/cli/wizards/providerWizard.tsx +59 -9
- package/src/config/configDefaults.js +1 -1
- package/src/config/configDefaults.ts +1 -1
- package/src/config/configDiagnostics.js +1 -21
- package/src/config/configDiagnostics.ts +3 -23
- package/src/config/configFile.js +11 -4
- package/src/config/configFile.ts +27 -7
- package/src/config/configManagement.js +1 -0
- package/src/config/configManagement.ts +2 -0
- package/src/config/configService.js +12 -8
- package/src/config/configService.ts +16 -11
- package/src/config/numericLimits.js +0 -4
- package/src/config/numericLimits.ts +0 -4
- package/src/config/reasoningEffort.js +16 -0
- package/src/config/reasoningEffort.ts +18 -0
- package/src/context/budgetPlan.js +1 -1
- package/src/context/budgetPlan.ts +1 -1
- package/src/context/optimizeConversation.js +1 -1
- package/src/context/optimizeConversation.ts +1 -1
- package/src/harness/stallGuard.js +8 -13
- package/src/harness/stallGuard.ts +8 -12
- package/src/llm/client.js +64 -17
- package/src/llm/client.ts +62 -7
- package/src/llm/providers/openaiFormat.js +14 -3
- package/src/llm/providers/openaiFormat.ts +17 -3
- package/src/llm/providers/responsesFormat.js +275 -0
- package/src/llm/providers/responsesFormat.ts +306 -0
- package/src/loop/README.md +18 -3
- package/src/loop/liveDiagramGate.js +63 -0
- package/src/loop/liveDiagramGate.ts +61 -0
- package/src/loop/messages.js +12 -0
- package/src/loop/messages.ts +13 -0
- package/src/loop/options.ts +7 -2
- package/src/loop/toolBatch.js +0 -1
- package/src/loop/toolBatch.ts +0 -1
- package/src/loop/toolExecutor.js +11 -46
- package/src/loop/toolExecutor.ts +12 -55
- package/src/loop/toolPresentation.js +2 -13
- package/src/loop/toolPresentation.ts +2 -13
- package/src/loop/toolSemantics.js +3 -4
- package/src/loop/toolSemantics.ts +4 -6
- package/src/loop.js +222 -214
- package/src/loop.ts +226 -232
- package/src/memory/dependencies.js +11 -3
- package/src/memory/dependencies.ts +11 -3
- package/src/memory/episodicMemory.js +59 -18
- package/src/memory/episodicMemory.ts +58 -18
- package/src/memory/experienceStore.js +4 -3
- package/src/memory/experienceStore.ts +4 -3
- package/src/memory/factCandidates.js +53 -1
- package/src/memory/factCandidates.ts +54 -1
- package/src/memory/factLearning.js +2 -2
- package/src/memory/factLearning.ts +2 -2
- package/src/memory/jobPolicy.js +24 -5
- package/src/memory/jobPolicy.ts +25 -5
- package/src/memory/learning.js +2 -2
- package/src/memory/learning.ts +2 -2
- package/src/memory/learningModel.js +7 -0
- package/src/memory/learningModel.ts +7 -0
- package/src/memory/managementService.js +8 -7
- package/src/memory/managementService.ts +16 -8
- package/src/memory/memoryService.js +104 -33
- package/src/memory/memoryService.ts +132 -28
- package/src/memory/projectKnowledge.js +39 -3
- package/src/memory/projectKnowledge.ts +45 -3
- package/src/memory/recallQuery.js +56 -0
- package/src/memory/recallQuery.ts +58 -0
- package/src/memory/retrieval.js +39 -10
- package/src/memory/retrieval.ts +46 -11
- package/src/memory/turnJournal.js +75 -10
- package/src/memory/turnJournal.ts +66 -10
- package/src/memory/types.ts +7 -1
- package/src/prompts/delegation.js +2 -1
- package/src/prompts/delegation.ts +3 -1
- package/src/prompts/policyRegistry.js +9 -12
- package/src/prompts/policyRegistry.ts +9 -12
- package/src/prompts/promptAssembly.js +6 -5
- package/src/prompts/promptAssembly.ts +8 -6
- package/src/prompts/visualPresentation.js +39 -10
- package/src/prompts/visualPresentation.ts +38 -9
- package/src/runtime/answerPreparation.js +1 -1
- package/src/runtime/answerPreparation.ts +1 -1
- package/src/runtime/fileLock.js +19 -3
- package/src/runtime/fileLock.ts +19 -3
- package/src/runtime/invocationPolicy.js +1 -1
- package/src/runtime/invocationPolicy.ts +1 -1
- package/src/runtime/outputContract.js +0 -4
- package/src/runtime/outputContract.ts +0 -7
- package/src/session/runAgentTurn.js +3 -0
- package/src/session/runAgentTurn.ts +3 -0
- package/src/team/onlineToolContract.js +0 -1
- package/src/team/onlineToolContract.ts +0 -1
- package/src/tools/advisor/advisor.js +207 -365
- package/src/tools/advisor/advisor.ts +211 -383
- package/src/tools/agent/agentTypes.js +0 -1
- package/src/tools/agent/agentTypes.ts +0 -1
- package/src/tools/agent/coordinator.js +4 -1
- package/src/tools/agent/coordinator.ts +4 -1
- package/src/tools/bash/semantics.js +4 -5
- package/src/tools/bash/semantics.ts +4 -5
- package/src/tools/capabilities.js +2 -3
- package/src/tools/capabilities.ts +2 -4
- package/src/tools/index.js +0 -16
- package/src/tools/index.ts +0 -16
- package/src/tools/memory/maintenance.js +8 -9
- package/src/tools/memory/maintenance.ts +13 -11
- package/src/tools/memory/memory.js +5 -1
- package/src/tools/memory/memory.ts +5 -0
- package/src/tools/optimizeConversation/README.md +4 -6
- package/src/tools/toolPromptGuides.js +2 -9
- package/src/tools/toolPromptGuides.ts +2 -9
- package/src/types.ts +28 -105
- package/src/ui/App.js +2 -0
- package/src/ui/App.tsx +1 -0
- package/src/ui/MessageList.js +8 -44
- package/src/ui/MessageList.tsx +3 -81
- package/src/ui/hooks/useChat.js +2 -0
- package/src/ui/hooks/useChat.ts +2 -0
- package/src/ui/toolProgress.js +1 -1
- package/src/ui/toolProgress.ts +1 -1
- package/src/web/protocol.ts +10 -3
- package/src/advisor/advisorCoordinator.js +0 -734
- package/src/advisor/advisorCoordinator.ts +0 -825
- package/src/cli/wizards/sshHostWizard.js +0 -156
- package/src/cli/wizards/sshHostWizard.tsx +0 -214
- package/src/context/sshHosts.js +0 -290
- package/src/context/sshHosts.ts +0 -312
- package/src/loop/publication.js +0 -94
- package/src/loop/publication.ts +0 -124
- package/src/runtime/finalAnswer.js +0 -9
- package/src/runtime/finalAnswer.ts +0 -13
- package/src/tools/advisor/advisorDecision.js +0 -79
- package/src/tools/advisor/advisorDecision.ts +0 -92
- package/src/tools/ssh/ssh.js +0 -152
- package/src/tools/ssh/ssh.ts +0 -167
- package/src/tools/ssh/sshAvailability.js +0 -35
- package/src/tools/ssh/sshAvailability.ts +0 -39
- package/src/tools/ssh/sshClient.js +0 -177
- package/src/tools/ssh/sshClient.ts +0 -189
- package/src/tools/ssh/sshDownload.js +0 -77
- package/src/tools/ssh/sshDownload.ts +0 -92
- package/src/tools/ssh/sshHostsTool.js +0 -36
- package/src/tools/ssh/sshHostsTool.ts +0 -48
- package/src/tools/ssh/sshUpload.js +0 -72
- 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
|
-
|
|
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
|
-
|
|
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: {
|
|
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
|
-
|
|
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' &&
|
|
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
|
-
?
|
|
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
|
-
|
|
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) ||
|
|
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
|
-
|
|
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
|
|
1196
|
-
|
|
1197
|
-
).candidates
|
|
1278
|
+
const knowledgeStatus = await projectKnowledgeStatus(workspaceId);
|
|
1279
|
+
const knowledgeCandidates = knowledgeStatus.candidates
|
|
1198
1280
|
.filter(
|
|
1199
1281
|
(item) =>
|
|
1200
|
-
item.
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|