@memory-river/core 0.2.0

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 (86) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +222 -0
  3. package/README.zh-TW.md +186 -0
  4. package/dist/api.d.ts +100 -0
  5. package/dist/api.js +156 -0
  6. package/dist/cognition/causal-attribution.d.ts +36 -0
  7. package/dist/cognition/causal-attribution.js +239 -0
  8. package/dist/cognition/causal-engine.d.ts +105 -0
  9. package/dist/cognition/causal-engine.js +150 -0
  10. package/dist/cognition/conflict-detector.d.ts +39 -0
  11. package/dist/cognition/conflict-detector.js +193 -0
  12. package/dist/cognition/global-working-memory.d.ts +53 -0
  13. package/dist/cognition/global-working-memory.js +211 -0
  14. package/dist/cognition/hooks-engine.d.ts +99 -0
  15. package/dist/cognition/hooks-engine.js +672 -0
  16. package/dist/cognition/ralph-core.d.ts +28 -0
  17. package/dist/cognition/ralph-core.js +104 -0
  18. package/dist/distill/concentrator-adapter.d.ts +167 -0
  19. package/dist/distill/concentrator-adapter.js +1876 -0
  20. package/dist/engine.d.ts +402 -0
  21. package/dist/engine.js +2254 -0
  22. package/dist/index.d.ts +6 -0
  23. package/dist/index.js +3 -0
  24. package/dist/lifecycle/cleanup-engine.d.ts +80 -0
  25. package/dist/lifecycle/cleanup-engine.js +162 -0
  26. package/dist/lifecycle/cleanup-state.d.ts +34 -0
  27. package/dist/lifecycle/cleanup-state.js +50 -0
  28. package/dist/lifecycle/night-consolidation.d.ts +102 -0
  29. package/dist/lifecycle/night-consolidation.js +640 -0
  30. package/dist/lifecycle/night-recovery.d.ts +40 -0
  31. package/dist/lifecycle/night-recovery.js +107 -0
  32. package/dist/paths.d.ts +17 -0
  33. package/dist/paths.js +16 -0
  34. package/dist/pipeline/capsule-bridge.d.ts +35 -0
  35. package/dist/pipeline/capsule-bridge.js +86 -0
  36. package/dist/pipeline/compact-request.d.ts +30 -0
  37. package/dist/pipeline/compact-request.js +66 -0
  38. package/dist/pipeline/inbox-watcher.d.ts +112 -0
  39. package/dist/pipeline/inbox-watcher.js +1039 -0
  40. package/dist/ports.d.ts +29 -0
  41. package/dist/ports.js +1 -0
  42. package/dist/providers/embedder-v5.d.ts +46 -0
  43. package/dist/providers/embedder-v5.js +155 -0
  44. package/dist/providers/ollama-embedding.d.ts +25 -0
  45. package/dist/providers/ollama-embedding.js +166 -0
  46. package/dist/retrieval/abstractness-judge.d.ts +14 -0
  47. package/dist/retrieval/abstractness-judge.js +87 -0
  48. package/dist/retrieval/coverage-selection.d.ts +3 -0
  49. package/dist/retrieval/coverage-selection.js +53 -0
  50. package/dist/retrieval/cross-encoder-gate.d.ts +40 -0
  51. package/dist/retrieval/cross-encoder-gate.js +239 -0
  52. package/dist/retrieval/retriever-v4.d.ts +78 -0
  53. package/dist/retrieval/retriever-v4.js +1200 -0
  54. package/dist/skills/validate.d.ts +6 -0
  55. package/dist/skills/validate.js +69 -0
  56. package/dist/storage.d.ts +19 -0
  57. package/dist/storage.js +54 -0
  58. package/dist/store/aux-table-maintenance.d.ts +5 -0
  59. package/dist/store/aux-table-maintenance.js +64 -0
  60. package/dist/store/graph-enumerator.d.ts +21 -0
  61. package/dist/store/graph-enumerator.js +185 -0
  62. package/dist/store/graph-store.d.ts +107 -0
  63. package/dist/store/graph-store.js +478 -0
  64. package/dist/store/status-manager.d.ts +44 -0
  65. package/dist/store/status-manager.js +235 -0
  66. package/dist/store/store-v4.d.ts +339 -0
  67. package/dist/store/store-v4.js +2871 -0
  68. package/dist/transcript/keyword-search.d.ts +9 -0
  69. package/dist/transcript/keyword-search.js +67 -0
  70. package/dist/transcript/rehydrate-keyword.d.ts +6 -0
  71. package/dist/transcript/rehydrate-keyword.js +29 -0
  72. package/dist/transcript/rehydrate.d.ts +33 -0
  73. package/dist/transcript/rehydrate.js +285 -0
  74. package/dist/transcript/transcript-archive.d.ts +46 -0
  75. package/dist/transcript/transcript-archive.js +516 -0
  76. package/dist/types.d.ts +409 -0
  77. package/dist/types.js +104 -0
  78. package/dist/util/bounded-map.d.ts +1 -0
  79. package/dist/util/bounded-map.js +8 -0
  80. package/dist/util/rate-limiter.d.ts +12 -0
  81. package/dist/util/rate-limiter.js +54 -0
  82. package/dist/util/session-identity.d.ts +65 -0
  83. package/dist/util/session-identity.js +227 -0
  84. package/dist/util/util-hash.d.ts +1 -0
  85. package/dist/util/util-hash.js +4 -0
  86. package/package.json +59 -0
