@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,1039 @@
1
+ /**
2
+ * InboxWatcher - 記憶 inbox 觀察者 + River Capsule 處理器
3
+ * memory-river
4
+ * * 改造要點:
5
+ * - inbox 路徑從構造函數參數傳入(不再 hardcode)
6
+ * - import 改為來自同目錄的本地模組
7
+ * - 保留 writeInbox() static 方法給 CapsuleBridge 呼叫
8
+ * - processRiverCapsule() 方法處理 river_capsule_*.txt 檔
9
+ */
10
+ import fs from "node:fs";
11
+ import path from "node:path";
12
+ import { judgeAbstractness } from "../retrieval/abstractness-judge.js";
13
+ import { setBoundedMapEntry } from "../util/bounded-map.js";
14
+ import { resolveSessionIdentity } from "../util/session-identity.js";
15
+ import { isCompactRequestFilename, readCompactRequest, } from "./compact-request.js";
16
+ const DEBUG = true;
17
+ /**
18
+ * 带 Exponential Backoff 的重試裝甲
19
+ * - baseDelayMs 起跳,指数增长,上限 maxDelayMs
20
+ * - retryableErrors: callback,判斷這個錯誤是否值得重試(不回傳 true 就立即放行,不重試)
21
+ * - 所有重試耗盡才拋出最後一個錯誤
22
+ */
23
+ async function withRetry(fn, opts = {}) {
24
+ const { maxAttempts = 3, baseDelayMs = 50, maxDelayMs = 2000, retryableErrors = () => false, label = 'operation', } = opts;
25
+ let lastError;
26
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
27
+ try {
28
+ return await fn();
29
+ }
30
+ catch (err) {
31
+ lastError = err;
32
+ // 不值得重試的錯誤,立即放行
33
+ if (!retryableErrors(err))
34
+ throw err;
35
+ if (attempt === maxAttempts) {
36
+ console.warn(`[withRetry] ${label} failed after ${maxAttempts} attempts: ${err?.message ?? err}`);
37
+ throw err;
38
+ }
39
+ const delay = Math.min(baseDelayMs * Math.pow(2, attempt - 1), maxDelayMs);
40
+ console.warn(`[withRetry] ${label} failed (attempt ${attempt}/${maxAttempts}); retrying in ${delay}ms...`);
41
+ await new Promise(resolve => setTimeout(resolve, delay));
42
+ }
43
+ }
44
+ throw lastError;
45
+ }
46
+ // 判斷錯誤是否值得重試(網路瞬斷 / LanceDB 瞬斷)
47
+ function isRetryableError(err) {
48
+ if (!err)
49
+ return false;
50
+ const msg = String(err?.message ?? err).toLowerCase();
51
+ // 網路相關
52
+ if (err.name === 'FetchError' || err.name === 'TypeError')
53
+ return true; // fetch 底層錯誤
54
+ if (msg.includes('timeout') || msg.includes('timed out'))
55
+ return true;
56
+ if (msg.includes('econnreset') || msg.includes('econnrefused') || msg.includes('enotfound'))
57
+ return true;
58
+ if (msg.includes('network') || msg.includes('fetch'))
59
+ return true;
60
+ // Gemini / API 503 / 429
61
+ if (err?.status === 503 || err?.status === 429)
62
+ return true;
63
+ if (msg.includes('503') || msg.includes('service unavailable'))
64
+ return true;
65
+ if (msg.includes('429') || msg.includes('rate limit'))
66
+ return true;
67
+ // LanceDB 瞬斷
68
+ if (msg.includes('lance') && (msg.includes('io error') || msg.includes('locked') || msg.includes('commit')))
69
+ return true;
70
+ if (msg.includes('lance') && (msg.includes('timeout') || msg.includes('busy')))
71
+ return true;
72
+ return false;
73
+ }
74
+ export class InboxWatcher {
75
+ store;
76
+ embedder;
77
+ causalEngine;
78
+ hooksEngine;
79
+ graphStore;
80
+ llm;
81
+ inboxPath;
82
+ pollIntervalMs;
83
+ conflictDetector;
84
+ statusManager;
85
+ compactRequestProcessor;
86
+ static RIVER_CAPSULE_FAILURE_THRESHOLD = 5;
87
+ static RIVER_CAPSULE_FAILURE_LRU_MAX = 500;
88
+ intervalId = null;
89
+ isProcessing = false;
90
+ processingStartedAt = 0;
91
+ started = false;
92
+ fatalErrorCount = 0;
93
+ riverCapsuleFailureCounts = new Map();
94
+ // 每個 parentId 的寫入鎖,防止同時對同一個記憶發出兩次 UPDATE
95
+ parentIdLocks = new Map();
96
+ async withParentLock(parentId, fn) {
97
+ const existing = this.parentIdLocks.get(parentId) ?? Promise.resolve();
98
+ let release;
99
+ const next = new Promise(resolve => { release = resolve; });
100
+ this.parentIdLocks.set(parentId, next);
101
+ try {
102
+ await existing;
103
+ return await fn();
104
+ }
105
+ finally {
106
+ release();
107
+ // 只在沒有後繼等待者時才清除,避免 Map 洩漏
108
+ if (this.parentIdLocks.get(parentId) === next) {
109
+ this.parentIdLocks.delete(parentId);
110
+ }
111
+ }
112
+ }
113
+ parseMetadata(metaStr) {
114
+ if (!metaStr)
115
+ return {};
116
+ try {
117
+ return JSON.parse(metaStr);
118
+ }
119
+ catch {
120
+ return {};
121
+ }
122
+ }
123
+ constructor(store, embedder, causalEngine, hooksEngine, graphStore, llm, inboxPath, pollIntervalMs = 2000, conflictDetector, statusManager, compactRequestProcessor) {
124
+ this.store = store;
125
+ this.embedder = embedder;
126
+ this.causalEngine = causalEngine;
127
+ this.hooksEngine = hooksEngine;
128
+ this.graphStore = graphStore;
129
+ this.llm = llm;
130
+ this.inboxPath = inboxPath;
131
+ this.pollIntervalMs = pollIntervalMs;
132
+ this.conflictDetector = conflictDetector;
133
+ this.statusManager = statusManager;
134
+ this.compactRequestProcessor = compactRequestProcessor;
135
+ }
136
+ setDependencies(hooksEngine, graphStore) {
137
+ this.hooksEngine = hooksEngine;
138
+ this.graphStore = graphStore;
139
+ }
140
+ countPendingFiles() {
141
+ if (!fs.existsSync(this.inboxPath))
142
+ return 0;
143
+ try {
144
+ const files = fs.readdirSync(this.inboxPath);
145
+ const pendingJson = files.filter(f => f.endsWith(".json") && !f.startsWith("reflection_task")).length;
146
+ const riverCapsules = files.filter(f => f.startsWith("river_capsule_") && f.endsWith(".txt")).length;
147
+ return pendingJson + riverCapsules;
148
+ }
149
+ catch {
150
+ return 0;
151
+ }
152
+ }
153
+ runProcessInbox(trigger) {
154
+ void this.processInbox(trigger).catch(err => {
155
+ console.error(`[InboxWatcher] ${trigger} processInbox uncaught error:`, err);
156
+ });
157
+ }
158
+ recordFatalError(fileName, errMsg) {
159
+ this.fatalErrorCount += 1;
160
+ if (this.fatalErrorCount >= 3) {
161
+ console.error(`[InboxWatcher] Fatal error alarm: count=${this.fatalErrorCount} latestFile=${fileName} latestError=${errMsg}`);
162
+ }
163
+ }
164
+ setRiverCapsuleFailureCount(sessionKey, count) {
165
+ setBoundedMapEntry(this.riverCapsuleFailureCounts, sessionKey, count, InboxWatcher.RIVER_CAPSULE_FAILURE_LRU_MAX);
166
+ }
167
+ classifyRiverCapsuleReason(err) {
168
+ const message = err instanceof Error ? err.message : String(err);
169
+ const lower = message.toLowerCase();
170
+ if (lower.includes("timeout") || lower.includes("timed out"))
171
+ return "embed_timeout";
172
+ if (lower.includes("lance") || lower.includes("commit conflict"))
173
+ return "lancedb_write_error";
174
+ if (lower.includes("enoent"))
175
+ return "capsule_file_missing";
176
+ if (lower.includes("capsule_meta"))
177
+ return "capsule_meta_parse_error";
178
+ if (lower.includes("json"))
179
+ return "capsule_meta_parse_error";
180
+ if (lower.includes("writeinbox"))
181
+ return "inbox_write_error";
182
+ return "river_capsule_process_error";
183
+ }
184
+ async recordRiverCapsuleStat(stat) {
185
+ try {
186
+ await this.store.recordConcentratorStat({
187
+ canonicalKey: stat.sessionKey?.trim() || "unknown",
188
+ sessionId: null,
189
+ provider: "all_failed",
190
+ outcome: stat.outcome,
191
+ attemptedProviders: "[]",
192
+ inputTokens: 0,
193
+ outputTokens: null,
194
+ durationMs: stat.durationMs ?? 0,
195
+ failureReason: stat.outcome === "failure" ? "other" : null,
196
+ createdAt: Date.now(),
197
+ });
198
+ }
199
+ catch (err) {
200
+ console.warn("[InboxWatcher] Failed to write concentrator_stats:", err);
201
+ }
202
+ }
203
+ async moveRiverCapsuleToError(filePath) {
204
+ const errorDir = path.join(path.dirname(filePath), "error");
205
+ if (!fs.existsSync(errorDir))
206
+ fs.mkdirSync(errorDir, { recursive: true });
207
+ const targetPath = path.join(errorDir, path.basename(filePath));
208
+ fs.renameSync(filePath, targetPath);
209
+ }
210
+ /**
211
+ * 從 river capsule meta 推導 stat / failure counter 用的 key。
212
+ *
213
+ * 與 sessionIdentity 主鏈不同:river capsule 的 metadata 可能巢狀
214
+ * (capsuleMeta.metadata.sessionKey),且最後保底是 filename,不是 'global'。
215
+ * 因此先把巢狀欄位攤平成標準 payload,再交給 sessionIdentity 解析;
216
+ * 若 isFallback(payload 完全沒有 session 身分)才退到 filename。
217
+ */
218
+ getRiverCapsuleSessionKey(capsuleMeta, filePath) {
219
+ const normalized = {
220
+ sessionKey: capsuleMeta.sessionKey ?? capsuleMeta.metadata?.sessionKey,
221
+ sessionId: capsuleMeta.sessionId ?? capsuleMeta.metadata?.sessionId,
222
+ };
223
+ const identity = resolveSessionIdentity(normalized);
224
+ return identity.isFallback ? path.basename(filePath) : identity.canonicalKey;
225
+ }
226
+ start() {
227
+ if (this.started) {
228
+ console.log('[InboxWatcher] start() skipped: watcher already started');
229
+ return;
230
+ }
231
+ this.started = true;
232
+ if (DEBUG)
233
+ console.log('\x1b[35m[MemoryRiver Watcher] Watcher started and ready to process inbox items\x1b[0m');
234
+ if (!fs.existsSync(this.inboxPath)) {
235
+ fs.mkdirSync(this.inboxPath, { recursive: true });
236
+ }
237
+ this.intervalId = setInterval(() => {
238
+ this.runProcessInbox('interval');
239
+ }, this.pollIntervalMs);
240
+ // 啟動即收割,處理遺留紙條
241
+ this.runProcessInbox('start');
242
+ }
243
+ stop() {
244
+ if (this.intervalId)
245
+ clearInterval(this.intervalId);
246
+ this.intervalId = null;
247
+ this.started = false;
248
+ }
249
+ async processCompactRequest(filePath) {
250
+ const processingPath = filePath + '.processing';
251
+ const fileName = path.basename(filePath);
252
+ try {
253
+ await fs.promises.rename(filePath, processingPath);
254
+ }
255
+ catch {
256
+ return { ok: true, reason: 'already-picked' };
257
+ }
258
+ try {
259
+ await withRetry(async () => {
260
+ const item = await readCompactRequest(processingPath);
261
+ await this.executeCompactRequest(item);
262
+ }, {
263
+ maxAttempts: 3,
264
+ baseDelayMs: 50,
265
+ maxDelayMs: 2000,
266
+ retryableErrors: isRetryableError,
267
+ label: `compact request for ${fileName}`,
268
+ });
269
+ await fs.promises.unlink(processingPath);
270
+ console.log(`[inbox-watcher] compact_request processed: ${fileName}`);
271
+ return { ok: true };
272
+ }
273
+ catch (err) {
274
+ const errMsg = err instanceof Error ? err.message : String(err);
275
+ const errorDir = path.join(this.inboxPath, 'error');
276
+ const errorPath = path.join(errorDir, fileName);
277
+ await fs.promises.mkdir(errorDir, { recursive: true });
278
+ try {
279
+ await fs.promises.rename(processingPath, errorPath);
280
+ }
281
+ catch (moveErr) {
282
+ console.error(`[inbox-watcher] compact_request dead-letter move failed: ${fileName}`, moveErr);
283
+ }
284
+ console.error(`[inbox-watcher] compact_request dead-lettered: ${fileName}`, errMsg);
285
+ return { ok: false, reason: errMsg };
286
+ }
287
+ }
288
+ async executeCompactRequest(item) {
289
+ await this.compactRequestProcessor({
290
+ trackingKey: item.trackingKey,
291
+ sessionId: item.sessionId,
292
+ sessionKey: item.sessionKey,
293
+ originalTokens: item.originalTokens,
294
+ compressedTokens: item.compressedTokens,
295
+ timestamp: item.createdAt,
296
+ });
297
+ }
298
+ async processInbox(trigger = 'interval') {
299
+ const pendingCount = this.countPendingFiles();
300
+ if (pendingCount > 0 || this.isProcessing) {
301
+ console.log(`[InboxWatcher] tick: pending=${pendingCount} isProcessing=${this.isProcessing} startedAt=${this.processingStartedAt || 0} trigger=${trigger}`);
302
+ }
303
+ // 防止 fetch 永久阻塞:若上次 isProcessing 超過 5 分鐘,強制解鎖
304
+ if (this.isProcessing) {
305
+ if (this.processingStartedAt > 0 && Date.now() - this.processingStartedAt > 5 * 60 * 1000) {
306
+ console.warn('[InboxWatcher] isProcessing exceeded 5 minutes; forcing unlock');
307
+ this.isProcessing = false;
308
+ this.processingStartedAt = 0;
309
+ }
310
+ else {
311
+ return;
312
+ }
313
+ }
314
+ this.isProcessing = true;
315
+ this.processingStartedAt = Date.now();
316
+ try {
317
+ if (!fs.existsSync(this.inboxPath))
318
+ return;
319
+ const allFiles = fs.readdirSync(this.inboxPath).sort();
320
+ // C6: 孤兒 .processing 檔案搶救(系統崩潰後恢復)
321
+ const FIVE_MIN_MS = 5 * 60 * 1000;
322
+ const nowMs = Date.now();
323
+ const orphaned = allFiles.filter(f => f.endsWith('.processing'));
324
+ const oldOrphans = orphaned.filter(f => {
325
+ try {
326
+ const stat = fs.statSync(path.join(this.inboxPath, f));
327
+ return nowMs - stat.mtimeMs > FIVE_MIN_MS;
328
+ }
329
+ catch {
330
+ return false;
331
+ }
332
+ });
333
+ if (oldOrphans.length > 0) {
334
+ console.log(`[InboxWatcher] Found ${oldOrphans.length} orphaned files older than 5 minutes; recovering...`);
335
+ for (const fname of oldOrphans) {
336
+ try {
337
+ const fpath = path.join(this.inboxPath, fname);
338
+ const jsonName = fname.endsWith('.json.processing')
339
+ ? fname.replace(/\.processing$/, '')
340
+ : fname.replace(/\.processing$/, '.json');
341
+ const jsonPath = path.join(this.inboxPath, jsonName);
342
+ if (!fs.existsSync(jsonPath)) {
343
+ fs.renameSync(fpath, jsonPath);
344
+ console.log(`[InboxWatcher] Orphan recovered: ${fname} -> ${jsonName}`);
345
+ }
346
+ else {
347
+ fs.unlinkSync(fpath);
348
+ console.log(`[InboxWatcher] Orphan removed because target already exists: ${fname}`);
349
+ }
350
+ }
351
+ catch { /* ignore */ }
352
+ }
353
+ }
354
+ const compactRequestFiles = allFiles.filter(f => isCompactRequestFilename(f));
355
+ // 🌟 寬鬆過濾:只要是 JSON 且不是反思任務,就視為記憶精華
356
+ const pendingFiles = allFiles.filter(f => f.endsWith(".json") && !f.startsWith("reflection_task") && !isCompactRequestFilename(f));
357
+ const riverFiles = allFiles.filter(f => f.startsWith("river_capsule_") && f.endsWith(".txt"));
358
+ const totalWork = compactRequestFiles.length + pendingFiles.length + riverFiles.length;
359
+ if (totalWork > 0) {
360
+ console.log(`[InboxWatcher] processing ${totalWork} files`);
361
+ }
362
+ // C-8:並發處理,上限 INBOX_CONCURRENCY 個同時
363
+ // 單一檔案 stall(timeout)只影響同批其他槽位,不卡死整個佇列
364
+ const INBOX_CONCURRENCY = 3;
365
+ for (let i = 0; i < compactRequestFiles.length; i += INBOX_CONCURRENCY) {
366
+ const batch = compactRequestFiles.slice(i, i + INBOX_CONCURRENCY);
367
+ const results = await Promise.allSettled(batch.map(file => this.processCompactRequest(path.join(this.inboxPath, file))));
368
+ results.forEach((result, index) => {
369
+ const filePath = path.join(this.inboxPath, batch[index]);
370
+ const detail = result.status === 'fulfilled'
371
+ ? (result.value?.ok ? 'ok' : `error=${result.value?.reason ?? 'unknown'}`)
372
+ : `error=${result.reason instanceof Error ? result.reason.message : String(result.reason)}`;
373
+ if (detail !== 'ok') {
374
+ console.log(`[InboxWatcher] compact_request processed: path=${filePath} result=${detail}`);
375
+ }
376
+ });
377
+ }
378
+ for (let i = 0; i < pendingFiles.length; i += INBOX_CONCURRENCY) {
379
+ const batch = pendingFiles.slice(i, i + INBOX_CONCURRENCY);
380
+ const results = await Promise.allSettled(batch.map(file => this.processFile(path.join(this.inboxPath, file))));
381
+ results.forEach((result, index) => {
382
+ const filePath = path.join(this.inboxPath, batch[index]);
383
+ const detail = result.status === 'fulfilled'
384
+ ? (result.value?.ok ? 'ok' : `error=${result.value?.reason ?? 'unknown'}`)
385
+ : `error=${result.reason instanceof Error ? result.reason.message : String(result.reason)}`;
386
+ if (detail !== 'ok') {
387
+ console.log(`[InboxWatcher] file processed: path=${filePath} result=${detail}`);
388
+ }
389
+ });
390
+ }
391
+ for (let i = 0; i < riverFiles.length; i += INBOX_CONCURRENCY) {
392
+ const batch = riverFiles.slice(i, i + INBOX_CONCURRENCY);
393
+ const results = await Promise.allSettled(batch.map(file => this.processRiverCapsule(path.join(this.inboxPath, file))));
394
+ results.forEach((result, index) => {
395
+ const filePath = path.join(this.inboxPath, batch[index]);
396
+ if (result.status === 'fulfilled') {
397
+ if (result.value.ok) {
398
+ console.log(`[InboxWatcher] river_capsule processed: path=${filePath} sessionKey=${result.value.sessionKey ?? 'unknown'} result=ok`);
399
+ }
400
+ else {
401
+ console.log(`[InboxWatcher] river_capsule processed: path=${filePath} sessionKey=${result.value.sessionKey ?? 'unknown'} result=error=${result.value.reason ?? 'unknown'}`);
402
+ }
403
+ }
404
+ else {
405
+ console.log(`[InboxWatcher] river_capsule processed: path=${filePath} result=error=${result.reason instanceof Error ? result.reason.message : String(result.reason)}`);
406
+ }
407
+ });
408
+ }
409
+ // 清理無用佔位符
410
+ allFiles.filter(f => f.startsWith("reflection_task")).forEach(f => {
411
+ fs.unlinkSync(path.join(this.inboxPath, f));
412
+ });
413
+ }
414
+ catch (err) {
415
+ console.error("Inbox watcher error:", err);
416
+ }
417
+ finally {
418
+ this.isProcessing = false;
419
+ this.processingStartedAt = 0;
420
+ }
421
+ }
422
+ async processRiverCapsule(filePath) {
423
+ const startedAt = Date.now();
424
+ let sessionKey;
425
+ try {
426
+ const rawText = fs.readFileSync(filePath, "utf-8");
427
+ console.log(`[inbox-watcher] Processing river_capsule: ${filePath.split('/').pop()}`);
428
+ // 解析 CAPSULE_META comment,extract 所有結構化欄位
429
+ // 格式:\x3C!-- CAPSULE_META:{...json...} --\x3E
430
+ const capsuleMetaMatch = rawText.match(/\x3C!-- CAPSULE_META:([\s\S]*?) --\x3E/);
431
+ let capsuleMeta = {};
432
+ let textContent = rawText;
433
+ if (capsuleMetaMatch) {
434
+ try {
435
+ capsuleMeta = JSON.parse(capsuleMetaMatch[1]);
436
+ console.log(`[inbox-watcher] CAPSULE_META parsed: type=${capsuleMeta.capsuleType}, mode=${capsuleMeta.category}, skill=${capsuleMeta.skillName || '(none)'}`);
437
+ // 去掉 CAPSULE_META comment 行,保留正文(可能是多行)
438
+ textContent = rawText.replace(capsuleMetaMatch[0], '').trim();
439
+ }
440
+ catch (err) {
441
+ sessionKey = path.basename(filePath);
442
+ throw new Error(`CAPSULE_META parse failed: ${err instanceof Error ? err.message : String(err)}`);
443
+ }
444
+ }
445
+ sessionKey = this.getRiverCapsuleSessionKey(capsuleMeta, filePath);
446
+ // 萃取所有欄位(智慧相容大腦的 JSON 結構)
447
+ const capsuleType = capsuleMeta.capsuleType || capsuleMeta.metadata?.type || 'working_memory';
448
+ // 🎯 優先去多個可能的位置抓取 category,如果真的都沒有,預設給 'fact' 而不是 'relationship'
449
+ const conversationMode = capsuleMeta.category || capsuleMeta.metadata?.category || 'fact';
450
+ const skillName = capsuleMeta.skillName || capsuleMeta.metadata?.skillName;
451
+ const triggerConditions = capsuleMeta.triggerConditions || capsuleMeta.metadata?.triggerConditions || [];
452
+ const executionSteps = capsuleMeta.executionSteps || capsuleMeta.metadata?.executionSteps || [];
453
+ const confidence = capsuleMeta.confidence || 0;
454
+ const originalTurnCount = capsuleMeta.turnCount;
455
+ const originalTokens = capsuleMeta.originalTokens;
456
+ const parentId = capsuleMeta.parentId;
457
+ const capsuleMetadata = {
458
+ ...((capsuleMeta.metadata && typeof capsuleMeta.metadata === 'object') ? capsuleMeta.metadata : {}),
459
+ ...capsuleMeta,
460
+ };
461
+ delete capsuleMetadata.metadata;
462
+ // 🎯 優先抓取大腦賦予的重要性,找不到再給 0.8 (滿血小紙條標準),拒絕 0.3 垃圾權重
463
+ const parsedImportance = capsuleMeta.importance ?? capsuleMeta.metadata?.importance ?? 0.8;
464
+ const importance = capsuleType === 'skill_capsule' ? 0.9 : parsedImportance;
465
+ console.log(`[inbox-watcher] Written to pending inbox: ${textContent.slice(0, 40)}... (parentId=${parentId || '(none)'})`);
466
+ // 🩺 醫生修復區:補回遺失的 writeInbox 函數呼叫
467
+ await InboxWatcher.writeInbox(this.inboxPath, {
468
+ text: textContent,
469
+ category: conversationMode,
470
+ importance,
471
+ capsuleType,
472
+ skillName,
473
+ triggerConditions,
474
+ executionSteps,
475
+ confidence,
476
+ parentId,
477
+ metadata: {
478
+ ...capsuleMetadata,
479
+ turnCount: originalTurnCount,
480
+ originalTokens,
481
+ skillName,
482
+ triggerConditions,
483
+ executionSteps,
484
+ confidence,
485
+ parentId,
486
+ }
487
+ });
488
+ fs.unlinkSync(filePath);
489
+ this.riverCapsuleFailureCounts.delete(sessionKey);
490
+ await this.recordRiverCapsuleStat({
491
+ sessionKey,
492
+ outcome: "success",
493
+ reason: null,
494
+ durationMs: Date.now() - startedAt,
495
+ meta: JSON.stringify({
496
+ fileName: path.basename(filePath),
497
+ capsuleType,
498
+ category: conversationMode,
499
+ }),
500
+ });
501
+ return { ok: true, sessionKey };
502
+ }
503
+ catch (err) {
504
+ const reason = this.classifyRiverCapsuleReason(err);
505
+ sessionKey = sessionKey ?? path.basename(filePath);
506
+ const nextFailureCount = (this.riverCapsuleFailureCounts.get(sessionKey) ?? 0) + 1;
507
+ this.setRiverCapsuleFailureCount(sessionKey, nextFailureCount);
508
+ if (nextFailureCount >= InboxWatcher.RIVER_CAPSULE_FAILURE_THRESHOLD) {
509
+ try {
510
+ await this.moveRiverCapsuleToError(filePath);
511
+ console.error(`[InboxWatcher] river_capsule moved to error/: sessionKey=${sessionKey} failures=${nextFailureCount} reason=${reason}`);
512
+ this.riverCapsuleFailureCounts.delete(sessionKey);
513
+ }
514
+ catch (moveErr) {
515
+ console.error(`[InboxWatcher] Failed to move river_capsule to error/: ${path.basename(filePath)}`, moveErr);
516
+ }
517
+ }
518
+ console.error("River capsule failed:", err);
519
+ await this.recordRiverCapsuleStat({
520
+ sessionKey,
521
+ outcome: "failure",
522
+ reason,
523
+ durationMs: Date.now() - startedAt,
524
+ meta: JSON.stringify({
525
+ fileName: path.basename(filePath),
526
+ consecutiveFailures: nextFailureCount,
527
+ }),
528
+ });
529
+ return { ok: false, reason, sessionKey };
530
+ }
531
+ }
532
+ async processFile(filePath) {
533
+ const fileName = path.basename(filePath);
534
+ // 空的或格式無效的 → 直接砍掉,不進 error
535
+ try {
536
+ const stats = fs.statSync(filePath);
537
+ if (stats.size === 0) {
538
+ fs.unlinkSync(filePath);
539
+ return { ok: true, reason: 'deleted-empty' };
540
+ }
541
+ const item = JSON.parse(fs.readFileSync(filePath, "utf-8"));
542
+ if (!item.text || !item.category) {
543
+ fs.unlinkSync(filePath);
544
+ return { ok: true, reason: 'deleted-invalid' };
545
+ }
546
+ }
547
+ catch {
548
+ // JSON parse 失敗或 stat 失敗 → 進 error
549
+ }
550
+ // 改為 .processing 檔名,隔離搶食 & 避免重試時重複處理
551
+ const procPath = filePath.replace(/(\.json)$/, '.processing');
552
+ try {
553
+ fs.renameSync(filePath, procPath);
554
+ }
555
+ catch (renameErr) {
556
+ // 已被其他 processFile 實例撿走,直接略過
557
+ return { ok: true, reason: 'already-picked' };
558
+ }
559
+ try {
560
+ // ════════════════════════════════════════════════════════
561
+ // 重試區:整條 pipeline 包進 withRetry,指數退避
562
+ // ════════════════════════════════════════════════════════
563
+ await withRetry(() => this._processMemoryEntry(procPath), {
564
+ maxAttempts: 3,
565
+ baseDelayMs: 50,
566
+ maxDelayMs: 2000,
567
+ retryableErrors: isRetryableError,
568
+ label: `memory pipeline for ${fileName}`,
569
+ });
570
+ // 成功:砍掉 processing 檔
571
+ try {
572
+ fs.unlinkSync(procPath);
573
+ }
574
+ catch { /* already gone */ }
575
+ if (DEBUG)
576
+ console.log(`\x1b[32m[MemoryRiver] Item processed successfully: ${fileName}\x1b[0m`);
577
+ return { ok: true };
578
+ }
579
+ catch (err) {
580
+ // 所有重試耗盡 → 進 error/,不再卡死系統
581
+ const errMsg = err instanceof Error ? err.message : String(err);
582
+ console.error(`Processing failed for ${fileName} after all retries:`, errMsg);
583
+ const errorDir = path.join(path.dirname(procPath), "error");
584
+ if (!fs.existsSync(errorDir))
585
+ fs.mkdirSync(errorDir, { recursive: true });
586
+ const errorFileName = path.basename(procPath).replace('.processing', '.json');
587
+ try {
588
+ fs.renameSync(procPath, path.join(errorDir, errorFileName));
589
+ }
590
+ catch (moveErr) {
591
+ console.error(`[InboxWatcher] Failed to move failed file to error/: ${fileName}`, moveErr);
592
+ }
593
+ this.recordFatalError(fileName, errMsg);
594
+ return { ok: false, reason: errMsg };
595
+ }
596
+ }
597
+ /**
598
+ * _processMemoryEntry — 濃縮膠囊寫入記憶庫的核心 pipeline
599
+ * 不處理檔案狀態(由 processFile 統一管理 rename/delete)
600
+ */
601
+ async _processMemoryEntry(procPath) {
602
+ const fileName = path.basename(procPath);
603
+ const item = JSON.parse(fs.readFileSync(procPath, "utf-8"));
604
+ let importanceVal = item.importance ?? 1.0;
605
+ // ── 自然語言膠囊生成 ────────────────────────────────────────
606
+ const hasCapsuleMeta = item.text.includes('[CAPSULE_META]') || item.category === 'skill';
607
+ let capsuleSkillName;
608
+ let capsuleTriggerConditions = [];
609
+ let capsuleExecutionSteps = [];
610
+ let capsuleConfidence = 100;
611
+ const capsuleType = item.capsuleType ?? 'working_memory';
612
+ // ── Structured Slot 抽取(Phase 1)────────────────────────
613
+ // confidence >= 0.8 → 建立 Slot;0.5–0.8 → 待審核池;< 0.5 → 純 free-text
614
+ let slotData = null;
615
+ try {
616
+ slotData = await this.extractSlot(item.text, item.category);
617
+ if (slotData?.isStructured) {
618
+ console.log(`[inbox-watcher] Slot extracted: key=${slotData.slotKey} value=${slotData.slotValue} confidence=${slotData.confidence}`);
619
+ }
620
+ }
621
+ catch (slotErr) {
622
+ console.warn('[inbox-watcher] Slot extraction failed; falling back to free text:', slotErr);
623
+ }
624
+ if (hasCapsuleMeta) {
625
+ const capsuleMetaMatch = item.text.match(/\[CAPSULE_META\]\s*\{([^}]+)\}/);
626
+ if (capsuleMetaMatch) {
627
+ try {
628
+ const parsed = JSON.parse('{' + capsuleMetaMatch[1] + '}');
629
+ capsuleSkillName = parsed.skillName;
630
+ capsuleTriggerConditions = parsed.triggerConditions ?? [];
631
+ capsuleExecutionSteps = parsed.executionSteps ?? [];
632
+ capsuleConfidence = parsed.confidence ?? 100;
633
+ }
634
+ catch { /* ignore */ }
635
+ }
636
+ if (!capsuleSkillName && item.text.length > 0) {
637
+ const summary = item.text.replace(/\[CAPSULE_META\]\s*\{[^}]*\}/g, '').trim().slice(0, 80);
638
+ capsuleSkillName = await this.extractSkillNameFromText(summary, item.text);
639
+ capsuleTriggerConditions = this.extractTriggersFromText(item.text);
640
+ }
641
+ console.log(`[inbox-watcher] Natural-language capsule: name=${capsuleSkillName}`);
642
+ }
643
+ // ── 向量化(含空向量重試)───────────────────────────────
644
+ let vector = [];
645
+ const EMBED_RETRIES = 3;
646
+ for (let attempt = 1; attempt <= EMBED_RETRIES; attempt++) {
647
+ vector = await this.embedder.embed(item.text, 'store');
648
+ if (vector && Array.isArray(vector) && vector.length > 0)
649
+ break;
650
+ if (attempt < EMBED_RETRIES) {
651
+ console.warn(`[InboxWatcher] Empty vector; retrying embedding in 2s... (${attempt}/${EMBED_RETRIES})`);
652
+ await new Promise(r => setTimeout(r, 2000));
653
+ }
654
+ }
655
+ // 🛑 重試後仍然空向量 → 交給 processFile 保留並 dead-letter
656
+ if (!vector || !Array.isArray(vector) || vector.length === 0) {
657
+ throw new Error('empty_embedding');
658
+ }
659
+ // ── 重複檢查 ───────────────────────────────────────────────
660
+ try {
661
+ const similar = await this.store.hybridVectorSearch(item.text, 3);
662
+ if (similar.length > 0) {
663
+ const dist = similar[0].rawDistance;
664
+ if (dist < 0.15) {
665
+ console.log(`[InboxWatcher] Skipping duplicate memory dist=${dist.toFixed(3)}`);
666
+ return; // 不刪檔,由 processFile 統一處理
667
+ }
668
+ }
669
+ }
670
+ catch (dupErr) {
671
+ console.warn("[InboxWatcher] Duplicate check failed; continuing write:", dupErr);
672
+ }
673
+ // ── 因果關係判斷(可能 API 瞬斷)────────────────────────────
674
+ const relation = await this.causalEngine.determineRelation(item.text, undefined, item.category);
675
+ // ── Hook 生成 ──────────────────────────────────────────────
676
+ let hooks = [];
677
+ let entities = [];
678
+ if (this.hooksEngine) {
679
+ hooks = await this.hooksEngine.generateHooks(item.text, item.category);
680
+ entities = await this.hooksEngine.generateEntities(item.text);
681
+ }
682
+ // 優先使用 metadata 裡明確指定的 health(例如 concentrator 大膠囊標記的 30)
683
+ const metadataHealth = item.metadata?.health;
684
+ const healthScore = typeof metadataHealth === 'number' ? metadataHealth : Math.round(importanceVal * 100);
685
+ const metadataObj = {
686
+ ...(item.metadata ?? {}),
687
+ hooks, entities, importance: importanceVal,
688
+ health: { healthScore, accessCount: 0, lastAccessedAt: Date.now() },
689
+ ...(item.tool_result !== undefined ? { tool_result: item.tool_result } : {}),
690
+ };
691
+ if (hasCapsuleMeta && capsuleSkillName) {
692
+ metadataObj.capsuleType = capsuleType;
693
+ metadataObj.skillName = capsuleSkillName;
694
+ metadataObj.triggerConditions = capsuleTriggerConditions;
695
+ metadataObj.executionSteps = capsuleExecutionSteps;
696
+ // 膠囊資訊已存在 metadataObj 中,隨主記憶一起寫入 memories table
697
+ console.log(`[inbox-watcher] Skill capsule tagged: ${capsuleSkillName} (written with primary memory)`);
698
+ }
699
+ const abstractness = judgeAbstractness(item.text);
700
+ if (abstractness.isAbstract) {
701
+ importanceVal = 0.1;
702
+ metadataObj.importance = importanceVal;
703
+ metadataObj.abstractness = abstractness.abstractness;
704
+ metadataObj.abstractRejected = true;
705
+ metadataObj.abstractReasons = abstractness.reasons;
706
+ console.log(`[InboxWatcher] Soft abstractness rejection: ${abstractness.reasons.join(',')} ` +
707
+ `(score=${abstractness.abstractness.toFixed(2)}) "${item.text.slice(0, 50)}..."`);
708
+ if (typeof this.store.recordSubsystemEffectiveness === 'function') {
709
+ void this.store.recordSubsystemEffectiveness({
710
+ subsystem: 'inbox',
711
+ event: 'abstract_rejected',
712
+ entityId: '',
713
+ outcome: 'soft_rejected',
714
+ score: abstractness.abstractness,
715
+ count: 1,
716
+ metadata: {
717
+ reasons: abstractness.reasons,
718
+ ruleHits: abstractness.ruleHits,
719
+ textPreview: item.text.slice(0, 80),
720
+ category: item.category,
721
+ originalImportance: item.importance ?? 1.0,
722
+ },
723
+ }).catch((err) => {
724
+ console.warn('[PR-E9A] abstract_rejected event failed:', err?.message);
725
+ });
726
+ }
727
+ }
728
+ else {
729
+ metadataObj.abstractness = abstractness.abstractness;
730
+ metadataObj.abstractRejected = false;
731
+ }
732
+ const hooksText = hooks?.length > 0 ? hooks.map((h) => '#' + h.keyword).join(' ') : "";
733
+ const finalStoreText = hooksText ? `${item.text} [${hooksText}]` : item.text;
734
+ // ── 寫入記憶庫(UPDATE path)───────────────────────────────
735
+ let storedEntryId = '';
736
+ // Slot routing:低 confidence(< 0.5)不走 slot 邏輯
737
+ const isSlotEligible = slotData?.isStructured === true && (slotData.confidence ?? 0) >= 0.5;
738
+ if (relation.action === "UPDATE" && relation.parentId) {
739
+ // 加寫鎖:防止兩筆記憶同時 UPDATE 同一個 parentId 造成 race condition
740
+ // Slot supersedes 鏈:若新 entry 有 slotKey → 查找並標記同 key 的舊 active 記錄
741
+ let supersedesIds = [];
742
+ if (isSlotEligible && slotData?.slotKey) {
743
+ try {
744
+ const superseded = await this.checkSupersedes(slotData.slotKey, '');
745
+ supersedesIds = superseded.filter(id => id !== relation.parentId);
746
+ }
747
+ catch (err) {
748
+ console.warn('[inbox-watcher] checkSupersedes failed:', err);
749
+ }
750
+ }
751
+ await this.withParentLock(relation.parentId, async () => {
752
+ const stored = await this.store.store({
753
+ text: finalStoreText, vector, importance: importanceVal,
754
+ category: item.category,
755
+ metadata: JSON.stringify(metadataObj), parentId: relation.parentId,
756
+ creationAuditSource: 'inbox-watcher.new',
757
+ ...(isSlotEligible && slotData ? {
758
+ slotKey: slotData.slotKey,
759
+ slotValue: slotData.slotValue,
760
+ extractionDomain: slotData.extractionDomain,
761
+ confidence: slotData.confidence,
762
+ ...(supersedesIds.length > 0 ? { supersedes: supersedesIds } : {}),
763
+ } : {}),
764
+ });
765
+ storedEntryId = stored.id;
766
+ const oldMemory = await this.store.getById(relation.parentId, true);
767
+ if (oldMemory) {
768
+ await this.statusManager.changeStatus({
769
+ memoryId: relation.parentId,
770
+ toStatus: 'deprecated',
771
+ reason: 'causal_update',
772
+ source: 'inbox-watcher.update',
773
+ });
774
+ if (oldMemory.slotKey) {
775
+ console.log(`[inbox-watcher] UPDATE superseding old slot: ${oldMemory.slotKey}`);
776
+ }
777
+ }
778
+ });
779
+ }
780
+ else {
781
+ // Slot supersedes 鏈:若新 entry 有 slotKey → 查找並標記同 key 的舊 active 記錄
782
+ let supersedesIds = [];
783
+ if (isSlotEligible && slotData?.slotKey) {
784
+ try {
785
+ supersedesIds = await this.checkSupersedes(slotData.slotKey, '');
786
+ if (supersedesIds.length > 0) {
787
+ console.log(`[inbox-watcher] Slot supersedes: ${slotData.slotKey} supersedes ${supersedesIds.length} previous versions`);
788
+ }
789
+ }
790
+ catch (err) {
791
+ console.warn('[inbox-watcher] checkSupersedes failed:', err);
792
+ }
793
+ }
794
+ const stored = await this.store.store({
795
+ text: finalStoreText, vector, importance: importanceVal,
796
+ category: item.category,
797
+ parentId: relation.parentId, metadata: JSON.stringify(metadataObj),
798
+ creationAuditSource: 'inbox-watcher.new',
799
+ ...(isSlotEligible && slotData ? {
800
+ slotKey: slotData.slotKey,
801
+ slotValue: slotData.slotValue,
802
+ extractionDomain: slotData.extractionDomain,
803
+ confidence: slotData.confidence,
804
+ ...(supersedesIds.length > 0 ? { supersedes: supersedesIds } : {}),
805
+ } : {}),
806
+ });
807
+ storedEntryId = stored.id;
808
+ // 將舊版本標記為 deprecated(新記憶成功寫入後才執行)
809
+ for (const oldId of supersedesIds) {
810
+ try {
811
+ await this.statusManager.changeStatus({
812
+ memoryId: oldId,
813
+ toStatus: 'deprecated',
814
+ reason: 'slot_supersedes',
815
+ source: 'inbox-watcher.slot',
816
+ supersededBy: '',
817
+ });
818
+ }
819
+ catch { /* ignore */ }
820
+ }
821
+ }
822
+ // ── 知識圖譜寫入(三元組,non-critical)───────────────────
823
+ if (this.graphStore && entities.length > 0) {
824
+ try {
825
+ await this.graphStore.addTriples(entities, storedEntryId);
826
+ console.log(`[inbox-watcher] Graph write: ${entities.length} triples (memoryId=${storedEntryId.slice(0, 8)})`);
827
+ }
828
+ catch (err) {
829
+ console.warn('[inbox-watcher] Graph write failed (non-fatal):', err);
830
+ }
831
+ }
832
+ // ── 衝突偵測(non-critical,失敗不重試)────────────────────
833
+ if (this.conflictDetector) {
834
+ try {
835
+ const conflict = await this.conflictDetector.detectAndResolve(storedEntryId, item.text, item.category);
836
+ if (conflict.hasConflict) {
837
+ console.log(`[InboxWatcher] Conflict resolved: ${conflict.resolution}`);
838
+ }
839
+ }
840
+ catch (err) {
841
+ console.warn('[InboxWatcher] Conflict detection failed (non-fatal):', err);
842
+ }
843
+ }
844
+ }
845
+ static async writeInbox(inboxPath, item) {
846
+ if (!fs.existsSync(inboxPath))
847
+ fs.mkdirSync(inboxPath, { recursive: true });
848
+ const filename = `pending_${Date.now()}_${Math.random().toString(36).slice(2, 5)}.json`;
849
+ const filePath = path.join(inboxPath, filename);
850
+ const tempPath = `${filePath}.tmp`;
851
+ await fs.promises.writeFile(tempPath, JSON.stringify(item, null, 2));
852
+ await fs.promises.rename(tempPath, filePath);
853
+ return filename;
854
+ }
855
+ // ========================================================================
856
+ // 🌟 技能發現系統
857
+ // ========================================================================
858
+ /**
859
+ * 從記憶文字萃取技能名稱(簡單關鍵詞法,fallback)
860
+ */
861
+ async extractSkillNameFromText(summary, fullText) {
862
+ // 嘗試用 LLM 萃取
863
+ try {
864
+ const prompt = `從以下文字萃取技能名稱(最多15字,纯中文):\n${summary}`;
865
+ const name = (await this.llm.generate(prompt, {
866
+ purpose: 'skill-name-extraction',
867
+ maxTokens: 20,
868
+ })).trim();
869
+ if (name)
870
+ return name.slice(0, 15);
871
+ }
872
+ catch (err) {
873
+ console.warn('[SkillDiscovery] extractSkillNameFromText fetch failed (timeout or network error):', err.message);
874
+ /* fall through to fallback */
875
+ }
876
+ // Fallback: 取第一個語素詞
877
+ const words = summary.replace(/[^\w\u4e00-\u9fa5]/g, ' ').split(/\s+/).filter(Boolean);
878
+ return (words[0] || '未命名技能').slice(0, 15);
879
+ }
880
+ /**
881
+ * 從記憶文字抽取觸發關鍵詞(簡單枚舉法)
882
+ */
883
+ extractTriggersFromText(text) {
884
+ // 去除 CAPSULE_META 區塊
885
+ const clean = text.replace(/\[CAPSULE_META\]\s*\{[^}]*\}/g, '');
886
+ // 簡單關鍵詞抽取:技術術語、常見命令等
887
+ const keywords = [];
888
+ const techPatterns = [
889
+ /git\s+\w+/gi, /docker\s+\w*/gi, /npm\s+\w+/gi, /yarn\s+\w+/gi,
890
+ /curl\s+[^ ]+/gi, /ssh\s+[^ ]+/gi, /grep\s+[^ ]+/gi,
891
+ /[A-Z][a-z]+(?:\.[a-z]+){1,3}/g, // 函式名如 OpenClaw.handle
892
+ /\b\w+@\w+\.\w+\b/g, // email
893
+ /\x60[^\x60]+\x60/g, // code backtick (改用 hex \x60 防 UI 崩潰)
894
+ ];
895
+ for (const pattern of techPatterns) {
896
+ const matches = clean.match(pattern);
897
+ if (matches) {
898
+ for (const m of matches) {
899
+ const kw = m.replace(/[\x60]/g, '').trim();
900
+ if (kw && kw.length > 2 && !keywords.includes(kw)) {
901
+ keywords.push(kw.slice(0, 30));
902
+ }
903
+ }
904
+ }
905
+ }
906
+ return [...new Set(keywords)].slice(0, 10);
907
+ }
908
+ // ========================================================================
909
+ // 🎯 Structured Slot 系統(Phase 1)
910
+ // ========================================================================
911
+ /**
912
+ * extractSlot — LLM 結構化抽取
913
+ * 輸入文字 → 判斷是否可結構化 → 輸出 slotKey / slotValue / confidence / extractionDomain
914
+ *
915
+ * confidence 等級:
916
+ * >= 0.8:高可信,建立 Slot,自動 supersedes 檢查
917
+ * 0.5–0.8:中可信,寫入待審核池(Night Consolidation 處理)
918
+ * < 0.5:低可信,純 free-text,不建 Slot
919
+ */
920
+ async extractSlot(text, category) {
921
+ const prompt = `你是結構化資訊抽取 AI。分析以下文字,判斷是否包含可結構化的關鍵事實參數。
922
+
923
+ 文字:${text.slice(0, 800)}
924
+
925
+ Slot Key 命名規範:{namespace}:{param_name}
926
+ namespace 自由命名但同類事實要穩定(如 user:、memory:、project:);extractionDomain 才是四選一:technical | identity | preference | free_text
927
+
928
+ 範例:
929
+ - "drift threshold 調到 0.78" → slotKey="memory:drift_threshold", slotValue=0.78, domain="technical"
930
+ - "老闆喜歡喝手沖咖啡" → slotKey="user:coffee_preference", slotValue="手沖咖啡", domain="preference"
931
+ - "我是三重人" → slotKey="identity:location", slotValue="三重", domain="identity"
932
+ - "今天天氣很好" → 無結構化資訊
933
+
934
+ 輸出嚴格 JSON:
935
+ {
936
+ "slotKey": "namespace:param_name 或空字串",
937
+ "slotValue": "具體數值或字串或布林值,或 null",
938
+ "confidence": 0.0-1.0(抽取可靠性),
939
+ "extractionDomain": "technical | identity | preference | free_text",
940
+ "isStructured": true或false
941
+ }
942
+
943
+ ⚠️ 【防呆警告:欄位型別嚴格限制】⚠️
944
+ 1. "confidence" 欄位只能是 0.0 到 1.0 之間的「數字」!
945
+ 2. 絕對不准輸出陣列(如 [])或字串形式的數字!如果真的無法評估,請一律輸出 0。
946
+ 3. "slotValue" 如果為空請寫 null,嚴禁使用空陣列 []。
947
+
948
+ 若文字不包含可結構化的參數,輸出:{"slotKey":"","slotValue":null,"confidence":0,"extractionDomain":"free_text","isStructured":false}`;
949
+ try {
950
+ const raw = await this.llm.generate(prompt, {
951
+ purpose: 'slot-extraction',
952
+ maxTokens: 800,
953
+ });
954
+ if (!raw)
955
+ return null;
956
+ // 👇 括號天平演算法 (Brace Balancer):完美抵禦廢話與 Markdown 👇
957
+ let parsed = {};
958
+ const cleanRaw = raw.replace(/\x60\x60\x60(?:json)?\n?/i, '').replace(/\x60\x60\x60\n?$/, '').trim();
959
+ try {
960
+ parsed = JSON.parse(cleanRaw);
961
+ }
962
+ catch (e) {
963
+ let start = cleanRaw.indexOf('{');
964
+ if (start !== -1) {
965
+ let depth = 0, end = -1;
966
+ for (let i = start; i < cleanRaw.length; i++) {
967
+ if (cleanRaw[i] === '{')
968
+ depth++;
969
+ else if (cleanRaw[i] === '}')
970
+ depth--;
971
+ if (depth === 0) {
972
+ end = i;
973
+ break;
974
+ }
975
+ }
976
+ if (end !== -1) {
977
+ try {
978
+ parsed = JSON.parse(cleanRaw.substring(start, end + 1));
979
+ }
980
+ catch (err2) {
981
+ console.warn('[inbox-watcher] extractSlot still failed after bracket balancing');
982
+ return null;
983
+ }
984
+ }
985
+ else {
986
+ console.warn('[inbox-watcher] extractSlot LLM failed: complete JSON structure not found');
987
+ return null;
988
+ }
989
+ }
990
+ else {
991
+ return null;
992
+ }
993
+ }
994
+ // 過濾:confidence < 0.5 → 不視為結構化
995
+ if ((parsed.confidence ?? 0) < 0.5) {
996
+ parsed.isStructured = false;
997
+ }
998
+ return {
999
+ slotKey: parsed.slotKey ?? '',
1000
+ slotValue: parsed.slotValue ?? null,
1001
+ confidence: parsed.confidence ?? 0,
1002
+ extractionDomain: parsed.extractionDomain ?? 'free_text',
1003
+ isStructured: parsed.isStructured === true,
1004
+ };
1005
+ }
1006
+ catch (err) {
1007
+ console.warn('[inbox-watcher] extractSlot LLM failed:', err);
1008
+ return null;
1009
+ }
1010
+ }
1011
+ /**
1012
+ * checkSupersedes — 查詢同 slotKey 的所有 active 舊版本
1013
+ * @returns 被取代的舊 entry id 清單
1014
+ */
1015
+ async checkSupersedes(slotKey, _newId) {
1016
+ if (!slotKey)
1017
+ return [];
1018
+ try {
1019
+ const existing = await this.store.searchBySlotKey(slotKey);
1020
+ // 只回傳 status = 'active' 的舊版本(排除已 deprecated)
1021
+ return existing
1022
+ .filter(e => {
1023
+ try {
1024
+ const meta = typeof e.metadata === 'string' ? JSON.parse(e.metadata) : (e.metadata || {});
1025
+ // status 為 'active' 或未設定時視為有效
1026
+ return meta.status === 'active' || meta.status == null;
1027
+ }
1028
+ catch {
1029
+ return true; // parse 失敗時保守視為有效
1030
+ }
1031
+ })
1032
+ .map(e => e.id);
1033
+ }
1034
+ catch (err) {
1035
+ console.warn('[inbox-watcher] searchBySlotKey failed:', err);
1036
+ return [];
1037
+ }
1038
+ }
1039
+ }