@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,150 @@
1
+ /**
2
+ * Causal - 因果突觸判定
3
+ * memory-lance-v4
4
+ */
5
+ export class CausalEngine {
6
+ store;
7
+ embedder;
8
+ updateThreshold;
9
+ causalThreshold;
10
+ overlapThreshold;
11
+ embeddingModel;
12
+ embeddingDim;
13
+ constructor(store, embedder, config = {}) {
14
+ this.store = store;
15
+ this.embedder = embedder;
16
+ // 儲存 embedding 維度(用於動態調整閾值)
17
+ this.embeddingDim = config.embeddingDim ?? 1024;
18
+ // 根據維度動態調整閾值
19
+ // Qwen3 1024d: 維度較低,向量更緊密,相似度普遍較高 → 需較高閾值
20
+ // Gemini 3072d: 維度較高,向量更稀疏,相似度普遍較低 → 需較低閾值
21
+ const isLowDim = this.embeddingDim <= 1280; // 1024d
22
+ const dimAdjustedUpdateThreshold = isLowDim ? 0.28 : 0.20;
23
+ const dimAdjustedCausalThreshold = isLowDim ? 0.32 : 0.23;
24
+ // 從 config 讀取,未提供時使用維度調整後的預設值
25
+ this.updateThreshold = config.updateThreshold ?? dimAdjustedUpdateThreshold;
26
+ this.causalThreshold = config.causalThreshold ?? dimAdjustedCausalThreshold;
27
+ this.overlapThreshold = config.overlapThreshold ?? 0.30;
28
+ this.embeddingModel = config.embeddingModel ?? "hf.co/Qwen/Qwen3-Embedding-0.6B-GGUF";
29
+ }
30
+ /**
31
+ * 判定新記憶與現有記憶的因果關係
32
+ * @param category - 新記憶的類別(可選,用於 Category-aware UPDATE 加速)
33
+ */
34
+ async determineRelation(text, excludeId, category) {
35
+ const vector = await this.embedder.embed(text);
36
+ // 搜尋最相似的記憶 (取前 5 筆就夠了)
37
+ const results = await this.store.vectorSearch(vector, 5);
38
+ // 🛡️ 過濾掉自己,以及系統初始化專用的 init_ 記憶
39
+ const filtered = results.filter(r => {
40
+ const isSelf = excludeId ? r.entry.id === excludeId : false;
41
+ const isInit = r.entry.id.startsWith("init_");
42
+ return !isSelf && !isInit;
43
+ });
44
+ if (filtered.length === 0) {
45
+ return { action: "INDEPENDENT", parentId: null };
46
+ }
47
+ const nearest = filtered[0];
48
+ const distance = nearest.rawDistance;
49
+ // ⚠️ 使用 configurable threshold(Gemini 3072d 預設值:0.15 / 0.45)
50
+ if (distance < this.causalThreshold) {
51
+ // 相似度高,判斷具體關係
52
+ const action = await this.judgeAction(text, nearest.entry.text, distance, category, nearest.entry.category);
53
+ return {
54
+ action,
55
+ parentId: action === "CAUSAL" || action === "UPDATE" ? nearest.entry.id : null,
56
+ distance,
57
+ };
58
+ }
59
+ return { action: "INDEPENDENT", parentId: null, distance };
60
+ }
61
+ /**
62
+ * 判斷具體因果關係(使用精準距離判斷 + 字面重疊二次確認 + Category 感知)
63
+ * ⚠️ threshold 來自 configurable 參數(Qwen3 1024d 預設:0.25 / 0.40)
64
+ *
65
+ * 🛡️ 距離漂移保護:當 distance < updateThreshold 時,
66
+ * 需同時通過字面重疊度檢查(Jaccard >= overlapThreshold)才判定 UPDATE,
67
+ * 否則降級為 CAUSAL,防止語意蒸餾後的向量飄移導致誤覆寫。
68
+ *
69
+ * 🆕 Category 同源加速:相同 category 的記憶,UPDATE 門檻放寬 50%,
70
+ * 且 distance 極低時即使字面重疊不達標也判定 UPDATE。
71
+ */
72
+ async judgeAction(newText, existingText, distance, newCategory, existingCategory) {
73
+ // 🆕 Category 同源加速:相同 category → UPDATE 門檻放寬 50%
74
+ const sameCategory = !!(newCategory && existingCategory && newCategory === existingCategory);
75
+ const effectiveUpdateThreshold = sameCategory
76
+ ? this.updateThreshold * 1.5
77
+ : this.updateThreshold;
78
+ // ⚠️ 第一分支:UPDATE threshold
79
+ if (distance <= effectiveUpdateThreshold) {
80
+ // 🛡️ 二次確認:distance 通過還不夠,字面也要有足夠重疊
81
+ const overlap = this.computeOverlap(newText, existingText);
82
+ if (overlap >= this.overlapThreshold) {
83
+ return "UPDATE";
84
+ }
85
+ // 🆕 即使字面重疊不夠,同 category 且 distance 極低 → 仍判定 UPDATE
86
+ if (sameCategory && distance < this.updateThreshold * 0.75) {
87
+ return "UPDATE";
88
+ }
89
+ // 重疊度不足,降級為 CAUSAL(不覆寫,只建立關聯)
90
+ return "CAUSAL";
91
+ // ⚠️ 第二分支:CAUSAL threshold
92
+ }
93
+ else if (distance <= this.causalThreshold) {
94
+ return "CAUSAL";
95
+ }
96
+ else {
97
+ return "INDEPENDENT";
98
+ }
99
+ }
100
+ /**
101
+ * 計算兩段文字的字面重疊度(Jaccard similarity)
102
+ * - 統一小寫、移除停用詞、比較 word-level set
103
+ * - lightweight,無需額外依賴
104
+ */
105
+ computeOverlap(textA, textB) {
106
+ const STOP_WORDS = new Set([
107
+ "the", "a", "an", "is", "are", "was", "were", "be", "been", "being",
108
+ "have", "has", "had", "do", "does", "did", "will", "would", "could",
109
+ "should", "may", "might", "must", "can", "to", "of", "in", "for",
110
+ "on", "with", "at", "by", "from", "as", "into", "through", "during",
111
+ "and", "or", "but", "if", "then", "so", "than", "that", "this",
112
+ "it", "its", "i", "you", "he", "she", "we", "they", "what", "which",
113
+ "who", "whom", "whose", "where", "when", "why", "how",
114
+ ]);
115
+ const normalize = (t) => t.toLowerCase()
116
+ .replace(/[^\w\s]/g, " ")
117
+ .split(/\s+/)
118
+ .filter(w => w.length > 1 && !STOP_WORDS.has(w));
119
+ const setA = new Set(normalize(textA));
120
+ const setB = new Set(normalize(textB));
121
+ if (setA.size === 0 || setB.size === 0)
122
+ return 0;
123
+ // Jaccard = |A ∩ B| / |A ∪ B|
124
+ let intersection = 0;
125
+ for (const w of setA) {
126
+ if (setB.has(w))
127
+ intersection++;
128
+ }
129
+ const union = setA.size + setB.size - intersection;
130
+ return union === 0 ? 0 : intersection / union;
131
+ }
132
+ /**
133
+ * 回傳目前 embedding 模型建議的 threshold 組。
134
+ * 閾值會根據 embeddingDim 動態調整:
135
+ * - Qwen3 1024d(低維):updateThreshold=0.28, causalThreshold=0.32
136
+ * - Gemini 3072d(高維):updateThreshold=0.20, causalThreshold=0.23
137
+ */
138
+ getRecommendedThresholds() {
139
+ const isLowDim = this.embeddingDim <= 1280;
140
+ return {
141
+ updateThreshold: isLowDim ? 0.28 : 0.20,
142
+ causalThreshold: isLowDim ? 0.32 : 0.23,
143
+ overlapThreshold: 0.30,
144
+ embeddingDim: this.embeddingDim,
145
+ note: isLowDim
146
+ ? "Qwen3 1024d optimized (dense vectors, higher similarity)"
147
+ : "Gemini 3072d optimized (sparse vectors, lower similarity)",
148
+ };
149
+ }
150
+ }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * ConflictDetector — 記憶衝突偵測與主動抑制
3
+ *
4
+ * 模擬人類大腦的「主動抑制」(Retrieval-Induced Forgetting):
5
+ * 新記憶寫入後,掃描同 category 的高相似記憶,
6
+ * 用 LLM 判斷是否存在語意衝突,若有則標記舊記憶為 deprecated。
7
+ *
8
+ * 只對高衝突風險類別觸發:preference, constraint, identity, decision
9
+ */
10
+ import { MemoryStore } from '../store/store-v4.js';
11
+ import { StatusManager } from '../store/status-manager.js';
12
+ import { Embedder } from '../providers/embedder-v5.js';
13
+ import type { LlmClient } from '../ports.js';
14
+ export interface ConflictResult {
15
+ hasConflict: boolean;
16
+ conflictingIds: string[];
17
+ resolution: string;
18
+ }
19
+ export declare class ConflictDetector {
20
+ private store;
21
+ private embedder;
22
+ private llm?;
23
+ private statusManager;
24
+ private lastJudgeErrorMessage;
25
+ constructor(store: MemoryStore, embedder: Embedder, llm?: LlmClient | undefined, statusManager?: StatusManager);
26
+ /**
27
+ * 在新記憶寫入後呼叫,掃描是否存在衝突記憶
28
+ */
29
+ detectAndResolve(newMemoryId: string, newText: string, category: string): Promise<ConflictResult>;
30
+ /**
31
+ * LLM 衝突判定:兩段記憶是否在描述同一件事但結論矛盾
32
+ */
33
+ private judgeConflict;
34
+ /**
35
+ * 主動抑制:標記舊記憶為 deprecated
36
+ */
37
+ private suppressMemory;
38
+ private recordEffectiveness;
39
+ }
@@ -0,0 +1,193 @@
1
+ /**
2
+ * ConflictDetector — 記憶衝突偵測與主動抑制
3
+ *
4
+ * 模擬人類大腦的「主動抑制」(Retrieval-Induced Forgetting):
5
+ * 新記憶寫入後,掃描同 category 的高相似記憶,
6
+ * 用 LLM 判斷是否存在語意衝突,若有則標記舊記憶為 deprecated。
7
+ *
8
+ * 只對高衝突風險類別觸發:preference, constraint, identity, decision
9
+ */
10
+ import { hashQuery } from '../util/util-hash.js';
11
+ // 需要衝突偵測的高風險類別
12
+ const CONFLICT_CATEGORIES = new Set([
13
+ 'preference', 'constraint', 'identity', 'decision'
14
+ ]);
15
+ export class ConflictDetector {
16
+ store;
17
+ embedder;
18
+ llm;
19
+ statusManager;
20
+ lastJudgeErrorMessage = null;
21
+ constructor(store, embedder, llm, statusManager = (() => { throw new Error('[ConflictDetector] statusManager is required'); })()) {
22
+ this.store = store;
23
+ this.embedder = embedder;
24
+ this.llm = llm;
25
+ this.statusManager = statusManager;
26
+ }
27
+ /**
28
+ * 在新記憶寫入後呼叫,掃描是否存在衝突記憶
29
+ */
30
+ async detectAndResolve(newMemoryId, newText, category) {
31
+ const attemptHash = hashQuery(`newMemoryId-${newMemoryId}-${Date.now()}`);
32
+ // 只對高風險類別觸發
33
+ if (!CONFLICT_CATEGORIES.has(category)) {
34
+ this.recordEffectiveness({
35
+ event: 'conflict_detect_attempted',
36
+ entityId: newMemoryId,
37
+ queryHash: attemptHash,
38
+ outcome: 'category_skipped',
39
+ metadata: { category },
40
+ });
41
+ return { hasConflict: false, conflictingIds: [], resolution: 'skip' };
42
+ }
43
+ this.recordEffectiveness({
44
+ event: 'conflict_detect_attempted',
45
+ entityId: newMemoryId,
46
+ queryHash: attemptHash,
47
+ outcome: 'entered',
48
+ metadata: { category },
49
+ });
50
+ // Step 1: 找同 category 的相似記憶
51
+ const candidates = await this.store.hybridVectorSearch(newText, 10);
52
+ const sameCategoryCandidates = candidates.filter(r => {
53
+ if (r.entry.id === newMemoryId)
54
+ return false;
55
+ if (r.entry.id.startsWith('init_'))
56
+ return false;
57
+ if (r.entry.category !== category)
58
+ return false;
59
+ // 只看 active 狀態的記憶
60
+ try {
61
+ const meta = typeof r.entry.metadata === 'string'
62
+ ? JSON.parse(r.entry.metadata) : r.entry.metadata;
63
+ if (meta?.status === 'deprecated')
64
+ return false;
65
+ }
66
+ catch { }
67
+ return true;
68
+ });
69
+ this.recordEffectiveness({
70
+ event: 'conflict_candidates_found',
71
+ entityId: newMemoryId,
72
+ queryHash: attemptHash,
73
+ outcome: sameCategoryCandidates.length > 0 ? 'has_candidates' : 'no_candidates',
74
+ count: sameCategoryCandidates.length,
75
+ metadata: { category },
76
+ });
77
+ if (sameCategoryCandidates.length === 0) {
78
+ return { hasConflict: false, conflictingIds: [], resolution: 'no_candidates' };
79
+ }
80
+ // Step 2: 對前 3 筆用 LLM 做衝突判定
81
+ const conflictingIds = [];
82
+ const judgeCandidates = sameCategoryCandidates.slice(0, 3);
83
+ const judgeStartedAt = Date.now();
84
+ let judgeErrorMessage = null;
85
+ for (const candidate of judgeCandidates) {
86
+ this.lastJudgeErrorMessage = null;
87
+ const isConflict = await this.judgeConflict(newText, candidate.entry.text, category);
88
+ if (this.lastJudgeErrorMessage)
89
+ judgeErrorMessage = this.lastJudgeErrorMessage;
90
+ if (isConflict) {
91
+ conflictingIds.push(candidate.entry.id);
92
+ }
93
+ }
94
+ this.recordEffectiveness({
95
+ event: 'conflict_llm_judged',
96
+ entityId: newMemoryId,
97
+ queryHash: attemptHash,
98
+ outcome: judgeErrorMessage
99
+ ? 'llm_failed'
100
+ : conflictingIds.length > 0
101
+ ? 'conflict_found'
102
+ : 'no_conflict',
103
+ count: judgeErrorMessage ? 0 : conflictingIds.length,
104
+ durationMs: Date.now() - judgeStartedAt,
105
+ metadata: {
106
+ category,
107
+ candidateCount: sameCategoryCandidates.length,
108
+ ...(judgeErrorMessage ? { errorMessage: judgeErrorMessage } : {}),
109
+ },
110
+ });
111
+ if (conflictingIds.length === 0) {
112
+ return { hasConflict: false, conflictingIds: [], resolution: 'no_conflict' };
113
+ }
114
+ // Step 3: 主動抑制 — 標記舊記憶為 deprecated
115
+ for (const oldId of conflictingIds) {
116
+ const ok = await this.suppressMemory(oldId, newMemoryId);
117
+ this.recordEffectiveness({
118
+ event: 'conflict_resolution_fired',
119
+ entityId: oldId,
120
+ relatedId: newMemoryId,
121
+ queryHash: attemptHash,
122
+ outcome: ok ? 'ok' : 'failed',
123
+ metadata: { category, reason: 'conflict_detected' },
124
+ });
125
+ }
126
+ console.log(`[ConflictDetector] Conflict resolution complete: ${conflictingIds.length} previous memories superseded by ${newMemoryId.slice(0, 8)}`);
127
+ return {
128
+ hasConflict: true,
129
+ conflictingIds,
130
+ resolution: `deprecated ${conflictingIds.length} conflicting memories`,
131
+ };
132
+ }
133
+ /**
134
+ * LLM 衝突判定:兩段記憶是否在描述同一件事但結論矛盾
135
+ */
136
+ async judgeConflict(newText, existingText, category) {
137
+ const prompt = `你是記憶衝突裁判。判斷以下兩段記憶是否存在「事實衝突」。
138
+
139
+ 衝突 = 兩段記憶描述的是同一個主題/主體,但給出了不同的結論、偏好或指令。
140
+ 共存 = 兩段記憶雖然相似,但描述的是不同面向、不同時間點的事實補充,可以同時成立。
141
+
142
+ 記憶 A(舊):${existingText}
143
+ 記憶 B(新):${newText}
144
+ 類別:${category}
145
+
146
+ 只回答一個字:「衝突」或「共存」`;
147
+ if (!this.llm) {
148
+ console.warn('[ConflictDetector] LLM provider not configured; skipping conflict evaluation and defaulting to coexistence');
149
+ return false;
150
+ }
151
+ try {
152
+ const result = await this.llm.generate(prompt, { purpose: 'conflict-detection' });
153
+ const answer = result.trim();
154
+ return answer.includes('衝突');
155
+ }
156
+ catch (err) {
157
+ console.warn('[ConflictDetector] LLM evaluation failed; defaulting to coexistence:', err);
158
+ this.lastJudgeErrorMessage = err instanceof Error ? err.message : String(err);
159
+ return false; // 判定失敗時保守處理,不誤刪
160
+ }
161
+ }
162
+ /**
163
+ * 主動抑制:標記舊記憶為 deprecated
164
+ */
165
+ async suppressMemory(oldId, newId) {
166
+ const result = await this.statusManager.changeStatus({
167
+ memoryId: oldId,
168
+ toStatus: 'deprecated',
169
+ reason: 'conflict_detected',
170
+ source: 'conflict-detector',
171
+ supersededBy: newId,
172
+ });
173
+ if (result.ok) {
174
+ console.log(`[ConflictDetector] Active suppression: ${oldId.slice(0, 8)} superseded by ${newId.slice(0, 8)}`);
175
+ }
176
+ else {
177
+ console.warn(`[ConflictDetector] Active suppression failed: ${oldId.slice(0, 8)} error=${result.error}`);
178
+ }
179
+ return result.ok;
180
+ }
181
+ recordEffectiveness(event) {
182
+ void this.store.recordSubsystemEffectiveness({
183
+ subsystem: 'conflict',
184
+ relatedId: '',
185
+ count: 0,
186
+ score: 0,
187
+ durationMs: 0,
188
+ ...event,
189
+ }).catch((err) => {
190
+ console.warn('[conflict-eff]', err?.message ?? err);
191
+ });
192
+ }
193
+ }
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Global Working Memory (GWM) — 全局工作記憶引擎
3
+ *
4
+ * 功能:
5
+ * - 追蹤當前任務主題,計算 embedding drift
6
+ * - 當用戶對話偏離主題超過 5 輪,inject 工作記憶提醒
7
+ * - 支援 gwm_on/off/status/update 工具命令
8
+ */
9
+ import { Embedder } from '../providers/embedder-v5.js';
10
+ export interface GwmState {
11
+ active: boolean;
12
+ taskName: string;
13
+ taskDescription: string;
14
+ keywords: string[];
15
+ embedding: number[];
16
+ driftRoundCount: number;
17
+ createdAt: number;
18
+ }
19
+ export interface DriftResult {
20
+ isDrifting: boolean;
21
+ similarity: number;
22
+ }
23
+ export declare class GlobalWorkingMemory {
24
+ private readonly stateFile;
25
+ private lastDriftText;
26
+ private lastDriftVector;
27
+ private embedder;
28
+ private state;
29
+ private pendingInject;
30
+ private driftThreshold;
31
+ constructor(embedder: Embedder, stateFile: string, driftThreshold?: number);
32
+ private save;
33
+ load(): Promise<GwmState | null>;
34
+ gwmOn(taskName: string, taskDescription: string, keywords?: string[]): Promise<string>;
35
+ gwmOff(): Promise<string>;
36
+ gwmStatus(): string;
37
+ gwmUpdate(updates: {
38
+ taskName?: string;
39
+ taskDescription?: string;
40
+ keywords?: string[];
41
+ }): Promise<string>;
42
+ detectDrift(userMessages: {
43
+ role: string;
44
+ content: string | any[];
45
+ }[]): Promise<DriftResult>;
46
+ shouldInject(): boolean;
47
+ markInjected(): Promise<void>;
48
+ requestInject(): void;
49
+ getReminderMessage(): string;
50
+ isActive(): boolean;
51
+ getState(): GwmState | null;
52
+ }
53
+ export default GlobalWorkingMemory;
@@ -0,0 +1,211 @@
1
+ /**
2
+ * Global Working Memory (GWM) — 全局工作記憶引擎
3
+ *
4
+ * 功能:
5
+ * - 追蹤當前任務主題,計算 embedding drift
6
+ * - 當用戶對話偏離主題超過 5 輪,inject 工作記憶提醒
7
+ * - 支援 gwm_on/off/status/update 工具命令
8
+ */
9
+ import * as fs from 'node:fs';
10
+ import * as path from 'node:path';
11
+ // ─── Cosine Similarity ──────────────────────────────────────────────────────
12
+ function cosineSimilarity(a, b) {
13
+ if (a.length !== b.length || a.length === 0)
14
+ return 0;
15
+ let dotProduct = 0;
16
+ let normA = 0;
17
+ let normB = 0;
18
+ for (let i = 0; i < a.length; i++) {
19
+ dotProduct += a[i] * b[i];
20
+ normA += a[i] * a[i];
21
+ normB += b[i] * b[i];
22
+ }
23
+ const d = Math.sqrt(normA) * Math.sqrt(normB);
24
+ return d === 0 ? 0 : dotProduct / d;
25
+ }
26
+ // ─── Keyword Extractor ───────────────────────────────────────────────────────
27
+ function extractKeywords(text) {
28
+ const stopwords = new Set([
29
+ '的', '了', '是', '在', '我', '有', '和', '就', '不', '人', '都', '一', '一個', '上', '也', '很', '到', '說', '要', '去', '你',
30
+ '會', '著', '沒有', '看', '好', '自己', '這', '什麼', '還', '這個', '那個', '然後', '如果', '所以', '但是', '可以', '因為',
31
+ 'the', 'a', 'an', 'is', 'are', 'was', 'were', 'of', 'in', 'to', 'for', 'with', 'on', 'at', 'by', 'from', 'as', 'or', 'and',
32
+ 'it', 'that', 'this', 'i', 'you', 'we', 'they', 'he', 'she', 'my', 'your', 'our', 'their',
33
+ ]);
34
+ const raw = text
35
+ .split(/[\s,,、。.!!??::;;""''()\(\)\[\]]+/)
36
+ .filter((w) => w.length > 1 && !stopwords.has(w.toLowerCase()));
37
+ // 去重
38
+ return Array.from(new Set(raw)).slice(0, 5);
39
+ }
40
+ // ─── GlobalWorkingMemory ────────────────────────────────────────────────────
41
+ export class GlobalWorkingMemory {
42
+ stateFile;
43
+ lastDriftText = "";
44
+ lastDriftVector = null;
45
+ embedder;
46
+ state = null;
47
+ pendingInject = false; // injectOnce flag
48
+ driftThreshold;
49
+ constructor(embedder, stateFile, driftThreshold = 0.65) {
50
+ this.stateFile = stateFile;
51
+ this.embedder = embedder;
52
+ this.driftThreshold = driftThreshold;
53
+ }
54
+ // ── State I/O ──────────────────────────────────────────────────────────────
55
+ async save() {
56
+ if (!this.state)
57
+ return;
58
+ const dir = path.dirname(this.stateFile);
59
+ await fs.promises.mkdir(dir, { recursive: true });
60
+ await fs.promises.writeFile(this.stateFile, JSON.stringify(this.state, null, 2), 'utf-8');
61
+ }
62
+ async load() {
63
+ try {
64
+ const data = await fs.promises.readFile(this.stateFile, 'utf-8');
65
+ this.state = JSON.parse(data);
66
+ return this.state;
67
+ }
68
+ catch {
69
+ return null;
70
+ }
71
+ }
72
+ // ── GWM Tools ──────────────────────────────────────────────────────────────
73
+ async gwmOn(taskName, taskDescription, keywords) {
74
+ const kw = keywords && keywords.length > 0
75
+ ? keywords
76
+ : extractKeywords(taskDescription);
77
+ const embedding = await this.embedder.embed(taskDescription, 'store');
78
+ this.state = {
79
+ active: true,
80
+ taskName,
81
+ taskDescription,
82
+ keywords: kw,
83
+ embedding,
84
+ driftRoundCount: 0,
85
+ createdAt: Date.now(),
86
+ };
87
+ await this.save();
88
+ return `[✅ GWM 啟動] 任務:${taskName},關鍵字:${kw.join(', ')}`;
89
+ }
90
+ async gwmOff() {
91
+ this.state = null;
92
+ this.pendingInject = false;
93
+ try {
94
+ await fs.promises.unlink(this.stateFile);
95
+ }
96
+ catch { /* file may not exist */ }
97
+ return '[✅ GWM 已關閉]';
98
+ }
99
+ gwmStatus() {
100
+ if (!this.state || !this.state.active) {
101
+ return '[📋 GWM 狀態] 目前未啟動';
102
+ }
103
+ return [
104
+ '[📋 GWM 狀態]',
105
+ `任務:${this.state.taskName}`,
106
+ `描述:${this.state.taskDescription}`,
107
+ `關鍵字:${this.state.keywords.join(', ')}`,
108
+ `Drift 輪數:${this.state.driftRoundCount}/5`,
109
+ `啟動時間:${new Date(this.state.createdAt).toLocaleString('zh-TW')}`,
110
+ ].join('\n');
111
+ }
112
+ async gwmUpdate(updates) {
113
+ if (!this.state)
114
+ return '[❌ GWM 未啟動]';
115
+ if (updates.taskName)
116
+ this.state.taskName = updates.taskName;
117
+ if (updates.taskDescription) {
118
+ this.state.taskDescription = updates.taskDescription;
119
+ // re-embed if description changed
120
+ this.state.embedding = await this.embedder.embed(updates.taskDescription, 'store');
121
+ }
122
+ if (updates.keywords)
123
+ this.state.keywords = updates.keywords;
124
+ await this.save();
125
+ return '[✅ GWM 已更新]';
126
+ }
127
+ // ── Drift Detection ────────────────────────────────────────────────────────
128
+ async detectDrift(userMessages) {
129
+ if (!this.state || !this.state.active) {
130
+ return { isDrifting: false, similarity: 1 };
131
+ }
132
+ // 取出最近 1 筆 user message
133
+ let lastUserText = '';
134
+ for (let i = userMessages.length - 1; i >= 0; i--) {
135
+ const m = userMessages[i];
136
+ if (m.role === 'user') {
137
+ if (typeof m.content === 'string') {
138
+ lastUserText = m.content;
139
+ }
140
+ else if (Array.isArray(m.content)) {
141
+ lastUserText = m.content.map((c) => c.type === 'text' ? c.text : '').join(' ');
142
+ }
143
+ break;
144
+ }
145
+ }
146
+ if (!lastUserText)
147
+ return { isDrifting: false, similarity: 1 };
148
+ // 🛡️ 短句保護:< 10 字的回覆(如「好」「繼續」)不計入 drift
149
+ if (lastUserText.trim().length < 10) {
150
+ return { isDrifting: this.state.driftRoundCount >= 2, similarity: 1 };
151
+ }
152
+ // 🛠️ Embedding 快取:同一句話不重複呼叫 Ollama
153
+ let msgEmbedding;
154
+ if (lastUserText === this.lastDriftText && this.lastDriftVector) {
155
+ msgEmbedding = this.lastDriftVector;
156
+ }
157
+ else {
158
+ msgEmbedding = await this.embedder.embed(lastUserText, 'store');
159
+ this.lastDriftText = lastUserText;
160
+ this.lastDriftVector = msgEmbedding;
161
+ }
162
+ const similarity = cosineSimilarity(msgEmbedding, this.state.embedding);
163
+ if (similarity < this.driftThreshold) {
164
+ this.state.driftRoundCount += 1;
165
+ }
166
+ else {
167
+ this.state.driftRoundCount = Math.max(0, this.state.driftRoundCount - 1); // 慢慢恢復,不直接歸零
168
+ }
169
+ await this.save();
170
+ const isDrifting = this.state.driftRoundCount >= 2; // 2 輪就觸發(原本 5 輪太晚)
171
+ if (isDrifting) {
172
+ this.requestInject();
173
+ }
174
+ return { isDrifting, similarity };
175
+ }
176
+ // ── Check & consume inject (injectOnce 模式) ──────────────────────────────
177
+ shouldInject() {
178
+ return this.pendingInject && this.state?.active === true;
179
+ }
180
+ async markInjected() {
181
+ this.pendingInject = false;
182
+ if (this.state) {
183
+ // 不歸零,只減 1 — 持續施壓,如果下一輪還是漂移就會立刻再次提醒
184
+ this.state.driftRoundCount = Math.max(0, this.state.driftRoundCount - 1);
185
+ await this.save();
186
+ }
187
+ }
188
+ requestInject() {
189
+ this.pendingInject = true;
190
+ }
191
+ // ── Reminder Message ───────────────────────────────────────────────────────
192
+ getReminderMessage() {
193
+ if (!this.state)
194
+ return '';
195
+ return [
196
+ `⚠️【重要指令】你的當前任務是「${this.state.taskName}」。`,
197
+ `任務描述:${this.state.taskDescription}`,
198
+ `關鍵字:${this.state.keywords.join(', ')}`,
199
+ ``,
200
+ `❗ 如果你現在做的事情與上述任務無關,請立即停下並回到主線。`,
201
+ `如果你認為當前工作是完成任務的必要步驟,請在回覆中明確說明關聯性。`,
202
+ ].join('\n');
203
+ }
204
+ isActive() {
205
+ return this.state?.active === true;
206
+ }
207
+ getState() {
208
+ return this.state;
209
+ }
210
+ }
211
+ export default GlobalWorkingMemory;