@@ -0,0 +1,1876 @@
1
+ /**
2
+ * ConcentratorAdapter — memory-river 的濃縮膠囊 class wrapper (V8 終極重構版 + 幽靈清掃機)
3
+ *
4
+ * 【四步完美閉環架構】:
5
+ * 1. 60% 動態水位線精準觸發
6
+ * 2. 徹底解除 800 字物理腰斬,保持 MUD 遊戲情報原汁原味
7
+ * 3. 雙軌並行提煉:短期前情提要 (大膠囊, health: 30) + 長期精確記憶 (小紙條)
8
+ * 4. 完美回注與幽靈清理:注入 Context 頂端,並自動拔除斷片的 Tool Result
9
+ */
10
+ import * as path from 'node:path';
11
+ import * as fs from 'node:fs';
12
+ import { CapsuleBridge } from '../pipeline/capsule-bridge.js';
13
+ import { sharedLLMRateLimiter } from '../util/rate-limiter.js';
14
+ const CONCRETE_FACT_NOTE_CATEGORIES = new Set([
15
+ 'fact',
16
+ 'decision',
17
+ 'entity',
18
+ 'preference',
19
+ 'constraint',
20
+ 'identity',
21
+ 'knowledge',
22
+ 'history',
23
+ 'business',
24
+ ]);
25
+ function getConcentratorNoteImportanceThreshold(category) {
26
+ return typeof category === 'string' && CONCRETE_FACT_NOTE_CATEGORIES.has(category) ? 0.2 : 0.4;
27
+ }
28
+ export function passesConcentratorNoteImportanceFilter(item) {
29
+ return typeof item.importance === 'number'
30
+ && item.importance >= getConcentratorNoteImportanceThreshold(item.category);
31
+ }
32
+ // ─── Regex 過濾 helper ────────────────────────────────────────────────
33
+ function isSystemExecInjection(msg) {
34
+ if (msg.role !== 'user')
35
+ return false;
36
+ const raw = msg.content;
37
+ if (typeof raw === 'string')
38
+ return /^System:\s*\[.*?\]\s*Exec/i.test(raw);
39
+ if (Array.isArray(raw)) {
40
+ return raw.some((part) => part?.type === 'text' && typeof part.text === 'string' && /^System:\s*\[.*?\]\s*Exec/i.test(part.text));
41
+ }
42
+ return false;
43
+ }
44
+ const CONCENTRATION_FRAMEWORK_PATTERNS = [
45
+ 'A new session was started via /new or /reset',
46
+ 'Run your Session Startup sequence',
47
+ '✅ New session started · model:',
48
+ ];
49
+ const SESSION_INIT_PATTERNS = [
50
+ 'A new session was started via /new or /reset',
51
+ 'Run your Session Startup sequence',
52
+ ];
53
+ export function extractTextForConcentrationContent(content) {
54
+ if (typeof content === 'string')
55
+ return content.trim();
56
+ if (!Array.isArray(content))
57
+ return '';
58
+ return content
59
+ .map((part) => {
60
+ if (!part || typeof part !== 'object')
61
+ return '';
62
+ if (part.type !== 'text' || typeof part.text !== 'string')
63
+ return '';
64
+ return part.text.trim();
65
+ })
66
+ .filter(Boolean)
67
+ .join('\n')
68
+ .trim();
69
+ }
70
+ export function isFrameworkMetadataForConcentration(text) {
71
+ if (!text)
72
+ return false;
73
+ const trimmed = text.trimStart();
74
+ if (CONCENTRATION_FRAMEWORK_PATTERNS.some((pattern) => trimmed.includes(pattern))) {
75
+ return true;
76
+ }
77
+ return (trimmed.startsWith('Conversation info (untrusted metadata)') ||
78
+ trimmed.startsWith('[Inter-session message]') ||
79
+ trimmed.startsWith('Note: The previous agent run was aborted') ||
80
+ trimmed.startsWith('[media attached:') ||
81
+ trimmed.startsWith('(system)') ||
82
+ trimmed.startsWith('[metadata]'));
83
+ }
84
+ export function stripFrameworkMetadataForConcentration(text) {
85
+ if (!text)
86
+ return '';
87
+ let stripped = text.trim();
88
+ if (stripped.startsWith('[Inter-session message]')) {
89
+ const interSessionPrefix = stripped.match(/^\[Inter-session message\]\s+sourceSession=.*?\s+sourceChannel=.*?\s+sourceTool=.*?(?=\s+\[)/i);
90
+ if (interSessionPrefix) {
91
+ stripped = stripped.slice(interSessionPrefix[0].length).trimStart();
92
+ }
93
+ else {
94
+ stripped = stripped.replace(/^\[Inter-session message\][\s\S]*$/i, '').trim();
95
+ }
96
+ }
97
+ if (stripped.startsWith('Conversation info (untrusted metadata)')) {
98
+ stripped = stripped.replace(/^Conversation info \(untrusted metadata\)[\s\S]*?\n\n/i, '').trim();
99
+ }
100
+ if (stripped.startsWith('Sender (untrusted metadata):')) {
101
+ stripped = stripped
102
+ .replace(/^Sender \(untrusted metadata\):\s*```json[\s\S]*?```\s*/i, '')
103
+ .replace(/^Sender \(untrusted metadata\):\s*\{[\s\S]*?\}\s*/i, '')
104
+ .trim();
105
+ }
106
+ return stripped;
107
+ }
108
+ function normalizeComparableText(text) {
109
+ return stripFrameworkMetadataForConcentration(text).replace(/\s+/g, ' ').trim();
110
+ }
111
+ function isSessionInitMessageForProbe(text) {
112
+ return SESSION_INIT_PATTERNS.some((pattern) => text.includes(pattern));
113
+ }
114
+ export function buildComparableTranscriptPairs(messages) {
115
+ const pairs = [];
116
+ let pendingUser = null;
117
+ let pendingTimestamp = Date.now();
118
+ for (const msg of messages) {
119
+ const text = normalizeComparableText(extractTextForConcentrationContent(msg.content));
120
+ if (!text || isSessionInitMessageForProbe(text))
121
+ continue;
122
+ if (msg.role === 'user') {
123
+ pendingUser = text;
124
+ pendingTimestamp = msg.timestamp ?? Date.now();
125
+ continue;
126
+ }
127
+ if (msg.role === 'assistant' && pendingUser !== null) {
128
+ pairs.push({
129
+ user: pendingUser,
130
+ assistant: text,
131
+ timestamp: pendingTimestamp,
132
+ });
133
+ pendingUser = null;
134
+ }
135
+ }
136
+ return pairs;
137
+ }
138
+ function isSystemExecTranscriptCandidate(userText) {
139
+ return isSystemExecInjection({
140
+ role: 'user',
141
+ content: userText,
142
+ timestamp: 0,
143
+ });
144
+ }
145
+ export function buildComparableTranscriptCandidates(entries) {
146
+ const normalizedEntries = entries
147
+ .map((entry) => ({
148
+ entryId: entry.entryId,
149
+ user: normalizeComparableText(entry.user),
150
+ assistant: normalizeComparableText(entry.assistant),
151
+ timestamp: entry.timestamp,
152
+ }))
153
+ .filter((entry) => !isSystemExecTranscriptCandidate(entry.user));
154
+ const mergedEntries = [];
155
+ let pendingUserPrefix = '';
156
+ let pendingEntryIds = [];
157
+ for (const entry of normalizedEntries) {
158
+ if (entry.assistant.trim().length === 0) {
159
+ pendingUserPrefix = pendingUserPrefix
160
+ ? `${pendingUserPrefix} ${entry.user}`.trim()
161
+ : entry.user;
162
+ if (typeof entry.entryId === 'number' && entry.entryId > 0) {
163
+ pendingEntryIds.push(entry.entryId);
164
+ }
165
+ continue;
166
+ }
167
+ const mergedUser = pendingUserPrefix
168
+ ? `${pendingUserPrefix} ${entry.user}`.trim()
169
+ : entry.user;
170
+ const mergedFromEntryIds = [
171
+ ...pendingEntryIds,
172
+ ...(typeof entry.entryId === 'number' && entry.entryId > 0 ? [entry.entryId] : []),
173
+ ];
174
+ mergedEntries.push({
175
+ ...entry,
176
+ user: mergedUser,
177
+ mergedFromEntryIds: mergedFromEntryIds.length > 1 ? mergedFromEntryIds : undefined,
178
+ });
179
+ pendingUserPrefix = '';
180
+ pendingEntryIds = [];
181
+ }
182
+ return mergedEntries;
183
+ }
184
+ export function applyBoundaryHeuristicForProbe(candidateEntries, summarizePairCount) {
185
+ if (candidateEntries.length === summarizePairCount + 1 && candidateEntries.length > 0) {
186
+ return {
187
+ triggered: true,
188
+ originalCandidateCount: candidateEntries.length,
189
+ candidateEntries: candidateEntries.slice(0, -1),
190
+ droppedCandidate: candidateEntries[candidateEntries.length - 1],
191
+ };
192
+ }
193
+ return {
194
+ triggered: false,
195
+ originalCandidateCount: candidateEntries.length,
196
+ candidateEntries,
197
+ };
198
+ }
199
+ function charCodeOrNull(char) {
200
+ if (!char)
201
+ return null;
202
+ return char.codePointAt(0) ?? null;
203
+ }
204
+ function findFirstStringDiff(field, summarizeText, candidateText) {
205
+ const maxLength = Math.max(summarizeText.length, candidateText.length);
206
+ for (let index = 0; index < maxLength; index++) {
207
+ const summarizeChar = summarizeText[index] ?? '';
208
+ const candidateChar = candidateText[index] ?? '';
209
+ if (summarizeChar !== candidateChar) {
210
+ return {
211
+ field,
212
+ index,
213
+ summarizeChar,
214
+ summarizeCharCode: charCodeOrNull(summarizeChar),
215
+ candidateChar,
216
+ candidateCharCode: charCodeOrNull(candidateChar),
217
+ };
218
+ }
219
+ }
220
+ return null;
221
+ }
222
+ function previewText(text) {
223
+ return JSON.stringify(text.slice(0, 200));
224
+ }
225
+ function previewAlignmentText(text, limit) {
226
+ return JSON.stringify(text.slice(0, limit));
227
+ }
228
+ export function findProbeTextMismatchDetail(summarizePairs, candidateEntries) {
229
+ const maxLength = Math.min(summarizePairs.length, candidateEntries.length);
230
+ for (let index = 0; index < maxLength; index++) {
231
+ const summarizePair = summarizePairs[index];
232
+ const candidatePair = candidateEntries[index];
233
+ if (!candidatePair)
234
+ continue;
235
+ if (summarizePair.user !== candidatePair.user) {
236
+ const diff = findFirstStringDiff('user', summarizePair.user, candidatePair.user);
237
+ if (!diff)
238
+ continue;
239
+ return { mismatchIndex: index, summarizePair, candidatePair, diff };
240
+ }
241
+ if (summarizePair.assistant !== candidatePair.assistant) {
242
+ const diff = findFirstStringDiff('assistant', summarizePair.assistant, candidatePair.assistant);
243
+ if (!diff)
244
+ continue;
245
+ return { mismatchIndex: index, summarizePair, candidatePair, diff };
246
+ }
247
+ }
248
+ return null;
249
+ }
250
+ export function logProbeTextMismatchDetail(detail) {
251
+ console.log(`[P0-1 probe] text_mismatch detail: index=${detail.mismatchIndex} entryId=${detail.candidatePair.entryId ?? 'unknown'}`);
252
+ console.log(`[P0-1 probe] summarize.user[0..200]: ${previewText(detail.summarizePair.user)}`);
253
+ console.log(`[P0-1 probe] candidate.user[0..200]: ${previewText(detail.candidatePair.user)}`);
254
+ console.log(`[P0-1 probe] summarize.user.length=${detail.summarizePair.user.length} candidate.user.length=${detail.candidatePair.user.length}`);
255
+ console.log(`[P0-1 probe] summarize.assistant[0..200]: ${previewText(detail.summarizePair.assistant)}`);
256
+ console.log(`[P0-1 probe] candidate.assistant[0..200]: ${previewText(detail.candidatePair.assistant)}`);
257
+ console.log(`[P0-1 probe] summarize.assistant.length=${detail.summarizePair.assistant.length} candidate.assistant.length=${detail.candidatePair.assistant.length}`);
258
+ console.log(`[P0-1 probe] first diff at ${detail.diff.field} index ${detail.diff.index}: summarize=${JSON.stringify(detail.diff.summarizeChar)}(${detail.diff.summarizeCharCode ?? 'null'}) candidate=${JSON.stringify(detail.diff.candidateChar)}(${detail.diff.candidateCharCode ?? 'null'})`);
259
+ }
260
+ function logProbeAlignmentDump(summarizePairs, candidateEntries) {
261
+ const sharedLength = Math.min(summarizePairs.length, candidateEntries.length);
262
+ for (let index = 0; index < sharedLength; index++) {
263
+ const summarizePair = summarizePairs[index];
264
+ const candidatePair = candidateEntries[index];
265
+ const isMatch = summarizePair.user === candidatePair.user && summarizePair.assistant === candidatePair.assistant;
266
+ const mergedSuffix = candidatePair.mergedFromEntryIds?.length
267
+ ? ` (merged from [${candidatePair.mergedFromEntryIds.join(', ')}])`
268
+ : '';
269
+ console.log(`[P0-1 probe] align[${index}]: match=${isMatch ? 'yes' : 'no'} candEntryId=${candidatePair.entryId ?? 'unknown'}${mergedSuffix} candTs=${candidatePair.timestamp}`);
270
+ console.log(` s.user[0..80]=${previewAlignmentText(summarizePair.user, 80)}`);
271
+ console.log(` c.user[0..80]=${previewAlignmentText(candidatePair.user, 80)}`);
272
+ console.log(` s.assist[0..40]=${previewAlignmentText(summarizePair.assistant, 40)}`);
273
+ console.log(` c.assist[0..40]=${previewAlignmentText(candidatePair.assistant, 40)}`);
274
+ }
275
+ if (summarizePairs.length > candidateEntries.length) {
276
+ for (let index = candidateEntries.length; index < summarizePairs.length; index++) {
277
+ const summarizePair = summarizePairs[index];
278
+ console.log(`[P0-1 probe] align[${index}]: summarize-tail`);
279
+ console.log(` s.user[0..80]=${previewAlignmentText(summarizePair.user, 80)}`);
280
+ console.log(` s.assist[0..40]=${previewAlignmentText(summarizePair.assistant, 40)}`);
281
+ }
282
+ }
283
+ if (candidateEntries.length > summarizePairs.length) {
284
+ for (let index = summarizePairs.length; index < candidateEntries.length; index++) {
285
+ const candidatePair = candidateEntries[index];
286
+ const mergedSuffix = candidatePair.mergedFromEntryIds?.length
287
+ ? ` (merged from [${candidatePair.mergedFromEntryIds.join(', ')}])`
288
+ : '';
289
+ console.log(`[P0-1 probe] align[${index}]: candidate-tail candEntryId=${candidatePair.entryId ?? 'unknown'}${mergedSuffix} candTs=${candidatePair.timestamp}`);
290
+ console.log(` c.user[0..80]=${previewAlignmentText(candidatePair.user, 80)}`);
291
+ console.log(` c.assist[0..40]=${previewAlignmentText(candidatePair.assistant, 40)}`);
292
+ }
293
+ }
294
+ }
295
+ function buildSourceEntryIds(candidateEntries) {
296
+ const ids = [];
297
+ const seen = new Set();
298
+ for (const entry of candidateEntries) {
299
+ const entryIds = entry.mergedFromEntryIds?.length
300
+ ? entry.mergedFromEntryIds
301
+ : [entry.entryId];
302
+ for (const entryId of entryIds) {
303
+ if (typeof entryId !== 'number' || entryId <= 0 || seen.has(entryId))
304
+ continue;
305
+ seen.add(entryId);
306
+ ids.push(entryId);
307
+ }
308
+ }
309
+ return ids;
310
+ }
311
+ function probeSourceEntryIdMatch(transcriptArchive, messagesToSummarize, sessionIdentity, firstTimestamp, lastTimestamp) {
312
+ if (!sessionIdentity)
313
+ return null;
314
+ const summarizePairs = buildComparableTranscriptPairs(messagesToSummarize);
315
+ const rawCandidateEntries = buildComparableTranscriptCandidates(transcriptArchive.getRawTranscript(sessionIdentity, firstTimestamp)
316
+ .filter((entry) => entry.timestamp <= lastTimestamp));
317
+ const boundaryHeuristic = applyBoundaryHeuristicForProbe(rawCandidateEntries, summarizePairs.length);
318
+ if (boundaryHeuristic.triggered) {
319
+ console.log(`[P0-1 probe] boundary heuristic: drop last candidate (entryId=${boundaryHeuristic.droppedCandidate?.entryId ?? 'unknown'}, ts=${boundaryHeuristic.droppedCandidate?.timestamp ?? 'unknown'}, summarizePairs=${summarizePairs.length}, candidateCount=${boundaryHeuristic.originalCandidateCount})`);
320
+ }
321
+ const candidateEntries = boundaryHeuristic.candidateEntries;
322
+ if (candidateEntries.length < summarizePairs.length) {
323
+ return {
324
+ summarizePairCount: summarizePairs.length,
325
+ candidateCount: candidateEntries.length,
326
+ matched: false,
327
+ matchedEntryIds: [],
328
+ sourceEntryIds: [],
329
+ reason: 'archive_lag',
330
+ };
331
+ }
332
+ if (candidateEntries.length > summarizePairs.length) {
333
+ return {
334
+ summarizePairCount: summarizePairs.length,
335
+ candidateCount: candidateEntries.length,
336
+ matched: false,
337
+ matchedEntryIds: [],
338
+ sourceEntryIds: [],
339
+ reason: 'count_mismatch',
340
+ };
341
+ }
342
+ const sameOrder = summarizePairs.every((pair, index) => {
343
+ const candidate = candidateEntries[index];
344
+ return candidate?.user === pair.user && candidate?.assistant === pair.assistant;
345
+ });
346
+ if (sameOrder) {
347
+ return {
348
+ summarizePairCount: summarizePairs.length,
349
+ candidateCount: candidateEntries.length,
350
+ matched: true,
351
+ matchedEntryIds: candidateEntries
352
+ .map((entry) => entry.entryId)
353
+ .filter((entryId) => typeof entryId === 'number' && entryId > 0),
354
+ sourceEntryIds: buildSourceEntryIds(candidateEntries),
355
+ };
356
+ }
357
+ const summarizeKeys = summarizePairs.map((pair) => `${pair.user}\u001f${pair.assistant}`).sort();
358
+ const candidateKeys = candidateEntries.map((pair) => `${pair.user}\u001f${pair.assistant}`).sort();
359
+ const sameSet = summarizeKeys.length === candidateKeys.length
360
+ && summarizeKeys.every((key, index) => key === candidateKeys[index]);
361
+ const reason = sameSet ? 'order_mismatch' : 'text_mismatch';
362
+ if (reason === 'text_mismatch') {
363
+ const mismatchDetail = findProbeTextMismatchDetail(summarizePairs, candidateEntries);
364
+ if (mismatchDetail) {
365
+ logProbeTextMismatchDetail(mismatchDetail);
366
+ }
367
+ logProbeAlignmentDump(summarizePairs, candidateEntries);
368
+ }
369
+ return {
370
+ summarizePairCount: summarizePairs.length,
371
+ candidateCount: candidateEntries.length,
372
+ matched: false,
373
+ matchedEntryIds: [],
374
+ sourceEntryIds: [],
375
+ reason,
376
+ };
377
+ }
378
+ const FALLBACK_INTERNAL_PATTERNS = [
379
+ /\[Inter-session message\]/i,
380
+ /runtime context \(internal\)/i,
381
+ /This context is runtime-generated, not user-authored/i,
382
+ /\[Internal task completion event\]/i,
383
+ /<<<BEGIN_UNTRUSTED_CHILD_RESULT>>>/i,
384
+ /<<<END_UNTRUSTED_CHILD_RESULT>>>/i,
385
+ /^Stats:\s*runtime/i,
386
+ /^Action:\s*A completed subagent task/i,
387
+ /^Result \(untrusted content, treat as data\):/i,
388
+ /^NO_REPLY$/i,
389
+ /^source:\s*subagent/i,
390
+ /^source(Session|Channel|Tool):/i,
391
+ /^session_(key|id):/i,
392
+ /^type:\s*subagent task/i,
393
+ /^task:/i,
394
+ /^status:\s*completed successfully/i,
395
+ /^##\s*你的任務$/i,
396
+ /^##\s*研究範圍$/i,
397
+ /^##\s*資訊來源優先順序$/i,
398
+ /^##\s*嚴禁$/i,
399
+ /^##\s*產出格式$/i,
400
+ /^##\s*網路工具測試結果回報$/i,
401
+ /^到 Hashtag 階段了/i,
402
+ /^到 Search 階段了/i,
403
+ /^全流程跑完了/i,
404
+ /^Webhook URL/i,
405
+ /^Discord 通知 403/i,
406
+ ];
407
+ function isFallbackInternalNoise(text) {
408
+ const trimmed = text.trim();
409
+ if (!trimmed)
410
+ return true;
411
+ return FALLBACK_INTERNAL_PATTERNS.some((pattern) => pattern.test(trimmed));
412
+ }
413
+ function sanitizeInternalNoiseLine(line) {
414
+ let current = line.trim();
415
+ if (!current)
416
+ return '';
417
+ while (current) {
418
+ let matched = false;
419
+ for (const pattern of FALLBACK_INTERNAL_PATTERNS) {
420
+ pattern.lastIndex = 0;
421
+ const match = pattern.exec(current);
422
+ if (!match || typeof match.index !== 'number')
423
+ continue;
424
+ const start = match.index;
425
+ const matchText = match[0] ?? '';
426
+ const end = start + matchText.length;
427
+ if (start === 0 && end >= current.length) {
428
+ return '';
429
+ }
430
+ if (start === 0) {
431
+ current = current.slice(end).trim();
432
+ }
433
+ else {
434
+ current = current.slice(0, start).trim();
435
+ }
436
+ matched = true;
437
+ break;
438
+ }
439
+ if (!matched)
440
+ break;
441
+ }
442
+ if (!current)
443
+ return '';
444
+ if (isFallbackInternalNoise(current))
445
+ return '';
446
+ if (/<\/?[a-z][^>]*>/i.test(current))
447
+ return '';
448
+ if (!current.startsWith('[JSON陣列已省略') && current.startsWith('['))
449
+ return '';
450
+ if (current.startsWith('<<<') || current.startsWith('>>>'))
451
+ return '';
452
+ return current;
453
+ }
454
+ function sanitizeInternalNoiseText(text) {
455
+ if (!text)
456
+ return '';
457
+ const stripped = stripFrameworkMetadataForConcentration(text);
458
+ if (!stripped)
459
+ return '';
460
+ const lines = stripped
461
+ .split('\n')
462
+ .map((line) => sanitizeInternalNoiseLine(line))
463
+ .filter(Boolean);
464
+ const joined = lines.join('\n').trim();
465
+ return isFallbackInternalNoise(joined) ? '' : joined;
466
+ }
467
+ function sanitizeForFallbackSummary(text) {
468
+ return sanitizeInternalNoiseText(text);
469
+ }
470
+ function selectRecentFallbackWindow(items) {
471
+ if (items.length === 0)
472
+ return [];
473
+ const conversationalItems = items.filter((item) => item.role === 'user' || item.role === 'assistant');
474
+ if (conversationalItems.length === 0)
475
+ return [];
476
+ let anchor = -1;
477
+ for (let i = conversationalItems.length - 1; i >= 0; i--) {
478
+ if (conversationalItems[i].role === 'user' && conversationalItems[i].text.trim().length >= 8) {
479
+ anchor = i;
480
+ break;
481
+ }
482
+ }
483
+ if (anchor === -1) {
484
+ return conversationalItems.slice(-6);
485
+ }
486
+ const window = conversationalItems.slice(anchor, Math.min(conversationalItems.length, anchor + 8));
487
+ return window.length > 0 ? window : conversationalItems.slice(-6);
488
+ }
489
+ // ─── 終極深層防爆破截斷器 V2 (防禦「蟲群」陣列攻擊) ───
490
+ function truncateGiantStrings(msg) {
491
+ const MAX_STRING_LENGTH = 15000;
492
+ const MAX_ARRAY_LENGTH = 50;
493
+ const MAX_OBJECT_KEYS = 50;
494
+ function truncateDeep(obj) {
495
+ if (typeof obj === 'string') {
496
+ return obj.length > MAX_STRING_LENGTH
497
+ ? obj.substring(0, MAX_STRING_LENGTH) + "\n...[字串過長已截斷]..."
498
+ : obj;
499
+ }
500
+ if (Array.isArray(obj)) {
501
+ if (obj.length > MAX_ARRAY_LENGTH) {
502
+ const sliced = obj.slice(0, MAX_ARRAY_LENGTH).map(item => truncateDeep(item));
503
+ sliced.push(`\n...[系統保護:陣列資料過多,原本 ${obj.length} 筆,已強制截斷剩前 ${MAX_ARRAY_LENGTH} 筆]...`);
504
+ return sliced;
505
+ }
506
+ return obj.map(item => truncateDeep(item));
507
+ }
508
+ if (typeof obj === 'object' && obj !== null) {
509
+ const newObj = {};
510
+ let keyCount = 0;
511
+ for (const key in obj) {
512
+ if (keyCount >= MAX_OBJECT_KEYS) {
513
+ newObj["_SYSTEM_WARNING_"] = `...[系統保護:物件 Key 過多,已強制截斷]...`;
514
+ break;
515
+ }
516
+ newObj[key] = truncateDeep(obj[key]);
517
+ keyCount++;
518
+ }
519
+ return newObj;
520
+ }
521
+ return obj;
522
+ }
523
+ return truncateDeep(msg);
524
+ }
525
+ function stripThinkingBlocks(msg) {
526
+ if (msg.role !== 'assistant')
527
+ return msg;
528
+ const cleaned = JSON.parse(JSON.stringify(msg));
529
+ if (typeof cleaned.content === 'string') {
530
+ cleaned.content = cleaned.content
531
+ .replace(/<thinking>[\s\S]*?<\/thinking>/gi, '')
532
+ .replace(/<think>[\s\S]*?<\/think>/gi, '')
533
+ .replace(/<antThinking>[\s\S]*?<\/antThinking>/gi, '')
534
+ .replace(/<reasoning>[\s\S]*?<\/reasoning>/gi, '')
535
+ .replace(/<\/reasoning>/gi, '')
536
+ .replace(/\[Reasoning:\s*\][\s\S]*?\[\/Reasoning\]/gi, '')
537
+ .replace(/\[Reasoning\][\s\S]*?\[\/Reasoning\]/gi, '')
538
+ .replace(/\[Reasoning:[^\]]*\][\s\S]*?\[\/Reasoning\]/gi, '')
539
+ .replace(/\[Reasoning:[^\]]*\]/gi, '')
540
+ .replace(/\[\/Reasoning\]/gi, '')
541
+ .trim();
542
+ }
543
+ else if (Array.isArray(cleaned.content)) {
544
+ cleaned.content.forEach(part => {
545
+ if (part && part.type === 'text' && typeof part.text === 'string') {
546
+ // 🛠️ 補齊所有思考區塊的過濾網!
547
+ part.text = part.text
548
+ .replace(/<thinking>[\s\S]*?<\/thinking>/gi, '')
549
+ .replace(/<think>[\s\S]*?<\/think>/gi, '')
550
+ .replace(/<antThinking>[\s\S]*?<\/antThinking>/gi, '')
551
+ .replace(/<reasoning>[\s\S]*?<\/reasoning>/gi, '')
552
+ .replace(/<\/reasoning>/gi, '')
553
+ .replace(/\[Reasoning:\s*\][\s\S]*?\[\/Reasoning\]/gi, '')
554
+ .replace(/\[Reasoning\][\s\S]*?\[\/Reasoning\]/gi, '')
555
+ .replace(/\[Reasoning:[^\]]*\][\s\S]*?\[\/Reasoning\]/gi, '')
556
+ .replace(/\[Reasoning:[^\]]*\]/gi, '')
557
+ .replace(/\[\/Reasoning\]/gi, '')
558
+ .trim();
559
+ }
560
+ });
561
+ }
562
+ delete cleaned.tool_calls;
563
+ return cleaned;
564
+ }
565
+ // ─── 濃縮前置過濾器 (Claude 究極四層降噪濾網版 - UI 防崩潰 Hex 編碼) ───
566
+ function preFilterForConcentration(messages) {
567
+ const seenCodeBlocks = new Set();
568
+ let beforeLength = 0;
569
+ let afterLength = 0;
570
+ // 用於記錄訊息結構診斷 Log
571
+ const stats = {};
572
+ const filtered = messages.map(msg => {
573
+ let cleanMsg = JSON.parse(JSON.stringify(msg));
574
+ const role = cleanMsg.role || 'unknown';
575
+ // 估算原始長度與統計
576
+ const msgStr = extractTextForConcentrationContent(cleanMsg.content);
577
+ const currentLen = msgStr.length;
578
+ beforeLength += currentLen;
579
+ if (!stats[role])
580
+ stats[role] = { count: 0, length: 0 };
581
+ stats[role].count++;
582
+ stats[role].length += currentLen;
583
+ // ==========================================
584
+ // 🛡️ 第 1 層:完全移除 tool/function/tool_result/toolResult
585
+ // ==========================================
586
+ if (['tool', 'function', 'tool_result', 'toolResult'].includes(role)) {
587
+ return null;
588
+ }
589
+ // 處理 assistant 中的 tool_call JSON (替換為省略標記)
590
+ if (role === 'assistant') {
591
+ if (cleanMsg.tool_calls || cleanMsg.function_call) {
592
+ delete cleanMsg.tool_calls;
593
+ delete cleanMsg.function_call;
594
+ if (typeof cleanMsg.content === 'string') {
595
+ cleanMsg.content += '\n[工具呼叫已省略]';
596
+ }
597
+ else if (Array.isArray(cleanMsg.content)) {
598
+ cleanMsg.content.push({ type: 'text', text: '\n[工具呼叫已省略]' });
599
+ }
600
+ else if (!cleanMsg.content) {
601
+ cleanMsg.content = '[工具呼叫已省略]';
602
+ }
603
+ }
604
+ }
605
+ // 若 content 是陣列,先清理裡面的 tool 區塊
606
+ if (Array.isArray(cleanMsg.content)) {
607
+ cleanMsg.content = cleanMsg.content.filter((part) => part.type !== 'tool_result' && part.type !== 'tool_use');
608
+ if (cleanMsg.content.length === 0)
609
+ return null;
610
+ const normalized = extractTextForConcentrationContent(cleanMsg.content);
611
+ if (!normalized)
612
+ return null;
613
+ cleanMsg.content = sanitizeInternalNoiseText(normalized);
614
+ if (!cleanMsg.content)
615
+ return null;
616
+ }
617
+ if (typeof cleanMsg.content === 'string') {
618
+ cleanMsg.content = sanitizeInternalNoiseText(cleanMsg.content);
619
+ if (!cleanMsg.content)
620
+ return null;
621
+ if (isFrameworkMetadataForConcentration(cleanMsg.content)) {
622
+ return null;
623
+ }
624
+ // ==========================================
625
+ // 🛡️ 第 2 層:Code block 去重 + 截斷到 300 字
626
+ // 這裡使用 \x60 取代反引號,防止聊天室 UI 解析崩潰!
627
+ // ==========================================
628
+ cleanMsg.content = cleanMsg.content.replace(/\x60\x60\x60[\s\S]*?\x60\x60\x60/g, (match) => {
629
+ const signature = match.substring(0, 100);
630
+ if (seenCodeBlocks.has(signature)) {
631
+ return '\n[重複代碼已省略]\n';
632
+ }
633
+ seenCodeBlocks.add(signature);
634
+ if (match.length > 300) {
635
+ return match.substring(0, 300) + '\n...\n[代碼過長已截斷]\n\x60\x60\x60';
636
+ }
637
+ return match;
638
+ });
639
+ // ==========================================
640
+ // 🛡️ 第 3 層:大型 JSON 物件/陣列 (>500字) — 結構特徵判定
641
+ // 不用 JSON.parse(截斷過的 JSON 永遠 parse 失敗),改用長度+引號特徵
642
+ // ==========================================
643
+ cleanMsg.content = cleanMsg.content.replace(/\{[\s\S]{500,}?\}/g, (match) => {
644
+ if (match.includes('"') && (match.includes('":') || match.includes('" :'))) {
645
+ return `[JSON物件已省略(${match.length}字)]`;
646
+ }
647
+ return match;
648
+ });
649
+ cleanMsg.content = cleanMsg.content.replace(/\[[\s\S]{500,}?\]/g, (match) => {
650
+ if (match.includes('"') || match.includes('{')) {
651
+ return `[JSON陣列已省略(${match.length}字)]`;
652
+ }
653
+ return match;
654
+ });
655
+ // ==========================================
656
+ // 🛡️ 第 4 層:Role-aware 截斷
657
+ // assistant 訊息上限 2000 字(結論在頭尾,code 已被第 2 層砍過)
658
+ // user 訊息上限 3000 字(保留更多原始意圖)
659
+ // ==========================================
660
+ const maxChars = role === 'assistant' ? 2000 : 3000;
661
+ if (cleanMsg.content.length > maxChars) {
662
+ const len = cleanMsg.content.length;
663
+ const headLen = Math.floor(maxChars * 0.7);
664
+ const tailLen = Math.floor(maxChars * 0.3);
665
+ const head = cleanMsg.content.substring(0, headLen);
666
+ const tail = cleanMsg.content.substring(len - tailLen);
667
+ cleanMsg.content = `${head}\n\n...[訊息過長已截斷,保留頭尾 (原長 ${len} 字)]...\n\n${tail}`;
668
+ }
669
+ }
670
+ // 估算過濾後長度
671
+ const afterStr = extractTextForConcentrationContent(cleanMsg.content);
672
+ afterLength += afterStr.length;
673
+ return cleanMsg;
674
+ }).filter(Boolean);
675
+ // 印出訊息結構診斷 Log
676
+ const statsArr = Object.entries(stats).map(([role, data]) => {
677
+ const wCount = (data.length / 10000).toFixed(1);
678
+ return `${role}:${data.count}則/${wCount}萬字`;
679
+ });
680
+ console.log(`[ConcentratorAdapter] Message structure: ${statsArr.join(', ')}`);
681
+ // 印出雜訊過濾 Log
682
+ if (beforeLength > 0) {
683
+ const reducePercent = Math.round((1 - afterLength / beforeLength) * 100);
684
+ console.log(`[ConcentratorAdapter] Noise filtering: ${(beforeLength / 10000).toFixed(1)} ten-thousand chars -> ${(afterLength / 10000).toFixed(1)} ten-thousand chars (reduced ${reducePercent}%)`);
685
+ }
686
+ return filtered;
687
+ }
688
+ // ─── 幽靈工具回傳清理 (Ghost Tool Call Cleaner) ───────────────────────
689
+ function finalizeMessages(newMessages) {
690
+ const activeCallIds = new Set();
691
+ for (const m of newMessages) {
692
+ if (m.role === 'assistant') {
693
+ if (m.tool_calls) {
694
+ m.tool_calls.forEach((c) => c.id && activeCallIds.add(c.id));
695
+ }
696
+ if (Array.isArray(m.content)) {
697
+ m.content.forEach((part) => {
698
+ const callId = part.id || part.callId || part.tool_call_id || part.toolCallId || part.tool_use_id || part.function_call_id;
699
+ if (callId)
700
+ activeCallIds.add(callId);
701
+ });
702
+ }
703
+ }
704
+ }
705
+ let finalizedMessages = [];
706
+ for (const m of newMessages) {
707
+ const role = m.role;
708
+ if (role === 'tool' || role === 'function' || role === 'tool_result' || role === 'toolResult') {
709
+ const callId = m.tool_call_id || m.toolCallId || m.callId || m.id || m.name;
710
+ if (callId && !activeCallIds.has(callId)) {
711
+ continue;
712
+ }
713
+ }
714
+ if (Array.isArray(m.content)) {
715
+ let hasOrphan = false;
716
+ const cleanedContent = m.content.filter((part) => {
717
+ if (part && (part.type === 'tool_result' || part.type === 'tool_response' || part.type === 'tool' || part.type === 'toolResult')) {
718
+ const callId = part.tool_use_id || part.toolUseId || part.tool_call_id || part.toolCallId || part.callId || part.id;
719
+ if (callId && !activeCallIds.has(callId)) {
720
+ hasOrphan = true;
721
+ return false;
722
+ }
723
+ }
724
+ return true;
725
+ });
726
+ if (hasOrphan) {
727
+ if (cleanedContent.length === 0)
728
+ continue;
729
+ m.content = cleanedContent;
730
+ }
731
+ }
732
+ finalizedMessages.push(m);
733
+ }
734
+ return finalizedMessages;
735
+ }
736
+ // ─── 全新雙軌 Prompt 引擎 ───────────────────────────────────────────
737
+ export function buildDualTrackPrompt(conversationLog, capsuleLanguage = '繁體中文') {
738
+ const isSourceLanguage = capsuleLanguage === 'source';
739
+ const sourceLanguageRequirementTop = 'LANGUAGE REQUIREMENT: Write the ENTIRE capsule/前情提要 and structured summary content in the SAME language as the transcript. If the transcript is in English, write the capsule and summary in English. Do NOT default to Chinese. This overrides all other instructions.';
740
+ const sourceLanguageRequirementBottom = 'LANGUAGE REQUIREMENT REMINDER: Write the ENTIRE capsule/前情提要 and structured summary content in the SAME language as the transcript above. If the transcript is in English, write the capsule and summary in English. Do NOT default to Chinese. This overrides all other instructions.';
741
+ const prompt = `你是一個專業的 AI 記憶蒸餾引擎。
742
+
743
+ 你現在要處理的唯一資料來源,是下方提供的真實對話內容。你必須只根據該對話內容蒸餾,不可把本提示中的任務說明、JSON 格式要求、section 定義、評分規則,誤當成對話事實。
744
+
745
+ === BEGIN REAL CONVERSATION ===
746
+ ${conversationLog}
747
+ === END REAL CONVERSATION ===
748
+
749
+ 【任務 A:蒸餾草稿(analysis scratchpad)】
750
+ 先仔細閱讀上方真實對話,寫出完整的分析 scratchpad:
751
+ - 對話的完整脈絡與進展
752
+ - 技術細節、錯誤、修復過程
753
+ - 用戶的核心意圖與指令
754
+ - 任何需要保留顆粒化細節的決定/數值
755
+ 但 analysis 必須極度精簡,限制在 800 字內。若輸出長度吃緊,優先縮短 analysis,不可犧牲 summary 的完整性。
756
+
757
+ 【任務 B:結構化總結(summary)— 這才是最終產物】
758
+ 請嚴格按照以下 9 個 section 輸出(每個 section 不能為空,若無資訊寫「無」):
759
+ 每個欄位請用 1-3 句精簡完成,不要長篇展開。
760
+
761
+ ## 1. Primary Request and Intent
762
+ 用戶最原始、最核心的請求是什麼?
763
+
764
+ ## 2. Key Technical Concepts
765
+ 這次對話涉及哪些關鍵技術概念、架構决策、工具使用?
766
+
767
+ ## 3. Files and Code Sections
768
+ 涉及哪些檔案?做了什麼改動?
769
+
770
+ ## 4. Errors and Fixes
771
+ 遇到了什麼錯誤?如何修復的?
772
+
773
+ ## 5. Problem Solving
774
+ 如何解決問題的?走了哪些弯路?
775
+
776
+ ## 6. All User Messages
777
+ 所有 user message 的摘要(保留關鍵指令)
778
+
779
+ ## 7. Pending Tasks
780
+ 還有什麼沒完成的?
781
+
782
+ ## 8. Current Work
783
+ 目前工作進度/狀態
784
+
785
+ ## 9. Optional Next Step
786
+ 下一步建議(可選)
787
+
788
+ 【顆粒化長期記憶(notes)】
789
+ 從真實對話中提取所有可被獨立詢問與回答的具體事實,包括明確名稱、地點、日期、數值、物件、偏好、決定與結果。不得僅因某項事實看似重要性較低而丟棄;只要脫離其他 notes 後仍能獨立理解與檢索,就應收錄。
790
+ 以可檢索事實的覆蓋率優先於簡短。notes 筆數上限為 min(20, max(12, 對話輪數 × 2));在上限內完整收錄實際存在的合格事實,不得為達數量填入無具體內容的文字。仍須排除純社交寒暄、無內容的輪次與噪音;沒有合格事實時輸出空陣列。
791
+
792
+ ⚠️ 下面的時間正規化與結構欄位是「對已萃取事實的加值(enrichment)」,不是「萃取什麼」的篩選條件。**絕不可因為某事實沒有日期/數值/可結構化欄位就略過它**;上述收錄廣度(偏好、關係、情緒狀態、決定脈絡等)一字不減,照常收錄為純 text note。
793
+
794
+ 【時間正規化】對話每個 turn 前綴有 [at=<ISO>] 絕對時間戳。當事實含「昨天/今天/明天/上週/下個月/剛才/之後」等相對時間,必須以該事實所在 turn 的 at 為錨,在 text 內寫出絕對日期(例:「在 2023-05-07(原文稱昨天)參加了…」),並填 when 欄位。若 at=unknown 或語意不足以唯一解析,保留原文相對詞、不可猜,when.precision 與 when.source 設為 "unknown"。明確日期、月份、年份、區間、期限、事件先後順序都要保留,不要只寫「最近」「之前」。
795
+
796
+ 每筆格式:
797
+ { "text": "...", "category": "fact|decision|entity|preference|constraint|identity|knowledge|history|business|other", "importance": 0.0-1.0, "tags": ["..."], "subject": "主體(選配)", "predicate": "關係/屬性/動作(選配)", "value": "值,字串/數字/布林(選配)", "unit": "數值單位(選配)", "when": { "start": "YYYY-MM-DD 或 ISO(選配)", "end": "(選配)", "precision": "datetime|date|month|year|range|unknown", "sourceText": "原文時間詞", "source": "explicit|relative_anchored|contextual|unknown", "anchor": "錨定用的 turn ISO 時間" } }
798
+ (subject/predicate/value/unit/entities/when 全為選配:有對應資訊才填,沒有就整個省略,不可填空字串或編造。text 仍是主要可讀、可檢索內容;日期、數值、人名、地名、檔案名、產品名、事件名也必須同時寫進 text,能正規化的日期同時寫入 text 與 when。)
799
+
800
+ 收錄:
801
+ - 已確認的決策,以及影響決策的關鍵理由、作用域或條件。
802
+ - 仍有效的承諾、待辦與計畫;必要時寫明責任人、期限、觸發條件或目前狀態。
803
+ - 穩定且可操作的偏好、身份、關係、長期目標與約束(含來源與作用域)。
804
+ - 經驗證且不易重新推導的技術結論、環境特性、根因、失敗方案及其原因。
805
+ - 對既有記憶的更正、取消或取代;清楚指出何者已失效以及新結論。
806
+
807
+ 不要收錄:
808
+ - 問候、稱讚、泛泛建議、對話流程描述、模型自評或「已提供協助」等通用後設敘述。
809
+ - 一次性命令、短暫狀態、容易重新查得的通識,或僅為 capsule 敘事服務的細節。
810
+ - 尚未確認的猜測、未被接受的方案,或由語氣推測出的身份與偏好。
811
+ - 與另一筆 note 意義相同的重述。
812
+
813
+ 寫作要求:
814
+ - 一筆只表達一個可獨立更新的主張,必須自足:寫明主體、內容、作用域,以及必要的時間、狀態、條件或理由;不得使用「這個」「上述方案」「已處理」等脫離原文便無法理解的指涉。
815
+ - 保留足以語意檢索與回查原始 transcript 的專案、元件、人物或事件名稱,但不要複製整段對話、長篇推理、日誌或操作過程(原文可由 rehydrate 取回)。
816
+ - category 選最能代表該主張長期用途者;tags 用少量具辨識力的實體/專案/領域/狀態詞。
817
+ - importance 依「跨時間耐久性 × 未來決策效用」評分,不依篇幅、情緒強度或主題是否熱門;較低分代表耐久性或決策效用較低,但不得因此省略可獨立檢索的具體事實。
818
+
819
+ CRITICAL INSTRUCTION: Output ONLY valid, raw JSON. Do NOT wrap in markdown code blocks. Start with '{' and end with '}'.
820
+
821
+ confidence 評分標準(0.0–1.0):
822
+ - 0.8+:細節保留完整,能直接回答具體事實(數字、人名、程式碼)
823
+ - 0.4–0.7:涵蓋但壓縮率高,細節可能失真或遺漏
824
+ - <0.4:只能回答抽象概述,具體數字、名字、檔案名等流失
825
+
826
+ {
827
+ "analysis": "這裡放任務A的蒸餾草稿...",
828
+ "confidence": 0.85,
829
+ "summary": {
830
+ "primaryRequest": "...",
831
+ "technicalConcepts": "...",
832
+ "filesAndCode": "...",
833
+ "errorsAndFixes": "...",
834
+ "problemSolving": "...",
835
+ "userMessages": "...",
836
+ "pendingTasks": "...",
837
+ "currentWork": "...",
838
+ "nextStep": "..."
839
+ },
840
+ "notes": [
841
+ {
842
+ "text": "精確的顆粒化記憶(含足夠上下文,獨立可理解;日期/數值/人名要寫進 text)",
843
+ "category": "fact|decision|entity|preference|constraint|identity|knowledge|history|business|other",
844
+ "importance": 0.0-1.0,
845
+ "tags": [],
846
+ "subject": "主體(選配,省略則整個不要出現)",
847
+ "predicate": "關係/屬性/動作(選配)",
848
+ "value": "值,字串/數字/布林(選配)",
849
+ "unit": "單位(選配)",
850
+ "when": { "start": "YYYY-MM-DD(選配)", "precision": "date", "sourceText": "原文時間詞", "source": "relative_anchored", "anchor": "turn ISO 時間" }
851
+ }
852
+ ]
853
+ }
854
+
855
+ 額外限制:
856
+ - notes 筆數上限為 min(20, max(12, 對話輪數 × 2))
857
+ - 若輸出即將過長,先縮短 analysis,不可任意刪除合格 notes
858
+ - 目標是回傳可完整 JSON.parse 的有效 JSON,不可輸出半截 JSON`;
859
+ return isSourceLanguage ? `${sourceLanguageRequirementTop}\n\n${prompt}\n\n${sourceLanguageRequirementBottom}` : prompt;
860
+ }
861
+ export function buildGeneralConversationPrompt(conversationLog, capsuleLanguage = '繁體中文') {
862
+ const isSourceLanguage = capsuleLanguage === 'source';
863
+ const sourceLanguageRequirementTop = 'LANGUAGE REQUIREMENT: Write the ENTIRE capsule/前情提要 in the SAME language as the transcript. If the transcript is in English, write the capsule in English. Do NOT default to Chinese. This overrides all other instructions.';
864
+ const sourceLanguageRequirementBottom = 'LANGUAGE REQUIREMENT REMINDER: Write the ENTIRE capsule/前情提要 in the SAME language as the transcript above. If the transcript is in English, write the capsule in English. Do NOT default to Chinese. This overrides all other instructions.';
865
+ const capsuleLanguageInstruction = isSourceLanguage
866
+ ? '用與上方真實對話相同的語言'
867
+ : `用自然流暢的${capsuleLanguage}`;
868
+ const prompt = `你是一個專業的 AI 記憶蒸餾引擎。
869
+
870
+ 你現在要處理的唯一資料來源,是下方提供的真實對話內容。你必須只根據該對話內容蒸餾,不可把本提示中的任務說明、JSON 格式要求、欄位定義、評分規則,誤當成對話事實。
871
+
872
+ === BEGIN REAL CONVERSATION ===
873
+ ${conversationLog}
874
+ === END REAL CONVERSATION ===
875
+
876
+ 【任務 A:蒸餾草稿(analysis scratchpad)】
877
+ 先仔細閱讀上方真實對話,寫出精簡的分析草稿(限 800 字內):對話脈絡與進展、關鍵事實、雙方意圖。若輸出吃緊,優先縮短 analysis。
878
+
879
+ 【任務 B:前情提要膠囊(capsule)— 這才是最終產物】
880
+ ${capsuleLanguageInstruction}寫一段「前情提要」(600–900 字),讓 AI 接手對話時能立刻回到脈絡。必須涵蓋:
881
+ - 對話雙方是誰、彼此關係或身份
882
+ - 聊了哪些主題、進展到哪
883
+ - 確立的具體事實(日期、人名、數字、地點、事件、偏好)—— 必須 fact-faithful:日期依該 turn 的 [at=] 錨成絕對日期(不要只寫「昨天」「上週」)、人名/標題/專名照原文不改寫不譯走樣、數值保留原值與單位
884
+ - 做過的決定、計畫、承諾
885
+ - 情緒、狀態、關係近況
886
+ - 還沒談完或待處理的話題
887
+ - 合理的下一步(若有)
888
+ 寫成連貫敘事,不要分點條列,不要套用程式碼或技術報告格式。
889
+
890
+ 【顆粒化長期記憶(notes)】
891
+ 從真實對話中提取所有可被獨立詢問與回答的具體事實,包括明確名稱、地點、日期、數值、物件、偏好、決定與結果。不得僅因某項事實看似重要性較低而丟棄;只要脫離其他 notes 後仍能獨立理解與檢索,就應收錄。
892
+ 以可檢索事實的覆蓋率優先於簡短。notes 筆數上限為 min(20, max(12, 對話輪數 × 2));在上限內完整收錄實際存在的合格事實,不得為達數量填入無具體內容的文字。仍須排除純社交寒暄、無內容的輪次與噪音;沒有合格事實時輸出空陣列。
893
+
894
+ ⚠️ 下面的時間正規化與結構欄位是「對已萃取事實的加值(enrichment)」,不是「萃取什麼」的篩選條件。**絕不可因為某事實沒有日期/數值/可結構化欄位就略過它**;上述收錄廣度(偏好、關係、情緒狀態、決定脈絡等)一字不減,照常收錄為純 text note。
895
+
896
+ 【時間正規化】對話每個 turn 前綴有 [at=<ISO>] 絕對時間戳。當事實含「昨天/今天/明天/上週/下個月/剛才/之後」等相對時間,必須以該事實所在 turn 的 at 為錨,在 text 內寫出絕對日期(例:「在 2023-05-07(原文稱昨天)參加了…」),並填 when 欄位。若 at=unknown 或語意不足以唯一解析,保留原文相對詞、不可猜,when.precision 與 when.source 設為 "unknown"。明確日期、月份、年份、區間、期限、事件先後順序都要保留,不要只寫「最近」「之前」。
897
+
898
+ 每筆格式:
899
+ { "text": "...", "category": "fact|decision|entity|preference|constraint|identity|knowledge|history|business|other", "importance": 0.0-1.0, "tags": ["..."], "subject": "主體(選配)", "predicate": "關係/屬性/動作(選配)", "value": "值,字串/數字/布林(選配)", "unit": "數值單位(選配)", "when": { "start": "YYYY-MM-DD 或 ISO(選配)", "end": "(選配)", "precision": "datetime|date|month|year|range|unknown", "sourceText": "原文時間詞", "source": "explicit|relative_anchored|contextual|unknown", "anchor": "錨定用的 turn ISO 時間" } }
900
+ (subject/predicate/value/unit/entities/when 全為選配:有對應資訊才填,沒有就整個省略,不可填空字串或編造。text 仍是主要可讀、可檢索內容;日期、數值、人名、地名、檔案名、產品名、事件名也必須同時寫進 text,能正規化的日期同時寫入 text 與 when。)
901
+
902
+ 收錄:
903
+ - 已確認的決策,以及影響決策的關鍵理由、作用域或條件。
904
+ - 仍有效的承諾、待辦與計畫;必要時寫明責任人、期限、觸發條件或目前狀態。
905
+ - 穩定且可操作的偏好、身份、關係、長期目標與約束(含來源與作用域)。
906
+ - 經驗證且不易重新推導的技術結論、環境特性、根因、失敗方案及其原因。
907
+ - 對既有記憶的更正、取消或取代;清楚指出何者已失效以及新結論。
908
+
909
+ 不要收錄:
910
+ - 問候、稱讚、泛泛建議、對話流程描述、模型自評或「已提供協助」等通用後設敘述。
911
+ - 一次性命令、短暫狀態、容易重新查得的通識,或僅為 capsule 敘事服務的細節。
912
+ - 尚未確認的猜測、未被接受的方案,或由語氣推測出的身份與偏好。
913
+ - 與另一筆 note 意義相同的重述。
914
+
915
+ 寫作要求:
916
+ - 一筆只表達一個可獨立更新的主張,必須自足:寫明主體、內容、作用域,以及必要的時間、狀態、條件或理由;不得使用「這個」「上述方案」「已處理」等脫離原文便無法理解的指涉。
917
+ - 保留足以語意檢索與回查原始 transcript 的專案、元件、人物或事件名稱,但不要複製整段對話、長篇推理、日誌或操作過程(原文可由 rehydrate 取回)。
918
+ - category 選最能代表該主張長期用途者;tags 用少量具辨識力的實體/專案/領域/狀態詞。
919
+ - importance 依「跨時間耐久性 × 未來決策效用」評分,不依篇幅、情緒強度或主題是否熱門;較低分代表耐久性或決策效用較低,但不得因此省略可獨立檢索的具體事實。
920
+
921
+ CRITICAL INSTRUCTION: Output ONLY valid, raw JSON. Do NOT wrap in markdown code blocks. Start with '{' and end with '}'.
922
+
923
+ confidence 評分標準(0.0–1.0):
924
+ - 0.8+:細節保留完整,能直接回答具體事實(日期、人名、數字)
925
+ - 0.4–0.7:涵蓋但壓縮率高,細節可能失真
926
+ - <0.4:只能回答抽象概述
927
+
928
+ {
929
+ "analysis": "任務A的草稿...",
930
+ "confidence": 0.85,
931
+ "capsule": "任務B的自然語言前情提要...",
932
+ "notes": [
933
+ { "text": "精確的顆粒化記憶(含足夠上下文,獨立可理解;日期/數值/人名要寫進 text)", "category": "fact|decision|entity|preference|constraint|identity|knowledge|history|business|other", "importance": 0.0, "tags": [], "subject": "(選配,省略則不要出現)", "predicate": "(選配)", "value": "(選配)", "unit": "(選配)", "when": { "start": "YYYY-MM-DD(選配)", "precision": "date", "sourceText": "原文時間詞", "source": "relative_anchored", "anchor": "turn ISO 時間" } }
934
+ ]
935
+ }
936
+
937
+ 額外限制:
938
+ - notes 筆數上限為 min(20, max(12, 對話輪數 × 2))
939
+ - 若輸出即將過長,先縮短 analysis,不可任意刪除合格 notes
940
+ - 必須回傳可完整 JSON.parse 的有效 JSON,不可輸出半截 JSON`;
941
+ return isSourceLanguage ? `${sourceLanguageRequirementTop}\n\n${prompt}\n\n${sourceLanguageRequirementBottom}` : prompt;
942
+ }
943
+ // ─── 降級版 Prompt (專供本地小模型使用) ───
944
+ export function buildSimplePrompt(conversationLog, capsuleLanguage = '繁體中文') {
945
+ if (capsuleLanguage === 'source') {
946
+ const sourceLanguageRequirementTop = 'LANGUAGE REQUIREMENT: Write the ENTIRE capsule/前情提要 in the SAME language as the transcript. If the transcript is in English, write the capsule in English. Do NOT default to Chinese. This overrides all other instructions.';
947
+ const sourceLanguageRequirementBottom = 'LANGUAGE REQUIREMENT REMINDER: Write the ENTIRE capsule/前情提要 in the SAME language as the transcript above. If the transcript is in English, write the capsule in English. Do NOT default to Chinese. This overrides all other instructions.';
948
+ return `${sourceLanguageRequirementTop}\n\n你是一個專業的 AI 記憶摘要引擎。請仔細閱讀以下對話,並輸出一段 500 字以內的純文字「前情提要」(包含使用者的核心請求、已解決的問題、以及接下來的待辦清單)。
949
+ 請直接輸出純文字,絕對不要包含任何 markdown 或 JSON 格式:\n\n${conversationLog}\n\n${sourceLanguageRequirementBottom}`;
950
+ }
951
+ return `你是一個專業的 AI 記憶摘要引擎。請仔細閱讀以下對話,並輸出一段 500 字以內的純文字「前情提要」(包含使用者的核心請求、已解決的問題、以及接下來的待辦清單)。
952
+ 請直接輸出純文字,絕對不要包含任何 markdown 或 JSON 格式:\n\n${conversationLog}`;
953
+ }
954
+ // ─── Token 估算引擎 ───
955
+ function getComplexityFactor(msg) {
956
+ const content = extractTokenEstimationText(msg).combined;
957
+ // 使用 \x60 防禦 UI 崩潰
958
+ if (/\x60\x60\x60[\s\S]*?\x60\x60\x60/.test(content))
959
+ return 1.5;
960
+ if (/[\u4e00-\u9fff]/.test(content))
961
+ return 1.4;
962
+ return 1.0;
963
+ }
964
+ function extractTextLeafValues(value) {
965
+ if (typeof value === 'string')
966
+ return value.trim() ? [value.trim()] : [];
967
+ if (typeof value === 'number' || typeof value === 'boolean')
968
+ return [String(value)];
969
+ if (Array.isArray(value)) {
970
+ return value.flatMap((item) => extractTextLeafValues(item));
971
+ }
972
+ if (value && typeof value === 'object') {
973
+ return Object.values(value).flatMap((item) => extractTextLeafValues(item));
974
+ }
975
+ return [];
976
+ }
977
+ function extractTokenEstimationText(msg) {
978
+ const role = String(msg.role || '');
979
+ if (typeof msg.content === 'string') {
980
+ const text = msg.content.trim();
981
+ if (role === 'tool' || role === 'function') {
982
+ return { realText: '', toolText: text, combined: text };
983
+ }
984
+ return { realText: text, toolText: '', combined: text };
985
+ }
986
+ if (!Array.isArray(msg.content)) {
987
+ return { realText: '', toolText: '', combined: '' };
988
+ }
989
+ const realParts = [];
990
+ const toolParts = [];
991
+ for (const part of msg.content) {
992
+ if (!part || typeof part !== 'object')
993
+ continue;
994
+ if (typeof part.text === 'string' && part.text.trim()) {
995
+ if (role === 'tool' || role === 'function') {
996
+ toolParts.push(part.text.trim());
997
+ }
998
+ else {
999
+ realParts.push(part.text.trim());
1000
+ }
1001
+ }
1002
+ if (part.type === 'tool_use') {
1003
+ const inputText = extractTextLeafValues(part.input).join('\n').trim();
1004
+ if (inputText) {
1005
+ toolParts.push(inputText);
1006
+ }
1007
+ }
1008
+ }
1009
+ const realText = realParts.join('\n').trim();
1010
+ const toolText = toolParts.join('\n').trim();
1011
+ const combined = [realText, toolText].filter(Boolean).join('\n').trim();
1012
+ return { realText, toolText, combined };
1013
+ }
1014
+ function estimateTextTokens(text, complexityFactor) {
1015
+ if (!text)
1016
+ return 0;
1017
+ const cjkChars = (text.match(/[\u4e00-\u9fff\u3400-\u4dbf\uf900-\ufaff]/g) || []).length;
1018
+ const nonCjkChars = text.length - cjkChars;
1019
+ const baseTokens = (cjkChars * 1.5) + (nonCjkChars * 0.25);
1020
+ return Math.ceil(baseTokens * complexityFactor * (4 / 3));
1021
+ }
1022
+ function estimateTokenBreakdownForMessage(msg) {
1023
+ const { realText, toolText } = extractTokenEstimationText(msg);
1024
+ const complexityFactor = getComplexityFactor(msg);
1025
+ const realTokens = estimateTextTokens(realText, complexityFactor);
1026
+ const toolTokens = estimateTextTokens(toolText, complexityFactor);
1027
+ return { realTokens, toolTokens, total: realTokens + toolTokens };
1028
+ }
1029
+ function estimateTokenCount(msg) {
1030
+ return estimateTokenBreakdownForMessage(msg).total;
1031
+ }
1032
+ function estimateTotalTokens(messages) {
1033
+ return messages.reduce((sum, m) => sum + estimateTokenCount(m), 0);
1034
+ }
1035
+ function estimateTotalTokenBreakdown(messages) {
1036
+ return messages.reduce((acc, msg) => {
1037
+ const next = estimateTokenBreakdownForMessage(msg);
1038
+ acc.realTokens += next.realTokens;
1039
+ acc.toolTokens += next.toolTokens;
1040
+ acc.total += next.total;
1041
+ return acc;
1042
+ }, { realTokens: 0, toolTokens: 0, total: 0 });
1043
+ }
1044
+ function estimatePromptTokens(text) {
1045
+ const cjkChars = (text.match(/[\u4e00-\u9fff\u3400-\u4dbf\uf900-\ufaff]/g) || []).length;
1046
+ const nonCjkChars = text.length - cjkChars;
1047
+ return Math.ceil((cjkChars * 1.5 + nonCjkChars * 0.25) * (4 / 3));
1048
+ }
1049
+ function truncatePromptToTokenBudget(prompt, maxInputTokens) {
1050
+ if (estimatePromptTokens(prompt) <= maxInputTokens)
1051
+ return prompt;
1052
+ let left = 0;
1053
+ let right = prompt.length;
1054
+ let best = '';
1055
+ while (left <= right) {
1056
+ const mid = Math.floor((left + right) / 2);
1057
+ const candidate = prompt.slice(0, mid) + '\n\n...[對話過長已截斷,請基於以上內容進行摘要]...';
1058
+ const estimated = estimatePromptTokens(candidate);
1059
+ if (estimated <= maxInputTokens) {
1060
+ best = candidate;
1061
+ left = mid + 1;
1062
+ }
1063
+ else {
1064
+ right = mid - 1;
1065
+ }
1066
+ }
1067
+ return best || '\n\n...[對話過長已截斷,請基於以上內容進行摘要]...';
1068
+ }
1069
+ function classifyConcentratorFailure(err) {
1070
+ const message = String(err?.message ?? err ?? '').toLowerCase();
1071
+ if (message.includes('json') || message.includes('parse') || message.includes('invalid'))
1072
+ return 'broken_json';
1073
+ if (message.includes('timeout') || message.includes('timed out') || message.includes('abort'))
1074
+ return 'timeout';
1075
+ if (message.includes('quota') || message.includes('429') || message.includes('rate limit'))
1076
+ return 'quota';
1077
+ return 'other';
1078
+ }
1079
+ const GEMINI_503_BREAKER_THRESHOLD = 3;
1080
+ const GEMINI_503_COOLDOWN_MS = 90_000;
1081
+ let geminiConsecutive503Count = 0;
1082
+ let geminiCooldownUntil = 0;
1083
+ function isGemini503Error(err) {
1084
+ const message = String(err?.message ?? err ?? '').toLowerCase();
1085
+ return message.includes('gemini api error: 503');
1086
+ }
1087
+ function extractBalancedObjectForKey(text, key) {
1088
+ const keyIndex = text.indexOf(`"${key}"`);
1089
+ if (keyIndex === -1)
1090
+ return null;
1091
+ const braceStart = text.indexOf('{', keyIndex);
1092
+ if (braceStart === -1)
1093
+ return null;
1094
+ let depth = 0;
1095
+ let inString = false;
1096
+ let escaped = false;
1097
+ for (let i = braceStart; i < text.length; i++) {
1098
+ const ch = text[i];
1099
+ if (escaped) {
1100
+ escaped = false;
1101
+ continue;
1102
+ }
1103
+ if (ch === '\\') {
1104
+ escaped = true;
1105
+ continue;
1106
+ }
1107
+ if (ch === '"') {
1108
+ inString = !inString;
1109
+ continue;
1110
+ }
1111
+ if (inString)
1112
+ continue;
1113
+ if (ch === '{')
1114
+ depth++;
1115
+ if (ch === '}')
1116
+ depth--;
1117
+ if (depth === 0) {
1118
+ return text.slice(braceStart, i + 1);
1119
+ }
1120
+ }
1121
+ return null;
1122
+ }
1123
+ function salvageSummaryObject(jsonLike) {
1124
+ const obj = extractBalancedObjectForKey(jsonLike, 'summary');
1125
+ if (!obj)
1126
+ return null;
1127
+ try {
1128
+ const parsed = JSON.parse(obj);
1129
+ return typeof parsed === 'object' && parsed !== null ? parsed : null;
1130
+ }
1131
+ catch {
1132
+ return null;
1133
+ }
1134
+ }
1135
+ function resolveConcentratorTimeZone(timezone) {
1136
+ const candidate = timezone ?? (() => {
1137
+ try {
1138
+ return Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
1139
+ }
1140
+ catch {
1141
+ return 'UTC';
1142
+ }
1143
+ })();
1144
+ try {
1145
+ new Intl.DateTimeFormat(undefined, { timeZone: candidate });
1146
+ return candidate;
1147
+ }
1148
+ catch {
1149
+ return 'UTC';
1150
+ }
1151
+ }
1152
+ function formatTimestampWithTimeZone(timestamp, timezone) {
1153
+ const date = new Date(timestamp);
1154
+ if (Number.isNaN(date.getTime()))
1155
+ return null;
1156
+ const parts = new Intl.DateTimeFormat('en-CA', {
1157
+ timeZone: timezone,
1158
+ hourCycle: 'h23',
1159
+ year: 'numeric',
1160
+ month: '2-digit',
1161
+ day: '2-digit',
1162
+ hour: '2-digit',
1163
+ minute: '2-digit',
1164
+ second: '2-digit',
1165
+ }).formatToParts(date).reduce((acc, part) => {
1166
+ if (part.type !== 'literal')
1167
+ acc[part.type] = part.value;
1168
+ return acc;
1169
+ }, {});
1170
+ const year = Number(parts.year);
1171
+ const month = Number(parts.month);
1172
+ const day = Number(parts.day);
1173
+ const hour = Number(parts.hour);
1174
+ const minute = Number(parts.minute);
1175
+ const second = Number(parts.second);
1176
+ if (![year, month, day, hour, minute, second].every(Number.isFinite))
1177
+ return null;
1178
+ const wallClockAsUtc = Date.UTC(year, month - 1, day, hour, minute, second);
1179
+ const offsetMinutes = Math.round((wallClockAsUtc - date.getTime()) / 60000);
1180
+ const offsetSign = offsetMinutes >= 0 ? '+' : '-';
1181
+ const offsetAbs = Math.abs(offsetMinutes);
1182
+ const offsetHours = String(Math.floor(offsetAbs / 60)).padStart(2, '0');
1183
+ const offsetRemainder = String(offsetAbs % 60).padStart(2, '0');
1184
+ return `${parts.year}-${parts.month}-${parts.day}T${parts.hour}:${parts.minute}:${parts.second}${offsetSign}${offsetHours}:${offsetRemainder}`;
1185
+ }
1186
+ // ─── API 呼叫包裝 ───
1187
+ async function callGeminiAPI(apiKey, model, prompt, maxTokens = 8192) {
1188
+ await sharedLLMRateLimiter.acquire('gemini');
1189
+ const url = `https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${apiKey}`;
1190
+ const response = await fetch(url, {
1191
+ method: 'POST',
1192
+ headers: { 'Content-Type': 'application/json' },
1193
+ signal: AbortSignal.timeout(60000),
1194
+ body: JSON.stringify({
1195
+ contents: [{ parts: [{ text: prompt }] }],
1196
+ generationConfig: { temperature: 0.2, maxOutputTokens: maxTokens },
1197
+ }),
1198
+ });
1199
+ if (!response.ok) {
1200
+ let detail = response.statusText;
1201
+ try {
1202
+ const errBody = await response.json();
1203
+ detail = errBody?.error?.message || errBody?.error?.status || JSON.stringify(errBody?.error || errBody);
1204
+ }
1205
+ catch { }
1206
+ throw new Error(`Gemini API error: ${response.status} — ${detail}`);
1207
+ }
1208
+ const data = await response.json();
1209
+ if (data.candidates?.[0]?.content?.parts?.[0]?.text) {
1210
+ return data.candidates[0].content.parts[0].text.trim();
1211
+ }
1212
+ // Thinking responses may not put the answer at parts[0]; scan all non-thought
1213
+ // parts for text before giving up.
1214
+ const parts = data.candidates?.[0]?.content?.parts;
1215
+ if (Array.isArray(parts)) {
1216
+ const text = parts
1217
+ .filter((part) => part && typeof part.text === 'string' && part.thought !== true)
1218
+ .map((part) => part.text)
1219
+ .join('\n')
1220
+ .trim();
1221
+ if (text)
1222
+ return text;
1223
+ }
1224
+ throw new Error('Invalid Gemini API response');
1225
+ }
1226
+ async function callDeepSeekAPI(apiKey, model, prompt, maxTokens = 8192) {
1227
+ await sharedLLMRateLimiter.acquire('deepseek');
1228
+ const url = `https://api.deepseek.com/chat/completions`;
1229
+ const response = await fetch(url, {
1230
+ method: 'POST',
1231
+ headers: {
1232
+ 'Content-Type': 'application/json',
1233
+ 'Authorization': `Bearer ${apiKey}`,
1234
+ },
1235
+ signal: AbortSignal.timeout(60000),
1236
+ body: JSON.stringify({
1237
+ model,
1238
+ max_tokens: maxTokens,
1239
+ temperature: 0.2,
1240
+ messages: [{ role: 'user', content: prompt + "\n請直接輸出 JSON,不要包含 markdown 標籤。" }],
1241
+ }),
1242
+ });
1243
+ if (!response.ok) {
1244
+ let detail = response.statusText;
1245
+ try {
1246
+ const errBody = await response.json();
1247
+ detail = errBody?.error?.message || errBody?.error?.type || JSON.stringify(errBody?.error || errBody);
1248
+ }
1249
+ catch { }
1250
+ throw new Error(`DeepSeek API error: ${response.status} — ${detail}`);
1251
+ }
1252
+ const data = await response.json();
1253
+ const messageContent = data.choices?.[0]?.message?.content;
1254
+ if (typeof messageContent === 'string' && messageContent.trim()) {
1255
+ return messageContent.trim();
1256
+ }
1257
+ if (Array.isArray(messageContent)) {
1258
+ const text = messageContent
1259
+ .map((part) => {
1260
+ if (!part || typeof part !== 'object')
1261
+ return '';
1262
+ if (typeof part.text === 'string')
1263
+ return part.text;
1264
+ if (part.type === 'text' && typeof part.content === 'string')
1265
+ return part.content;
1266
+ return '';
1267
+ })
1268
+ .join('\n')
1269
+ .trim();
1270
+ if (text)
1271
+ return text;
1272
+ }
1273
+ // Reasoning models (deepseek-v4-pro, and occasionally flash) can return an
1274
+ // empty message.content with the real output in reasoning_content. Fall back
1275
+ // to it; the JSON salvage logic downstream tolerates surrounding CoT text.
1276
+ const reasoning = data.choices?.[0]?.message?.reasoning_content;
1277
+ if (typeof reasoning === 'string' && reasoning.trim()) {
1278
+ return reasoning.trim();
1279
+ }
1280
+ throw new Error('Invalid DeepSeek response');
1281
+ }
1282
+ export class ConcentratorAdapter {
1283
+ config;
1284
+ capsuleBridge;
1285
+ statsStore;
1286
+ transcriptArchive;
1287
+ llm;
1288
+ MAX_CONTEXT_WINDOW = 200000;
1289
+ // ── 動態水位線常數 ──────────────────────────────────────
1290
+ static WATERLINE_CODE = 0.40; // 代碼/技術密集:40%
1291
+ static WATERLINE_DEFAULT = 0.50; // 通用預設:50%
1292
+ constructor(config) {
1293
+ this.config = {
1294
+ apiKey: config.apiKey,
1295
+ model: config.model,
1296
+ inboxPath: config.inboxPath,
1297
+ capsuleCategory: config.capsuleCategory ?? 'history',
1298
+ capsuleLanguage: config.capsuleLanguage ?? '繁體中文',
1299
+ concentrationTarget: config.concentrationTarget ?? 0,
1300
+ provider: config.provider ?? 'gemini',
1301
+ maxTokens: config.maxTokens ?? 8192,
1302
+ deepseekApiKey: config.deepseekApiKey || '',
1303
+ deepseekModel: config.deepseekModel ?? 'deepseek-v4-flash',
1304
+ sessionSummaryDir: config.sessionSummaryDir,
1305
+ timezone: resolveConcentratorTimeZone(config.timezone),
1306
+ };
1307
+ this.statsStore = config.statsStore;
1308
+ this.transcriptArchive = config.transcriptArchive;
1309
+ this.llm = config.llm;
1310
+ this.capsuleBridge = new CapsuleBridge(this.config.inboxPath);
1311
+ }
1312
+ /**
1313
+ * 動態水位線偵測器:分析最近 30 則訊息判斷對話模式
1314
+ * - code: code block 密度 ≥ 15% 或 toolResult 佔比 ≥ 25%
1315
+ * - default: 其餘情況
1316
+ */
1317
+ detectConversationMode(messages) {
1318
+ const rawSample = messages.slice(-Math.min(30, messages.length));
1319
+ if (rawSample.length === 0) {
1320
+ return { mode: 'default', waterline: ConcentratorAdapter.WATERLINE_DEFAULT, reason: '無訊息' };
1321
+ }
1322
+ // 過濾掉 tool/function/tool_result — 這些是 OpenClaw infrastructure 噪音,不代表對話模式
1323
+ const contentSample = rawSample.filter(msg => {
1324
+ const role = msg.role;
1325
+ return role !== 'tool' && role !== 'function' && role !== 'toolResult' && role !== 'tool_result';
1326
+ });
1327
+ if (contentSample.length === 0) {
1328
+ return { mode: 'default', waterline: ConcentratorAdapter.WATERLINE_DEFAULT, reason: '全是工具訊息' };
1329
+ }
1330
+ let totalChars = 0;
1331
+ let codeBlockChars = 0;
1332
+ for (const msg of contentSample) {
1333
+ const content = typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content ?? '');
1334
+ totalChars += content.length;
1335
+ const codeBlocks = content.match(/```[\s\S]*?```/g);
1336
+ if (codeBlocks) {
1337
+ codeBlockChars += codeBlocks.reduce((sum, block) => sum + block.length, 0);
1338
+ }
1339
+ }
1340
+ const codeBlockRatio = totalChars > 0 ? codeBlockChars / totalChars : 0;
1341
+ if (codeBlockRatio >= 0.15) {
1342
+ const reason = `code block 佔比 ${(codeBlockRatio * 100).toFixed(1)}%`;
1343
+ return { mode: 'code', waterline: ConcentratorAdapter.WATERLINE_CODE, reason };
1344
+ }
1345
+ return { mode: 'default', waterline: ConcentratorAdapter.WATERLINE_DEFAULT, reason: '通用對話' };
1346
+ }
1347
+ buildFallbackCapsule(messages) {
1348
+ const cleaned = preFilterForConcentration(messages)
1349
+ .map((m) => ({
1350
+ role: m.role,
1351
+ text: sanitizeForFallbackSummary(extractTextForConcentrationContent(m.content)),
1352
+ }))
1353
+ .filter((m) => m.text.length > 0);
1354
+ const windowed = selectRecentFallbackWindow(cleaned);
1355
+ const userMessages = windowed
1356
+ .filter((m) => m.role === 'user')
1357
+ .map((m) => m.text)
1358
+ .slice(0, 3);
1359
+ const assistantMessages = windowed
1360
+ .filter((m) => m.role === 'assistant')
1361
+ .map((m) => m.text)
1362
+ .slice(-3);
1363
+ const primaryRequest = userMessages[0] || '無';
1364
+ const latestAssistant = assistantMessages[assistantMessages.length - 1] || '無';
1365
+ const currentWork = assistantMessages.length > 0 ? assistantMessages.join('\n') : '無';
1366
+ const userSummary = userMessages.length > 0 ? userMessages.join('\n') : '無';
1367
+ return [
1368
+ `## 1. Primary Request and Intent\n${primaryRequest}`,
1369
+ `## 2. Key Technical Concepts\n無`,
1370
+ `## 3. Files and Code Sections\n無`,
1371
+ `## 4. Errors and Fixes\n濃縮模型 fallback 失敗,改用本地 deterministic 摘要`,
1372
+ `## 5. Problem Solving\n依據對話順序保留主要使用者請求與近期 assistant 回應`,
1373
+ `## 6. All User Messages\n${userSummary}`,
1374
+ `## 7. Pending Tasks\n無`,
1375
+ `## 8. Current Work\n${currentWork}`,
1376
+ `## 9. Optional Next Step\n${latestAssistant === '無' ? '無' : latestAssistant}`,
1377
+ ].join('\n\n');
1378
+ }
1379
+ async concentrate(rawMessages, dryRun = false, force = false, context = {}) {
1380
+ const messages = rawMessages.map(msg => truncateGiantStrings(msg));
1381
+ const currentTokens = estimateTotalTokens(messages);
1382
+ // 🎯 動態水位線引擎:根據對話模式自動調整觸發門檻
1383
+ const { mode, waterline, reason } = this.detectConversationMode(messages);
1384
+ const dynamicTarget = this.config.concentrationTarget > 0
1385
+ ? this.config.concentrationTarget
1386
+ : Math.floor(this.MAX_CONTEXT_WINDOW * waterline);
1387
+ const needsCut = force || (currentTokens >= dynamicTarget);
1388
+ if (!needsCut) {
1389
+ return { messages, wasConcentrated: false, processedThroughIndex: 0 };
1390
+ }
1391
+ if (!this.llm && !this.config.apiKey && !this.config.deepseekApiKey) {
1392
+ console.warn('[ConcentratorAdapter] Concentration skipped: no LLM API key configured; raw transcripts and recall remain available.');
1393
+ return { messages, wasConcentrated: false, processedThroughIndex: 0 };
1394
+ }
1395
+ let cutEndIndex = 0;
1396
+ if (force) {
1397
+ if (context.exhaustive === true) {
1398
+ cutEndIndex = messages.length;
1399
+ }
1400
+ else {
1401
+ cutEndIndex = Math.max(0, messages.length - Math.min(messages.length, 5));
1402
+ if (cutEndIndex === 0 && messages.length > 0)
1403
+ cutEndIndex = messages.length;
1404
+ }
1405
+ }
1406
+ else {
1407
+ let keptTokens = 0;
1408
+ let keepCount = 0;
1409
+ const safeBufferTokens = 20000;
1410
+ for (let i = messages.length - 1; i >= 0; i--) {
1411
+ const msgTokens = estimateTokenCount(messages[i]);
1412
+ if (keptTokens + msgTokens > safeBufferTokens)
1413
+ break;
1414
+ keptTokens += msgTokens;
1415
+ keepCount++;
1416
+ }
1417
+ keepCount = Math.max(2, keepCount);
1418
+ cutEndIndex = Math.max(0, messages.length - keepCount);
1419
+ console.log(`[ConcentratorAdapter] Dynamic watermark triggered [${mode}] ${(waterline * 100).toFixed(0)}% (${currentTokens}/${dynamicTarget} tokens) - ${reason}`);
1420
+ }
1421
+ const newMessages = [];
1422
+ const messagesToSummarize = [];
1423
+ let hasModified = false;
1424
+ let capsuleText = '';
1425
+ for (let i = 0; i < messages.length; i++) {
1426
+ const msg = messages[i];
1427
+ let cleanMsg = JSON.parse(JSON.stringify(msg));
1428
+ cleanMsg = stripThinkingBlocks(cleanMsg);
1429
+ // 🛡️ 核心修復 1:System 訊息擁有「絕對免死金牌」
1430
+ // 只要是系統提示詞,無論新舊,一律無條件保留,且不參與濃縮!
1431
+ if (msg.role === 'system') {
1432
+ newMessages.push(cleanMsg);
1433
+ continue;
1434
+ }
1435
+ // 新訊息(近期對話):直接保留
1436
+ if (i >= cutEndIndex) {
1437
+ newMessages.push(cleanMsg);
1438
+ continue;
1439
+ }
1440
+ // 舊訊息(準備被濃縮):排除 SystemExecInjection (因為沒營養)
1441
+ if (!isSystemExecInjection(msg)) {
1442
+ messagesToSummarize.push(cleanMsg);
1443
+ }
1444
+ }
1445
+ // 收集被壓縮訊息的時間戳範圍(供 capsule metadata 使用)
1446
+ const summarizeTimestamps = messagesToSummarize
1447
+ .map(m => m.timestamp)
1448
+ .filter((t) => typeof t === 'number' && t > 0);
1449
+ const firstTimestamp = summarizeTimestamps.length > 0 ? Math.min(...summarizeTimestamps) : Date.now();
1450
+ const lastTimestamp = summarizeTimestamps.length > 0 ? Math.max(...summarizeTimestamps) : Date.now();
1451
+ const sourceEntryIdsProbe = probeSourceEntryIdMatch(this.transcriptArchive, messagesToSummarize, context.sessionIdentity, firstTimestamp, lastTimestamp);
1452
+ if (sourceEntryIdsProbe) {
1453
+ console.log(`[ConcentratorAdapter][P0-1 probe] summarizeMessages=${messagesToSummarize.length} summarizePairs=${sourceEntryIdsProbe.summarizePairCount} candidateCount=${sourceEntryIdsProbe.candidateCount} matched=${sourceEntryIdsProbe.matched} reason=${sourceEntryIdsProbe.reason ?? 'ok'} matchedEntryIds=${sourceEntryIdsProbe.matchedEntryIds.join(',') || 'none'} sourceEntryIds=${sourceEntryIdsProbe.sourceEntryIds.join(',') || 'none'} firstTimestamp=${firstTimestamp} lastTimestamp=${lastTimestamp} canonicalKey=${context.sessionIdentity?.canonicalKey ?? 'unknown'}`);
1454
+ }
1455
+ else {
1456
+ console.log(`[ConcentratorAdapter][P0-1 probe] summarizeMessages=${messagesToSummarize.length} summarizePairs=0 candidateCount=0 matched=false reason=archive_lag matchedEntryIds=none firstTimestamp=${firstTimestamp} lastTimestamp=${lastTimestamp} canonicalKey=unknown sessionIdentity=missing`);
1457
+ }
1458
+ if (messagesToSummarize.length > 0) {
1459
+ try {
1460
+ // 👇 四層完美過濾濾網啟動!
1461
+ const filteredForLLM = preFilterForConcentration(messagesToSummarize);
1462
+ const conversationLog = filteredForLLM
1463
+ .map(m => {
1464
+ const text = stripFrameworkMetadataForConcentration(extractTextForConcentrationContent(m.content));
1465
+ if (!text || isFrameworkMetadataForConcentration(text.trim()))
1466
+ return null;
1467
+ // Prefix each turn with its absolute timestamp so the distiller can anchor
1468
+ // relative time words ("昨天"/"上週") to a real date instead of dropping them.
1469
+ const ts = m.timestamp;
1470
+ let at = 'unknown';
1471
+ if (typeof ts === 'number' && Number.isFinite(ts)) {
1472
+ at = formatTimestampWithTimeZone(ts, this.config.timezone) ?? 'unknown';
1473
+ }
1474
+ return `[at=${at}] ${m.role.toUpperCase()}: ${text}`;
1475
+ })
1476
+ .filter((line) => !!line && line.trim().length > 0)
1477
+ .join('\n\n');
1478
+ const prompt = mode === 'code'
1479
+ ? buildDualTrackPrompt(conversationLog, this.config.capsuleLanguage)
1480
+ : buildGeneralConversationPrompt(conversationLog, this.config.capsuleLanguage);
1481
+ const simplePrompt = buildSimplePrompt(conversationLog, this.config.capsuleLanguage);
1482
+ if (!conversationLog.trim()) {
1483
+ console.warn('[ConcentratorAdapter] conversationLog is empty; compaction input may lack actual conversation content');
1484
+ }
1485
+ let generatedJSON = '';
1486
+ try {
1487
+ // 傳入雙軌 prompt 與降級 simplePrompt
1488
+ generatedJSON = await this.callWithFallback(prompt, 'concentrate', simplePrompt, {
1489
+ sessionIdentity: context.sessionIdentity,
1490
+ inputTokens: currentTokens,
1491
+ });
1492
+ }
1493
+ catch (err) {
1494
+ console.error("[ConcentratorAdapter] Compaction failed:", err);
1495
+ const fallbackCapsule = this.buildFallbackCapsule(filteredForLLM);
1496
+ console.warn('[ConcentratorAdapter] Enabling local deterministic fallback capsule');
1497
+ generatedJSON = JSON.stringify({
1498
+ capsule: fallbackCapsule,
1499
+ notes: [],
1500
+ confidence: 0.2,
1501
+ });
1502
+ }
1503
+ // ==========================================
1504
+ // 🛡️ 無敵 JSON 暴力萃取器 + 🚑 Regex 救生艇
1505
+ // ==========================================
1506
+ let parsedData = { capsule: '', notes: [] };
1507
+ let jsonString = generatedJSON.trim();
1508
+ try {
1509
+ parsedData = JSON.parse(jsonString);
1510
+ }
1511
+ catch (e) {
1512
+ const startIdx = Math.min(jsonString.indexOf('{') === -1 ? Infinity : jsonString.indexOf('{'), jsonString.indexOf('[') === -1 ? Infinity : jsonString.indexOf('['));
1513
+ const endIdx = Math.max(jsonString.lastIndexOf('}'), jsonString.lastIndexOf(']'));
1514
+ if (startIdx !== Infinity && endIdx !== -1 && startIdx < endIdx) {
1515
+ const strippedJson = jsonString.substring(startIdx, endIdx + 1);
1516
+ try {
1517
+ parsedData = JSON.parse(strippedJson);
1518
+ }
1519
+ catch (err) {
1520
+ console.error(`[ConcentratorAdapter] Parsing still failed after stripping; extracted string:\n${strippedJson}`);
1521
+ // 🚑 終極急救艇:就算 JSON 斷頭斷尾,也要把最重要的前情提要挖出來!
1522
+ const salvagedSummary = salvageSummaryObject(jsonString);
1523
+ if (salvagedSummary) {
1524
+ parsedData.summary = salvagedSummary;
1525
+ console.log(`[ConcentratorAdapter] Recovery succeeded: extracted summary structure`);
1526
+ }
1527
+ else {
1528
+ const capMatch = jsonString.match(/"capsule"\s*:\s*"([\s\S]*?)"\s*,\s*"notes"/);
1529
+ if (capMatch && capMatch[1]) {
1530
+ parsedData.capsule = capMatch[1].replace(/\\n/g, '\n').replace(/\\"/g, '"');
1531
+ console.log(`[ConcentratorAdapter] Recovery succeeded: extracted ${parsedData.capsule.length}-character summary`);
1532
+ }
1533
+ }
1534
+ }
1535
+ }
1536
+ else {
1537
+ console.warn(`[ConcentratorAdapter] Abandoning parse and returning fallback. Original string: ${jsonString.substring(0, 100)}...`);
1538
+ // 🚑 終極急救艇:無括號狀態下的搶救
1539
+ const salvagedSummary = salvageSummaryObject(jsonString);
1540
+ if (salvagedSummary) {
1541
+ parsedData.summary = salvagedSummary;
1542
+ console.log(`[ConcentratorAdapter] Bracketless recovery succeeded: extracted summary structure`);
1543
+ }
1544
+ else {
1545
+ const capMatch = jsonString.match(/"capsule"\s*:\s*"([\s\S]*?)"\s*,\s*"notes"/);
1546
+ if (capMatch && capMatch[1]) {
1547
+ parsedData.capsule = capMatch[1].replace(/\\n/g, '\n').replace(/\\"/g, '"');
1548
+ console.log(`[ConcentratorAdapter] Bracketless recovery succeeded: extracted ${parsedData.capsule.length}-character summary`);
1549
+ }
1550
+ }
1551
+ }
1552
+ }
1553
+ // 提取 confidence(LLM 自評保真度)
1554
+ const confidence = typeof parsedData.confidence === 'number'
1555
+ ? Math.max(0, Math.min(1, parsedData.confidence))
1556
+ : 0.7; // 舊格式或解析失敗時預設中等
1557
+ // Compose capsule from 9-section summary (新結構) 或 legacy capsule
1558
+ const summary = parsedData.summary;
1559
+ if (summary && typeof summary === 'object') {
1560
+ const sections = [
1561
+ `## 1. Primary Request and Intent\n${summary.primaryRequest || '無'}`,
1562
+ `## 2. Key Technical Concepts\n${summary.technicalConcepts || '無'}`,
1563
+ `## 3. Files and Code Sections\n${summary.filesAndCode || '無'}`,
1564
+ `## 4. Errors and Fixes\n${summary.errorsAndFixes || '無'}`,
1565
+ `## 5. Problem Solving\n${summary.problemSolving || '無'}`,
1566
+ `## 6. All User Messages\n${summary.userMessages || '無'}`,
1567
+ `## 7. Pending Tasks\n${summary.pendingTasks || '無'}`,
1568
+ `## 8. Current Work\n${summary.currentWork || '無'}`,
1569
+ `## 9. Optional Next Step\n${summary.nextStep || '無'}`,
1570
+ ];
1571
+ capsuleText = sections.join('\n\n');
1572
+ }
1573
+ else {
1574
+ // Fallback: legacy capsule field
1575
+ capsuleText = parsedData.capsule || parsedData.analysis || '';
1576
+ }
1577
+ const originalTokens = estimatePromptTokens(conversationLog);
1578
+ const summaryTokens = estimatePromptTokens(capsuleText);
1579
+ const compressionRatio = originalTokens / Math.max(1, summaryTokens);
1580
+ const notes = parsedData.notes || [];
1581
+ const sourceEntryIds = sourceEntryIdsProbe?.matched ? sourceEntryIdsProbe.sourceEntryIds : [];
1582
+ const sourceEntryRange = sourceEntryIds.length > 0
1583
+ ? {
1584
+ firstEntryId: sourceEntryIds[0],
1585
+ lastEntryId: sourceEntryIds[sourceEntryIds.length - 1],
1586
+ count: sourceEntryIds.length,
1587
+ }
1588
+ : undefined;
1589
+ if (capsuleText) {
1590
+ await this.capsuleBridge.writeToInbox("【前情提要】\n" + capsuleText, {
1591
+ category: this.config.capsuleCategory,
1592
+ importance: 0.8,
1593
+ metadata: {
1594
+ type: 'dynamic_capsule',
1595
+ health: 30,
1596
+ lastConcentratedAt: Date.now(),
1597
+ confidence,
1598
+ compressionRatio,
1599
+ firstTimestamp,
1600
+ lastTimestamp,
1601
+ ...(sourceEntryIds.length > 0 ? { sourceEntryIds } : {}),
1602
+ ...(sourceEntryRange ? { sourceEntryRange } : {}),
1603
+ }
1604
+ });
1605
+ console.log(`[ConcentratorAdapter] sourceEntryIds metadata: length=${sourceEntryIds.length} firstEntryId=${sourceEntryRange?.firstEntryId ?? 'none'} lastEntryId=${sourceEntryRange?.lastEntryId ?? 'none'}`);
1606
+ console.log(`[ConcentratorAdapter] Short-term capsule written (health: 30, confidence: ${confidence.toFixed(2)})`);
1607
+ }
1608
+ const acceptedNoteTexts = [];
1609
+ for (const item of notes) {
1610
+ const text = typeof item.text === 'string' ? item.text.trim() : '';
1611
+ const normalized = text.toLowerCase().replace(/[^\p{L}\p{N}]+/gu, '');
1612
+ const grams = new Set(Array.from({ length: Math.max(0, normalized.length - 1) }, (_, i) => normalized.slice(i, i + 2)));
1613
+ const isDuplicate = acceptedNoteTexts.some((existing) => {
1614
+ if (existing === normalized)
1615
+ return true;
1616
+ const existingGrams = new Set(Array.from({ length: Math.max(0, existing.length - 1) }, (_, i) => existing.slice(i, i + 2)));
1617
+ const overlap = [...grams].filter((gram) => existingGrams.has(gram)).length;
1618
+ return overlap / Math.max(1, grams.size + existingGrams.size - overlap) >= 0.8;
1619
+ });
1620
+ if (text && passesConcentratorNoteImportanceFilter(item) && !isDuplicate) {
1621
+ acceptedNoteTexts.push(normalized);
1622
+ await this.capsuleBridge.writeToInbox(text, {
1623
+ category: item.category || 'fact',
1624
+ importance: item.importance,
1625
+ metadata: {
1626
+ tags: item.tags || [],
1627
+ health: 100,
1628
+ lastConcentratedAt: Date.now(),
1629
+ confidence,
1630
+ firstTimestamp,
1631
+ lastTimestamp,
1632
+ ...(sourceEntryIds.length > 0 ? { sourceEntryIds } : {}),
1633
+ ...(sourceEntryRange ? { sourceEntryRange } : {}),
1634
+ // Optional structured enrichment — additive, stored as-is (no schema change),
1635
+ // with shape guards so a malformed LLM value never pollutes metadata.
1636
+ // NOTE: no `entities` here — graph entities are generated downstream by
1637
+ // inbox-watcher and would overwrite it; entity-relations are the graph's job.
1638
+ ...(typeof item.subject === 'string' && item.subject ? { subject: item.subject } : {}),
1639
+ ...(typeof item.predicate === 'string' && item.predicate ? { predicate: item.predicate } : {}),
1640
+ ...(['string', 'number', 'boolean'].includes(typeof item.value) ? { value: item.value } : {}),
1641
+ ...(typeof item.unit === 'string' && item.unit ? { unit: item.unit } : {}),
1642
+ ...(item.when && typeof item.when === 'object' && !Array.isArray(item.when) ? { when: item.when } : {}),
1643
+ }
1644
+ });
1645
+ }
1646
+ }
1647
+ if (notes.length > 0) {
1648
+ console.log(`[ConcentratorAdapter] ${notes.length} precise memory notes written`);
1649
+ }
1650
+ hasModified = true;
1651
+ // Write session summary JSON
1652
+ // Phase 4-4:sessionId 優先從 sessionIdentity 取,避免從 rawMessages[0]
1653
+ // 這種側通道猜(rawMessages 不一定每筆都掛 sessionId)
1654
+ const sessionId = context.sessionIdentity?.sessionId
1655
+ || rawMessages[0]?.sessionId
1656
+ || 'unknown';
1657
+ const concentratedAt = Date.now();
1658
+ await this.writeSessionSummary({ sessionId, concentratedAt, capsule: capsuleText, notes: parsedData.notes || [], primaryRequest: summary?.primaryRequest || '', pendingTasks: summary?.pendingTasks || '', nextStep: summary?.nextStep || '' });
1659
+ }
1660
+ catch (err) {
1661
+ console.error("[ConcentratorAdapter] Compaction failed:", err);
1662
+ }
1663
+ }
1664
+ let finalizedMessages = finalizeMessages(newMessages);
1665
+ if (capsuleText) {
1666
+ finalizedMessages = finalizedMessages.filter(m => !(m.role === 'system' && typeof m.content === 'string' && m.content.startsWith('【前情提要】')));
1667
+ const summarySystemMsg = {
1668
+ role: 'system',
1669
+ content: "【前情提要】\n前面的對話已被壓縮,以下是近期歷史詳細摘要,請將其作為背景脈絡繼續對話:\n" + capsuleText,
1670
+ timestamp: Date.now(),
1671
+ };
1672
+ let insertIdx = 0;
1673
+ finalizedMessages.splice(insertIdx, 0, summarySystemMsg);
1674
+ console.log("[ConcentratorAdapter] Summary injected at the top of context");
1675
+ }
1676
+ return { messages: finalizedMessages, wasConcentrated: hasModified || needsCut, summary: capsuleText, processedThroughIndex: cutEndIndex };
1677
+ }
1678
+ estimateTokens(messages) {
1679
+ return estimateTotalTokens(messages);
1680
+ }
1681
+ estimateTokenBreakdown(messages) {
1682
+ return estimateTotalTokenBreakdown(messages);
1683
+ }
1684
+ /**
1685
+ * Provider 輪替 fallback 核心方法
1686
+ * 依序嘗試 gemini → deepseek,任一成功即返回
1687
+ * Gemini 若連續 3 次 503,冷卻 90 秒內直接跳過
1688
+ */
1689
+ async callWithFallback(prompt, fnName = 'generate', fallbackPrompt, // concentrate 失敗時由呼叫端接 deterministic capsule;此處不處理
1690
+ metricContext, maxTokens = this.config.maxTokens) {
1691
+ if (this.llm) {
1692
+ return this.llm.generate(prompt, { purpose: fnName, maxTokens });
1693
+ }
1694
+ const providers = [];
1695
+ const now = Date.now();
1696
+ if (now < geminiCooldownUntil) {
1697
+ console.warn(`[${fnName}] gemini skipped: circuit breaker cooling down for ${Math.ceil((geminiCooldownUntil - now) / 1000)}s`);
1698
+ }
1699
+ else {
1700
+ providers.push('gemini');
1701
+ }
1702
+ providers.push('deepseek');
1703
+ const attemptedProviders = [];
1704
+ const startedAt = Date.now();
1705
+ const shouldRecordMetric = fnName === 'concentrate';
1706
+ let lastError = null;
1707
+ for (const provider of providers) {
1708
+ attemptedProviders.push(provider);
1709
+ try {
1710
+ if (provider === 'gemini') {
1711
+ if (this.config.apiKey) {
1712
+ const result = await this.callProvider(provider, prompt, maxTokens);
1713
+ geminiConsecutive503Count = 0;
1714
+ await this.recordConcentratorAttemptMetric({
1715
+ metricContext,
1716
+ provider,
1717
+ outcome: 'success',
1718
+ attemptedProviders,
1719
+ inputTokens: metricContext?.inputTokens ?? estimatePromptTokens(prompt),
1720
+ outputTokens: estimatePromptTokens(result),
1721
+ durationMs: Date.now() - startedAt,
1722
+ }, shouldRecordMetric);
1723
+ return result;
1724
+ }
1725
+ console.warn(`[${fnName}] gemini skipped: no API key`);
1726
+ continue;
1727
+ }
1728
+ if (provider === 'deepseek') {
1729
+ if (this.config.deepseekApiKey) {
1730
+ const result = await this.callProvider(provider, prompt, maxTokens);
1731
+ await this.recordConcentratorAttemptMetric({
1732
+ metricContext,
1733
+ provider,
1734
+ outcome: 'success',
1735
+ attemptedProviders,
1736
+ inputTokens: metricContext?.inputTokens ?? estimatePromptTokens(prompt),
1737
+ outputTokens: estimatePromptTokens(result),
1738
+ durationMs: Date.now() - startedAt,
1739
+ }, shouldRecordMetric);
1740
+ return result;
1741
+ }
1742
+ console.warn(`[${fnName}] deepseek skipped: no API key`);
1743
+ continue;
1744
+ }
1745
+ }
1746
+ catch (err) {
1747
+ console.warn(`[${fnName}] ${provider} failed; trying next provider:`, err);
1748
+ if (provider === 'gemini') {
1749
+ if (isGemini503Error(err)) {
1750
+ geminiConsecutive503Count += 1;
1751
+ if (geminiConsecutive503Count >= GEMINI_503_BREAKER_THRESHOLD) {
1752
+ geminiCooldownUntil = Date.now() + GEMINI_503_COOLDOWN_MS;
1753
+ console.warn(`[${fnName}] gemini circuit breaker opened for ${GEMINI_503_COOLDOWN_MS / 1000}s after ${geminiConsecutive503Count} consecutive 503s`);
1754
+ }
1755
+ }
1756
+ else {
1757
+ geminiConsecutive503Count = 0;
1758
+ }
1759
+ }
1760
+ lastError = err;
1761
+ }
1762
+ }
1763
+ await this.recordConcentratorAttemptMetric({
1764
+ metricContext,
1765
+ provider: 'all_failed',
1766
+ outcome: 'failure',
1767
+ attemptedProviders,
1768
+ inputTokens: metricContext?.inputTokens ?? estimatePromptTokens(prompt),
1769
+ outputTokens: null,
1770
+ durationMs: Date.now() - startedAt,
1771
+ failureReason: classifyConcentratorFailure(lastError),
1772
+ }, shouldRecordMetric);
1773
+ throw new Error(`[${fnName}] 所有 provider 都失敗: ${lastError}`);
1774
+ }
1775
+ async callProvider(provider, prompt, maxTokens) {
1776
+ if (provider === 'gemini') {
1777
+ return callGeminiAPI(this.config.apiKey, this.config.model, prompt, maxTokens);
1778
+ }
1779
+ if (provider === 'deepseek') {
1780
+ return callDeepSeekAPI(this.config.deepseekApiKey, this.config.deepseekModel, prompt, maxTokens);
1781
+ }
1782
+ throw new Error(`Unsupported provider: ${provider}`);
1783
+ }
1784
+ async recordConcentratorAttemptMetric(stat, enabled) {
1785
+ if (!enabled || !this.statsStore)
1786
+ return;
1787
+ const identity = stat.metricContext?.sessionIdentity;
1788
+ try {
1789
+ await this.statsStore.recordConcentratorStat({
1790
+ canonicalKey: identity?.canonicalKey?.trim() || 'unknown',
1791
+ sessionId: identity?.sessionId ?? null,
1792
+ provider: stat.provider,
1793
+ outcome: stat.outcome,
1794
+ attemptedProviders: JSON.stringify(stat.attemptedProviders),
1795
+ inputTokens: stat.inputTokens,
1796
+ outputTokens: stat.outputTokens,
1797
+ durationMs: stat.durationMs,
1798
+ failureReason: stat.failureReason ?? null,
1799
+ createdAt: Date.now(),
1800
+ });
1801
+ }
1802
+ catch (err) {
1803
+ console.warn(`[ConcentratorAdapter] Failed to write concentrator_stats: ${err?.message ?? err}`);
1804
+ }
1805
+ }
1806
+ async generate(input, opts) {
1807
+ let prompt;
1808
+ if (typeof input === 'string') {
1809
+ prompt = input;
1810
+ }
1811
+ else {
1812
+ prompt = '';
1813
+ for (let i = input.length - 1; i >= 0; i--) {
1814
+ const message = input[i];
1815
+ if (message?.role === 'user') {
1816
+ prompt = typeof message.content === 'string'
1817
+ ? message.content
1818
+ : JSON.stringify(message.content);
1819
+ break;
1820
+ }
1821
+ }
1822
+ if (!prompt) {
1823
+ prompt = typeof input[0]?.content === 'string'
1824
+ ? input[0].content
1825
+ : JSON.stringify(input);
1826
+ }
1827
+ }
1828
+ return this.callWithFallback(prompt, opts?.purpose ?? 'generate', undefined, undefined, opts?.maxTokens);
1829
+ }
1830
+ async assemble(params) {
1831
+ try {
1832
+ const msgs = Array.isArray(params.messages) ? params.messages : (params.session?.messages || []);
1833
+ if (msgs.length === 0)
1834
+ return [];
1835
+ const result = await this.concentrate(msgs, false);
1836
+ return result.messages;
1837
+ }
1838
+ catch (err) {
1839
+ console.error(`[ConcentratorAdapter] Critical assemble error:`, err);
1840
+ return Array.isArray(params.messages) ? params.messages : [];
1841
+ }
1842
+ }
1843
+ // 🛡️ P0 修復:sessionId 未驗證會被拼進路徑造成路徑穿越。比照
1844
+ // transcript-archive.ts 的 getTranscriptPath 規則:白名單字元 + 不含 '..' +
1845
+ // resolve 後仍必須落在 sessionSummaryDir 之內,否則視為不合法(回傳 null)。
1846
+ getSessionSummaryPath(sessionId) {
1847
+ if (!/^[A-Za-z0-9._:-]+$/.test(sessionId) || sessionId.includes('..')) {
1848
+ console.warn(`[ConcentratorAdapter] getSessionSummaryPath rejected invalid sessionId: ${sessionId}`);
1849
+ return null;
1850
+ }
1851
+ const dir = path.resolve(this.config.sessionSummaryDir);
1852
+ const filePath = path.resolve(dir, `${sessionId}-summary.json`);
1853
+ const dirPrefix = dir.endsWith(path.sep) ? dir : `${dir}${path.sep}`;
1854
+ if (!filePath.startsWith(dirPrefix)) {
1855
+ console.warn(`[ConcentratorAdapter] getSessionSummaryPath escaped sessionSummaryDir: ${sessionId}`);
1856
+ return null;
1857
+ }
1858
+ return filePath;
1859
+ }
1860
+ async writeSessionSummary(summary) {
1861
+ if (!summary.sessionId || summary.sessionId === 'unknown')
1862
+ return;
1863
+ try {
1864
+ const filePath = this.getSessionSummaryPath(summary.sessionId);
1865
+ if (!filePath)
1866
+ return;
1867
+ const dir = path.dirname(filePath);
1868
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
1869
+ fs.writeFileSync(filePath, JSON.stringify(summary, null, 2), { encoding: 'utf-8', mode: 0o600 });
1870
+ console.log(`[ConcentratorAdapter] Session summary written: ${filePath}`);
1871
+ }
1872
+ catch (err) {
1873
+ console.warn('[ConcentratorAdapter] writeSessionSummary failed:', err);
1874
+ }
1875
+ }
1876
+ }