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