@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.
- package/LICENSE +201 -0
- package/README.md +222 -0
- package/README.zh-TW.md +186 -0
- package/dist/api.d.ts +100 -0
- package/dist/api.js +156 -0
- package/dist/cognition/causal-attribution.d.ts +36 -0
- package/dist/cognition/causal-attribution.js +239 -0
- package/dist/cognition/causal-engine.d.ts +105 -0
- package/dist/cognition/causal-engine.js +150 -0
- package/dist/cognition/conflict-detector.d.ts +39 -0
- package/dist/cognition/conflict-detector.js +193 -0
- package/dist/cognition/global-working-memory.d.ts +53 -0
- package/dist/cognition/global-working-memory.js +211 -0
- package/dist/cognition/hooks-engine.d.ts +99 -0
- package/dist/cognition/hooks-engine.js +672 -0
- package/dist/cognition/ralph-core.d.ts +28 -0
- package/dist/cognition/ralph-core.js +104 -0
- package/dist/distill/concentrator-adapter.d.ts +167 -0
- package/dist/distill/concentrator-adapter.js +1876 -0
- package/dist/engine.d.ts +402 -0
- package/dist/engine.js +2254 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +3 -0
- package/dist/lifecycle/cleanup-engine.d.ts +80 -0
- package/dist/lifecycle/cleanup-engine.js +162 -0
- package/dist/lifecycle/cleanup-state.d.ts +34 -0
- package/dist/lifecycle/cleanup-state.js +50 -0
- package/dist/lifecycle/night-consolidation.d.ts +102 -0
- package/dist/lifecycle/night-consolidation.js +640 -0
- package/dist/lifecycle/night-recovery.d.ts +40 -0
- package/dist/lifecycle/night-recovery.js +107 -0
- package/dist/paths.d.ts +17 -0
- package/dist/paths.js +16 -0
- package/dist/pipeline/capsule-bridge.d.ts +35 -0
- package/dist/pipeline/capsule-bridge.js +86 -0
- package/dist/pipeline/compact-request.d.ts +30 -0
- package/dist/pipeline/compact-request.js +66 -0
- package/dist/pipeline/inbox-watcher.d.ts +112 -0
- package/dist/pipeline/inbox-watcher.js +1039 -0
- package/dist/ports.d.ts +29 -0
- package/dist/ports.js +1 -0
- package/dist/providers/embedder-v5.d.ts +46 -0
- package/dist/providers/embedder-v5.js +155 -0
- package/dist/providers/ollama-embedding.d.ts +25 -0
- package/dist/providers/ollama-embedding.js +166 -0
- package/dist/retrieval/abstractness-judge.d.ts +14 -0
- package/dist/retrieval/abstractness-judge.js +87 -0
- package/dist/retrieval/coverage-selection.d.ts +3 -0
- package/dist/retrieval/coverage-selection.js +53 -0
- package/dist/retrieval/cross-encoder-gate.d.ts +40 -0
- package/dist/retrieval/cross-encoder-gate.js +239 -0
- package/dist/retrieval/retriever-v4.d.ts +78 -0
- package/dist/retrieval/retriever-v4.js +1200 -0
- package/dist/skills/validate.d.ts +6 -0
- package/dist/skills/validate.js +69 -0
- package/dist/storage.d.ts +19 -0
- package/dist/storage.js +54 -0
- package/dist/store/aux-table-maintenance.d.ts +5 -0
- package/dist/store/aux-table-maintenance.js +64 -0
- package/dist/store/graph-enumerator.d.ts +21 -0
- package/dist/store/graph-enumerator.js +185 -0
- package/dist/store/graph-store.d.ts +107 -0
- package/dist/store/graph-store.js +478 -0
- package/dist/store/status-manager.d.ts +44 -0
- package/dist/store/status-manager.js +235 -0
- package/dist/store/store-v4.d.ts +339 -0
- package/dist/store/store-v4.js +2871 -0
- package/dist/transcript/keyword-search.d.ts +9 -0
- package/dist/transcript/keyword-search.js +67 -0
- package/dist/transcript/rehydrate-keyword.d.ts +6 -0
- package/dist/transcript/rehydrate-keyword.js +29 -0
- package/dist/transcript/rehydrate.d.ts +33 -0
- package/dist/transcript/rehydrate.js +285 -0
- package/dist/transcript/transcript-archive.d.ts +46 -0
- package/dist/transcript/transcript-archive.js +516 -0
- package/dist/types.d.ts +409 -0
- package/dist/types.js +104 -0
- package/dist/util/bounded-map.d.ts +1 -0
- package/dist/util/bounded-map.js +8 -0
- package/dist/util/rate-limiter.d.ts +12 -0
- package/dist/util/rate-limiter.js +54 -0
- package/dist/util/session-identity.d.ts +65 -0
- package/dist/util/session-identity.js +227 -0
- package/dist/util/util-hash.d.ts +1 -0
- package/dist/util/util-hash.js +4 -0
- package/package.json +59 -0
|
@@ -0,0 +1,672 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hooks Engine - 記憶鉤子系統 + 實體關係圖譜
|
|
3
|
+
* memory-lance-v4
|
|
4
|
+
* * 功能:
|
|
5
|
+
* 1. LLM 自動生成 hooks(最多 3 個,含權重)
|
|
6
|
+
* 2. LLM 自動生成 entities(實體關係圖譜)
|
|
7
|
+
* 3. 搜尋時觸發鉤子(層級 1,權重 > 0.5)
|
|
8
|
+
* 4. 智慧防呆:可接受字串、陣列或 JSON 格式的 Query
|
|
9
|
+
* 5. 冷卻時間(1 小時)
|
|
10
|
+
*/
|
|
11
|
+
import { recordAuxTableWrite } from "../store/aux-table-maintenance.js";
|
|
12
|
+
import * as lancedb from '@lancedb/lancedb';
|
|
13
|
+
import { Schema, Field, Int32, Float32, Utf8, Int64 } from 'apache-arrow';
|
|
14
|
+
const SEMANTIC_MATCH_THRESHOLD = 0.8;
|
|
15
|
+
const SEMANTIC_CONTEXT_THRESHOLD = 0.75;
|
|
16
|
+
const MAX_SEMANTIC_HOOK_EVALS = 50;
|
|
17
|
+
const HOOK_STOPWORDS = new Set([
|
|
18
|
+
'the', 'a', 'an', 'of', 'in', 'on', 'at', 'to', 'for', 'is', 'are', 'was', 'were',
|
|
19
|
+
'did', 'do', 'does', 'what', 'who', 'how', 'when', 'where', 'which', 'with', 'by',
|
|
20
|
+
'from', 'and', 'or', 'but', 'not', 'it', 'its', 'this', 'that',
|
|
21
|
+
'的', '了', '是', '在', '有', '和', '也', '都', '把', '被', '与', '以', '及', '或',
|
|
22
|
+
'但', '而', '就', '还', '很', '这', '那', '个', '们',
|
|
23
|
+
]);
|
|
24
|
+
function splitHookTokens(text) {
|
|
25
|
+
return text
|
|
26
|
+
.toLowerCase()
|
|
27
|
+
.split(/[\s,,、。;;!!??::「」【】()()\[\]"']+/)
|
|
28
|
+
.filter(token => {
|
|
29
|
+
if (HOOK_STOPWORDS.has(token))
|
|
30
|
+
return false;
|
|
31
|
+
return /[\u4e00-\u9fff]/.test(token) ? token.length >= 2 : token.length >= 3;
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
function extractProperNouns(text) {
|
|
35
|
+
return new Set(Array.from(text.matchAll(/\b[A-Z][\p{L}\p{N}_-]{2,}\b/gu), match => match[0].toLowerCase()));
|
|
36
|
+
}
|
|
37
|
+
function cosineSimilarity(a, b) {
|
|
38
|
+
if (a.length !== b.length || a.length === 0)
|
|
39
|
+
return 0;
|
|
40
|
+
let dot = 0;
|
|
41
|
+
let normA = 0;
|
|
42
|
+
let normB = 0;
|
|
43
|
+
for (let i = 0; i < a.length; i++) {
|
|
44
|
+
dot += a[i] * b[i];
|
|
45
|
+
normA += a[i] * a[i];
|
|
46
|
+
normB += b[i] * b[i];
|
|
47
|
+
}
|
|
48
|
+
if (normA === 0 || normB === 0)
|
|
49
|
+
return 0;
|
|
50
|
+
return dot / (Math.sqrt(normA) * Math.sqrt(normB));
|
|
51
|
+
}
|
|
52
|
+
export class HooksEngine {
|
|
53
|
+
store;
|
|
54
|
+
embedder;
|
|
55
|
+
config;
|
|
56
|
+
llm;
|
|
57
|
+
_db;
|
|
58
|
+
cooldown = new Map();
|
|
59
|
+
qualityTracker = new Map();
|
|
60
|
+
hookEmbeddingCache = new Map();
|
|
61
|
+
QUALITY_WINDOW = 10; // 只看最近10次觸發
|
|
62
|
+
MAX_QUALITY_TRACKER_SIZE = 1000; // 防止無上限記憶體增長
|
|
63
|
+
SUCCESS_RATE_THRESHOLD = 0.5; // 低於50% 降權
|
|
64
|
+
HIGH_SUCCESS_RATE = 0.7; // 高於70% 升權
|
|
65
|
+
DEGRADE_COOLDOWN = 24 * 60 * 60 * 1000; // 24小時冷卻
|
|
66
|
+
graphStore = null;
|
|
67
|
+
debounceTimer;
|
|
68
|
+
hardCapTimer;
|
|
69
|
+
isDirty = false;
|
|
70
|
+
constructor(store, embedder, config, llm, _db) {
|
|
71
|
+
this.store = store;
|
|
72
|
+
this.embedder = embedder;
|
|
73
|
+
this.config = config;
|
|
74
|
+
this.llm = llm;
|
|
75
|
+
this._db = _db;
|
|
76
|
+
this.config = {
|
|
77
|
+
enabled: config.enabled ?? true,
|
|
78
|
+
maxHooksPerMemory: config.maxHooksPerMemory ?? 3,
|
|
79
|
+
maxTriggerDepth: config.maxTriggerDepth ?? 1,
|
|
80
|
+
minTriggerScore: config.minTriggerScore ?? 0.5,
|
|
81
|
+
cooldownMs: config.cooldownMs ?? 3600000,
|
|
82
|
+
};
|
|
83
|
+
this.qualityTracker = new Map();
|
|
84
|
+
this._setupDebounce();
|
|
85
|
+
this._registerShutdown();
|
|
86
|
+
}
|
|
87
|
+
/** 注入 GraphStore(由 index.ts 在初始化時呼叫) */
|
|
88
|
+
setGraphStore(graphStore) {
|
|
89
|
+
this.graphStore = graphStore;
|
|
90
|
+
}
|
|
91
|
+
async generateHooks(text, category) {
|
|
92
|
+
if (!this.config.enabled)
|
|
93
|
+
return [];
|
|
94
|
+
if (!this.llm) {
|
|
95
|
+
console.warn('[HooksEngine] LLM provider not configured; skipping generateHooks');
|
|
96
|
+
return [];
|
|
97
|
+
}
|
|
98
|
+
const prompt = this.buildHookPrompt(text, category);
|
|
99
|
+
try {
|
|
100
|
+
const result = await this.llm.generate(prompt, { purpose: 'hooks' });
|
|
101
|
+
const hooks = this.parseHooksResponse(result, text);
|
|
102
|
+
return hooks.slice(0, this.config.maxHooksPerMemory);
|
|
103
|
+
}
|
|
104
|
+
catch (err) {
|
|
105
|
+
console.error('Hook generation failed:', err);
|
|
106
|
+
return [];
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
getCategoryHint(category) {
|
|
110
|
+
const hints = {
|
|
111
|
+
preference: '這是老闆的偏好。鉤子應像使用者下次會問起這個偏好的短語,並保留記憶中的人/物/場景名詞。',
|
|
112
|
+
constraint: '這是硬性約束/鐵律。鉤子應像使用者下次查這條規則時會輸入的短語,並保留記憶中的操作/物件名詞。',
|
|
113
|
+
identity: '這是身份/個人特徵。鉤子應像使用者下次問起這個人或身份時會說的短語,並保留記憶中的人名/地名/核心名詞。',
|
|
114
|
+
decision: '這是已做的決策。鉤子應像使用者下次問起這個決策或結論時會輸入的短語,並保留記憶中的專案/工具/物件名詞。',
|
|
115
|
+
fact: '這是客觀事實。鉤子應像使用者下次查這條事實時會輸入的短語,並保留記憶中的實體/數據/核心名詞。',
|
|
116
|
+
entity: '這是人物/組織資訊。鉤子應像使用者下次提到這些人或組織時會輸入的短語,並保留原文實體名稱。',
|
|
117
|
+
business: '這是商務/IP 資訊。鉤子應像使用者下次問起品牌、商務決策或 IP 時會輸入的短語,並保留記憶中的名稱。',
|
|
118
|
+
};
|
|
119
|
+
return hints[category] || '請生成使用者未來可能用來問起這條記憶的短語,並保留原文中的實體或核心名詞。';
|
|
120
|
+
}
|
|
121
|
+
buildHookPrompt(text, category) {
|
|
122
|
+
const max = this.config.maxHooksPerMemory;
|
|
123
|
+
const categoryHint = this.getCategoryHint(category);
|
|
124
|
+
return `你是聯想記憶引擎。為以下記憶生成最多 ${max} 個觸發鉤子。
|
|
125
|
+
鉤子 = 未來使用者可能用來提問的短語/別名。
|
|
126
|
+
每個鉤子必須包含記憶文本中至少一個 named entity 或核心名詞(人、地、物、專案、工具、專有名詞)。
|
|
127
|
+
只能重組記憶內容中出現的詞,不可補充外部知識、同義詞或猜測。
|
|
128
|
+
禁止方法論、產業、技術泛化;禁止抽象話題標籤;禁止幻覺。
|
|
129
|
+
|
|
130
|
+
【策略 — ${category}】${categoryHint}
|
|
131
|
+
|
|
132
|
+
【權重定義】
|
|
133
|
+
high: 使用者明確問到原文實體/核心名詞時必須觸發
|
|
134
|
+
medium: 使用者用原文中的別名或相鄰名詞問起時觸發
|
|
135
|
+
low: 原文中較邊緣但仍具名的問法
|
|
136
|
+
|
|
137
|
+
【好鉤子 vs 爛鉤子(學習這個模式)】
|
|
138
|
+
|
|
139
|
+
記憶:「Diana 的新工作在沖繩,她會研究珊瑚礁。」
|
|
140
|
+
✅ 好:[{"keyword":"Diana 的新工作","weight":"high"},{"keyword":"Diana 沖繩 珊瑚礁","weight":"high"},{"keyword":"珊瑚礁 研究 地點","weight":"medium"}]
|
|
141
|
+
❌ 爛:[{"keyword":"職業轉型成功案例","weight":"high"},{"keyword":"海洋生態調查","weight":"medium"},{"keyword":"伺服器選址","weight":"low"}]
|
|
142
|
+
|
|
143
|
+
記憶:「把 LanceDB hooks 轉成 JSON 字串塞進 metadata 欄位。」
|
|
144
|
+
✅ 好:[{"keyword":"LanceDB hooks metadata","weight":"high"},{"keyword":"JSON 字串 metadata 欄位","weight":"high"}]
|
|
145
|
+
❌ 爛:[{"keyword":"資料庫欄位限制破解","weight":"high"},{"keyword":"資料工程最佳實務","weight":"medium"}]
|
|
146
|
+
|
|
147
|
+
記憶:「決定用 pnpm 取代 npm,因為安裝速度快三倍。」
|
|
148
|
+
✅ 好:[{"keyword":"pnpm 取代 npm","weight":"high"},{"keyword":"pnpm 安裝速度","weight":"medium"}]
|
|
149
|
+
❌ 爛:[{"keyword":"套件管理器選型","weight":"high"},{"keyword":"安裝速度優化","weight":"medium"}]
|
|
150
|
+
|
|
151
|
+
記憶內容:${text}
|
|
152
|
+
|
|
153
|
+
直接輸出 JSON 陣列,禁止 Markdown 標記或說明文字:`;
|
|
154
|
+
}
|
|
155
|
+
async generateEntities(text) {
|
|
156
|
+
if (!this.llm) {
|
|
157
|
+
console.warn('[HooksEngine] LLM provider not configured; skipping generateEntities');
|
|
158
|
+
return [];
|
|
159
|
+
}
|
|
160
|
+
const prompt = `你是知識圖譜專家。請從以下記憶中萃取「實體關係三元組」。
|
|
161
|
+
|
|
162
|
+
記憶內容:${text}
|
|
163
|
+
|
|
164
|
+
【輸出格式要求】:
|
|
165
|
+
你只能回傳一個乾淨的 JSON 陣列,不准有任何 Markdown 標記。
|
|
166
|
+
只能輸出可直接從記憶內容推出的關係,不可補充外部知識或猜測。
|
|
167
|
+
|
|
168
|
+
範例輸入:
|
|
169
|
+
記憶:「老闆住在新北市三重區,他有一隻叫 Maru 的貓,很喜歡吃雞肉凍乾。」
|
|
170
|
+
|
|
171
|
+
範例輸出:
|
|
172
|
+
[
|
|
173
|
+
{"subject": "老闆", "relation": "住在", "object": "新北市三重區"},
|
|
174
|
+
{"subject": "老闆", "relation": "養", "object": "Maru"},
|
|
175
|
+
{"subject": "Maru", "relation": "喜歡吃", "object": "雞肉凍乾"}
|
|
176
|
+
]
|
|
177
|
+
|
|
178
|
+
請直接輸出 JSON 陣列:`;
|
|
179
|
+
try {
|
|
180
|
+
const result = await this.llm.generate(prompt, { purpose: 'entities' });
|
|
181
|
+
const entities = this.parseEntitiesResponse(result);
|
|
182
|
+
return entities.slice(0, 5);
|
|
183
|
+
}
|
|
184
|
+
catch (err) {
|
|
185
|
+
console.error('Entity generation failed:', err);
|
|
186
|
+
return [];
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
parseEntitiesResponse(response) {
|
|
190
|
+
try {
|
|
191
|
+
const jsonMatch = response.match(/\[[\s\S]*\]/);
|
|
192
|
+
if (!jsonMatch)
|
|
193
|
+
return [];
|
|
194
|
+
const parsed = JSON.parse(jsonMatch[0]);
|
|
195
|
+
if (!Array.isArray(parsed))
|
|
196
|
+
return [];
|
|
197
|
+
return parsed
|
|
198
|
+
.filter((e) => e.subject && e.relation && e.object)
|
|
199
|
+
.map((e) => ({
|
|
200
|
+
subject: String(e.subject).trim(),
|
|
201
|
+
relation: String(e.relation).trim(),
|
|
202
|
+
object: String(e.object).trim(),
|
|
203
|
+
}));
|
|
204
|
+
}
|
|
205
|
+
catch {
|
|
206
|
+
return [];
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
parseHooksResponse(response, originalText) {
|
|
210
|
+
try {
|
|
211
|
+
const jsonMatch = response.match(/\[[\s\S]*\]/);
|
|
212
|
+
if (!jsonMatch)
|
|
213
|
+
return this.fallbackHooks(originalText);
|
|
214
|
+
const parsed = JSON.parse(jsonMatch[0]);
|
|
215
|
+
if (!Array.isArray(parsed))
|
|
216
|
+
return this.fallbackHooks(originalText);
|
|
217
|
+
return parsed
|
|
218
|
+
.filter((h) => h.keyword && h.weight)
|
|
219
|
+
.map((h) => ({
|
|
220
|
+
keyword: h.keyword.toLowerCase().trim(),
|
|
221
|
+
weight: this.normalizeWeight(h.weight),
|
|
222
|
+
weightScore: this.weightToScore(h.weight),
|
|
223
|
+
}));
|
|
224
|
+
}
|
|
225
|
+
catch {
|
|
226
|
+
return this.fallbackHooks(originalText);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
fallbackHooks(text) {
|
|
230
|
+
const keywords = text
|
|
231
|
+
.split(/[,,、。;;!!?\s]+/)
|
|
232
|
+
.filter(w => w.length >= 2 && w.length <= 15)
|
|
233
|
+
.slice(0, 2);
|
|
234
|
+
return keywords.map(kw => ({
|
|
235
|
+
keyword: kw,
|
|
236
|
+
weight: "high",
|
|
237
|
+
weightScore: 1.0,
|
|
238
|
+
}));
|
|
239
|
+
}
|
|
240
|
+
normalizeWeight(w) {
|
|
241
|
+
const lower = w.toLowerCase();
|
|
242
|
+
if (lower === 'high')
|
|
243
|
+
return 'high';
|
|
244
|
+
if (lower === 'medium')
|
|
245
|
+
return 'medium';
|
|
246
|
+
return 'low';
|
|
247
|
+
}
|
|
248
|
+
weightToScore(w) {
|
|
249
|
+
const lower = w.toLowerCase();
|
|
250
|
+
if (lower === 'high')
|
|
251
|
+
return 1.0;
|
|
252
|
+
if (lower === 'medium')
|
|
253
|
+
return 0.7;
|
|
254
|
+
return 0.4;
|
|
255
|
+
}
|
|
256
|
+
isInCooldown(keyword) {
|
|
257
|
+
// Confirmatory flag: never suppress firing via cooldown, so each question fires hooks
|
|
258
|
+
// deterministically (independent of earlier questions' firing) -> no cross-question drift.
|
|
259
|
+
if (process.env.MR_OTTER_READONLY === '1')
|
|
260
|
+
return false;
|
|
261
|
+
const entry = this.cooldown.get(keyword);
|
|
262
|
+
if (!entry)
|
|
263
|
+
return false;
|
|
264
|
+
const now = Date.now();
|
|
265
|
+
if (now - entry.lastTriggered > this.config.cooldownMs) {
|
|
266
|
+
this.cooldown.delete(keyword);
|
|
267
|
+
return false;
|
|
268
|
+
}
|
|
269
|
+
return true;
|
|
270
|
+
}
|
|
271
|
+
getAdjustedWeight(keyword, baseNumericWeight) {
|
|
272
|
+
const record = this.qualityTracker.get(keyword);
|
|
273
|
+
return record?.adjustedNumericWeight ?? baseNumericWeight;
|
|
274
|
+
}
|
|
275
|
+
setCooldown(keyword) {
|
|
276
|
+
if (process.env.MR_OTTER_READONLY === '1')
|
|
277
|
+
return; // no cooldown accumulation under the flag
|
|
278
|
+
this.cooldown.set(keyword, {
|
|
279
|
+
keyword,
|
|
280
|
+
lastTriggered: Date.now(),
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
/**
|
|
284
|
+
* 🛡️ 智慧型 Query 解析器
|
|
285
|
+
* 處理 OpenClaw 傳入的複雜格式,精準提取純文字
|
|
286
|
+
*/
|
|
287
|
+
safelyExtractUserText(rawQuery) {
|
|
288
|
+
try {
|
|
289
|
+
// 情況 1: 已經是字串
|
|
290
|
+
if (typeof rawQuery === 'string') {
|
|
291
|
+
if (rawQuery.trim().startsWith('[')) {
|
|
292
|
+
const parsed = JSON.parse(rawQuery);
|
|
293
|
+
return this.safelyExtractUserText(parsed);
|
|
294
|
+
}
|
|
295
|
+
return rawQuery;
|
|
296
|
+
}
|
|
297
|
+
// 情況 2: OpenClaw Message 陣列 [{role: 'user', content: '...'}]
|
|
298
|
+
if (Array.isArray(rawQuery)) {
|
|
299
|
+
for (let i = rawQuery.length - 1; i >= 0; i--) {
|
|
300
|
+
const msg = rawQuery[i];
|
|
301
|
+
if (msg && msg.role === 'user' && typeof msg.content === 'string') {
|
|
302
|
+
return msg.content;
|
|
303
|
+
}
|
|
304
|
+
if (msg && typeof msg.text === 'string')
|
|
305
|
+
return msg.text;
|
|
306
|
+
}
|
|
307
|
+
return rawQuery.map(m => m.content || m.text || '').join(' ');
|
|
308
|
+
}
|
|
309
|
+
// 情況 3: 單一 Message 物件 {role: 'user', content: '...'}
|
|
310
|
+
if (typeof rawQuery === 'object' && rawQuery !== null) {
|
|
311
|
+
return rawQuery.content || rawQuery.text || JSON.stringify(rawQuery);
|
|
312
|
+
}
|
|
313
|
+
return String(rawQuery);
|
|
314
|
+
}
|
|
315
|
+
catch {
|
|
316
|
+
// 靜默降級:無法解析時回傳空字串,由呼叫端處理
|
|
317
|
+
return "";
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
async triggerHooks(rawQuery) {
|
|
321
|
+
if (!this.config.enabled) {
|
|
322
|
+
return { triggered: false, relatedMemories: [], naturalLanguage: "" };
|
|
323
|
+
}
|
|
324
|
+
const safeQueryText = this.safelyExtractUserText(rawQuery);
|
|
325
|
+
if (!safeQueryText || safeQueryText.trim() === "") {
|
|
326
|
+
return { triggered: false, relatedMemories: [], naturalLanguage: "" };
|
|
327
|
+
}
|
|
328
|
+
const queryKeywords = this.extractKeywords(safeQueryText);
|
|
329
|
+
if (queryKeywords.length === 0) {
|
|
330
|
+
return { triggered: false, relatedMemories: [], naturalLanguage: "" };
|
|
331
|
+
}
|
|
332
|
+
// ── 圖譜語意擴展(方向3核心)───────────────────────────────
|
|
333
|
+
// 如果 GraphStore 已注入,先用 ANN 搜尋圖譜三元組,擴展 keyword 範圍
|
|
334
|
+
let expandedKeywords = [...queryKeywords];
|
|
335
|
+
if (this.graphStore) {
|
|
336
|
+
try {
|
|
337
|
+
const { expandedKeywords: graphKeywords } = await this.graphStore.semanticExpand(safeQueryText, 5);
|
|
338
|
+
if (graphKeywords && graphKeywords.length > 0) {
|
|
339
|
+
console.log(`[HooksEngine] Graph expansion: ${queryKeywords} -> ${[...queryKeywords, ...graphKeywords]}`);
|
|
340
|
+
expandedKeywords = Array.from(new Set([...queryKeywords, ...graphKeywords]));
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
catch (err) {
|
|
344
|
+
// 圖譜失敗不阻斷 Hook trigger(降級方案)
|
|
345
|
+
console.warn('[HooksEngine] Semantic graph expansion failed; falling back to literal matching:', err);
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
const allMemories = await this.store.queryHookBearing();
|
|
349
|
+
const triggered = [];
|
|
350
|
+
const candidates = [];
|
|
351
|
+
for (const memory of allMemories) {
|
|
352
|
+
if (!memory.metadata)
|
|
353
|
+
continue;
|
|
354
|
+
let hooks = [];
|
|
355
|
+
let meta = {};
|
|
356
|
+
try {
|
|
357
|
+
meta = typeof memory.metadata === 'string' ? JSON.parse(memory.metadata) : memory.metadata;
|
|
358
|
+
hooks = meta?.hooks || [];
|
|
359
|
+
}
|
|
360
|
+
catch {
|
|
361
|
+
hooks = [];
|
|
362
|
+
}
|
|
363
|
+
// 對齊 store-v4 搜尋層 (PR-MS-2):只在 metaStatus 明確 deprecated/trashed 時擋下,missing/undefined 視為 active。
|
|
364
|
+
const topStatus = memory.status || 'active';
|
|
365
|
+
const metaStatus = meta?.status;
|
|
366
|
+
if (topStatus !== 'active' || metaStatus === 'deprecated' || metaStatus === 'trashed')
|
|
367
|
+
continue;
|
|
368
|
+
if (!hooks || hooks.length === 0)
|
|
369
|
+
continue;
|
|
370
|
+
for (const hook of hooks) {
|
|
371
|
+
if (typeof hook.keyword !== 'string')
|
|
372
|
+
continue;
|
|
373
|
+
if (this.isInCooldown(hook.keyword))
|
|
374
|
+
continue;
|
|
375
|
+
candidates.push({ memory, hook });
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
const queryTokens = new Set(expandedKeywords.flatMap(splitHookTokens));
|
|
379
|
+
const queryProperNouns = extractProperNouns(safeQueryText);
|
|
380
|
+
const candidateMatches = candidates.map(candidate => {
|
|
381
|
+
const hookTokens = new Set(splitHookTokens(candidate.hook.keyword));
|
|
382
|
+
const literalMatch = Array.from(hookTokens).some(token => queryTokens.has(token));
|
|
383
|
+
const hookProperNouns = extractProperNouns(candidate.hook.keyword);
|
|
384
|
+
const properNounMatch = Array.from(hookProperNouns).some(noun => queryProperNouns.has(noun));
|
|
385
|
+
const adjustedWeight = this.getAdjustedWeight(candidate.hook.keyword, (candidate.hook.weightScore ?? 0.5));
|
|
386
|
+
return { ...candidate, literalMatch, contextMatch: literalMatch || properNounMatch, adjustedWeight };
|
|
387
|
+
});
|
|
388
|
+
const semanticCandidateKeys = new Set(candidateMatches
|
|
389
|
+
.filter(candidate => !candidate.literalMatch)
|
|
390
|
+
.sort((a, b) => b.adjustedWeight - a.adjustedWeight)
|
|
391
|
+
.slice(0, MAX_SEMANTIC_HOOK_EVALS)
|
|
392
|
+
.map(candidate => `${candidate.memory.id}:${candidate.hook.keyword}`));
|
|
393
|
+
let queryEmbedding = null;
|
|
394
|
+
if (semanticCandidateKeys.size > 0) {
|
|
395
|
+
try {
|
|
396
|
+
const semanticQueryText = Array.from(new Set([safeQueryText, ...expandedKeywords]))
|
|
397
|
+
.filter(text => typeof text === 'string' && text.trim().length > 0)
|
|
398
|
+
.join(' ');
|
|
399
|
+
queryEmbedding = await this.embedder.embed(semanticQueryText, 'query');
|
|
400
|
+
}
|
|
401
|
+
catch (err) {
|
|
402
|
+
console.warn('[HooksEngine] Query embedding failed; falling back to literal matching:', err);
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
for (const candidate of candidateMatches) {
|
|
406
|
+
const { memory, hook, literalMatch, contextMatch, adjustedWeight } = candidate;
|
|
407
|
+
let semanticMatch = false;
|
|
408
|
+
if (!literalMatch && queryEmbedding && semanticCandidateKeys.has(`${memory.id}:${hook.keyword}`)) {
|
|
409
|
+
try {
|
|
410
|
+
let hookEmbedding = this.hookEmbeddingCache.get(hook.keyword);
|
|
411
|
+
if (!hookEmbedding) {
|
|
412
|
+
hookEmbedding = await this.embedder.embed(hook.keyword, 'store');
|
|
413
|
+
this.hookEmbeddingCache.set(hook.keyword, hookEmbedding);
|
|
414
|
+
}
|
|
415
|
+
const similarity = cosineSimilarity(queryEmbedding, hookEmbedding);
|
|
416
|
+
semanticMatch = similarity >= SEMANTIC_MATCH_THRESHOLD
|
|
417
|
+
|| (similarity >= SEMANTIC_CONTEXT_THRESHOLD && contextMatch);
|
|
418
|
+
}
|
|
419
|
+
catch (err) {
|
|
420
|
+
console.warn(`[HooksEngine] Hook embedding failed for "${hook.keyword}"; using literal matching:`, err);
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
const minScore = (this.config.minTriggerScore ?? 0.5);
|
|
424
|
+
if ((literalMatch || semanticMatch) && adjustedWeight >= minScore) {
|
|
425
|
+
triggered.push({
|
|
426
|
+
memory,
|
|
427
|
+
score: adjustedWeight,
|
|
428
|
+
viaHook: hook.keyword,
|
|
429
|
+
});
|
|
430
|
+
this.setCooldown(hook.keyword);
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
if (triggered.length === 0) {
|
|
434
|
+
return { triggered: false, relatedMemories: [], naturalLanguage: "" };
|
|
435
|
+
}
|
|
436
|
+
triggered.sort((a, b) => b.score - a.score);
|
|
437
|
+
const topResults = triggered.slice(0, 3);
|
|
438
|
+
const naturalLanguage = this.generateNaturalLanguage(topResults);
|
|
439
|
+
// 更新品質追蹤 — 只記錄觸發次數,成功/失敗由 reportHookOutcome() 回報
|
|
440
|
+
// Benchmark confirmatory A/B (MR_OTTER_READONLY=1): keep hook FIRING (injected memories above
|
|
441
|
+
// are identical per arm — same query, frozen weights) but freeze the adaptive quality/cooldown
|
|
442
|
+
// drift, which would otherwise accumulate cross-question and gate future firing differently per
|
|
443
|
+
// arm. Iterate an empty set under the flag. Production untouched (flag default off).
|
|
444
|
+
for (const hook of (process.env.MR_OTTER_READONLY === '1' ? [] : topResults)) {
|
|
445
|
+
const keyword = hook.viaHook;
|
|
446
|
+
if (!keyword)
|
|
447
|
+
continue;
|
|
448
|
+
const record = this.qualityTracker.get(keyword) ?? {
|
|
449
|
+
keyword,
|
|
450
|
+
triggerCount: 0,
|
|
451
|
+
successCount: 0,
|
|
452
|
+
failCount: 0,
|
|
453
|
+
lastTriggeredAt: null,
|
|
454
|
+
currentNumericWeight: typeof hook.score === 'number' ? hook.score : 0.7,
|
|
455
|
+
adjustedNumericWeight: typeof hook.score === 'number' ? hook.score : 0.7,
|
|
456
|
+
};
|
|
457
|
+
record.triggerCount++;
|
|
458
|
+
record.lastTriggeredAt = Date.now();
|
|
459
|
+
// 🩺 不再寫死 successCount++,由 CRAG 評估後透過 reportHookOutcome() 回報
|
|
460
|
+
// LRU 淘汰:超過上限時移除最舊的 entry(Map 保留插入順序)
|
|
461
|
+
if (!this.qualityTracker.has(keyword) && this.qualityTracker.size >= this.MAX_QUALITY_TRACKER_SIZE) {
|
|
462
|
+
const oldest = this.qualityTracker.keys().next().value;
|
|
463
|
+
if (oldest)
|
|
464
|
+
this.qualityTracker.delete(oldest);
|
|
465
|
+
}
|
|
466
|
+
this.qualityTracker.set(keyword, record);
|
|
467
|
+
}
|
|
468
|
+
return {
|
|
469
|
+
triggered: true,
|
|
470
|
+
relatedMemories: topResults,
|
|
471
|
+
naturalLanguage,
|
|
472
|
+
};
|
|
473
|
+
}
|
|
474
|
+
/**
|
|
475
|
+
* CRAG 評估後回報 hook 觸發結果(由 Retriever 呼叫)
|
|
476
|
+
* @param keyword - 觸發的 hook keyword
|
|
477
|
+
* @param wasRetained - CRAG 評估後該記憶是否被保留
|
|
478
|
+
*/
|
|
479
|
+
async reportHookOutcome(keyword, wasRetained) {
|
|
480
|
+
// See triggerHooks: freeze adaptive quality/weight drift under the confirmatory flag.
|
|
481
|
+
if (process.env.MR_OTTER_READONLY === '1')
|
|
482
|
+
return;
|
|
483
|
+
const record = this.qualityTracker.get(keyword);
|
|
484
|
+
if (!record)
|
|
485
|
+
return;
|
|
486
|
+
if (wasRetained) {
|
|
487
|
+
record.successCount++;
|
|
488
|
+
}
|
|
489
|
+
else {
|
|
490
|
+
record.failCount++;
|
|
491
|
+
}
|
|
492
|
+
// 重新計算調整後權重
|
|
493
|
+
const total = Math.min(record.triggerCount, this.QUALITY_WINDOW);
|
|
494
|
+
const recentRate = total > 0 ? record.successCount / total : 0.5;
|
|
495
|
+
if (recentRate >= this.HIGH_SUCCESS_RATE) {
|
|
496
|
+
record.adjustedNumericWeight = Math.min(1.0, record.currentNumericWeight * 1.1);
|
|
497
|
+
console.log(`[HooksEngine] Weight increased: ${keyword} -> ${record.adjustedNumericWeight.toFixed(2)} (rate=${recentRate.toFixed(2)})`);
|
|
498
|
+
}
|
|
499
|
+
else if (recentRate < this.SUCCESS_RATE_THRESHOLD) {
|
|
500
|
+
record.adjustedNumericWeight = Math.max(0.1, record.currentNumericWeight * 0.8);
|
|
501
|
+
console.log(`[HooksEngine] Weight decreased: ${keyword} -> ${record.adjustedNumericWeight.toFixed(2)} (rate=${recentRate.toFixed(2)})`);
|
|
502
|
+
}
|
|
503
|
+
this.qualityTracker.set(keyword, record);
|
|
504
|
+
// Hook 淘汰:觸發 5 次以上且成功率為 0 → 移除
|
|
505
|
+
if (record.triggerCount >= 5 && record.successCount === 0) {
|
|
506
|
+
console.log(`[HooksEngine] Removing ineffective hook: ${keyword}`);
|
|
507
|
+
this.qualityTracker.delete(keyword);
|
|
508
|
+
await this._deleteDBRecord(keyword);
|
|
509
|
+
}
|
|
510
|
+
await this.schedulePersist();
|
|
511
|
+
}
|
|
512
|
+
async _deleteDBRecord(keyword) {
|
|
513
|
+
if (!this._db)
|
|
514
|
+
return;
|
|
515
|
+
try {
|
|
516
|
+
const table = await this._db.openTable('hook_stats');
|
|
517
|
+
if (typeof keyword !== 'string' || keyword.length === 0)
|
|
518
|
+
return;
|
|
519
|
+
const escaped = keyword.replace(/'/g, "''");
|
|
520
|
+
await table.delete(`keyword = '${escaped}'`);
|
|
521
|
+
}
|
|
522
|
+
catch (err) {
|
|
523
|
+
console.warn(`[HooksEngine] _deleteDBRecord failed for "${keyword}":`, err.message);
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
extractKeywords(queryText) {
|
|
527
|
+
return Array.from(new Set(splitHookTokens(queryText)));
|
|
528
|
+
}
|
|
529
|
+
generateNaturalLanguage(results) {
|
|
530
|
+
if (results.length === 0)
|
|
531
|
+
return "";
|
|
532
|
+
const phrases = results.map(r => {
|
|
533
|
+
const memoryText = r.memory.text.length > 30
|
|
534
|
+
? r.memory.text.slice(0, 30) + "..."
|
|
535
|
+
: r.memory.text;
|
|
536
|
+
return memoryText;
|
|
537
|
+
});
|
|
538
|
+
const templates = [
|
|
539
|
+
`對了,突然想到:${phrases[0]}`,
|
|
540
|
+
`順便記得:${phrases[0]}`,
|
|
541
|
+
`想起來了:${phrases[0]}`,
|
|
542
|
+
phrases.length > 1
|
|
543
|
+
? `順便想起相關的:${phrases.slice(0, 2).join('、')}...`
|
|
544
|
+
: `記得你之前提過:${phrases[0]}`,
|
|
545
|
+
];
|
|
546
|
+
return templates[Math.floor(Math.random() * templates.length)];
|
|
547
|
+
}
|
|
548
|
+
// ── HookStats 持久化 ──────────────────────────────────────────────────────
|
|
549
|
+
_setupDebounce() {
|
|
550
|
+
// no-op for now
|
|
551
|
+
}
|
|
552
|
+
_registerShutdown() {
|
|
553
|
+
this.store.onShutdown(async () => {
|
|
554
|
+
if (this.isDirty) {
|
|
555
|
+
await this.flush();
|
|
556
|
+
}
|
|
557
|
+
});
|
|
558
|
+
}
|
|
559
|
+
schedulePersist() {
|
|
560
|
+
this.isDirty = true;
|
|
561
|
+
// debounce 30s — reset on every call
|
|
562
|
+
if (this.debounceTimer)
|
|
563
|
+
clearTimeout(this.debounceTimer);
|
|
564
|
+
this.debounceTimer = setTimeout(() => this.flush(), 30_000);
|
|
565
|
+
// hard cap 5min — only set if not already running
|
|
566
|
+
if (!this.hardCapTimer) {
|
|
567
|
+
this.hardCapTimer = setTimeout(() => this.flush(), 300_000);
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
async flush() {
|
|
571
|
+
if (!this.isDirty)
|
|
572
|
+
return;
|
|
573
|
+
if (this.debounceTimer) {
|
|
574
|
+
clearTimeout(this.debounceTimer);
|
|
575
|
+
this.debounceTimer = undefined;
|
|
576
|
+
}
|
|
577
|
+
if (this.hardCapTimer) {
|
|
578
|
+
clearTimeout(this.hardCapTimer);
|
|
579
|
+
this.hardCapTimer = undefined;
|
|
580
|
+
}
|
|
581
|
+
this.isDirty = false;
|
|
582
|
+
await this.persistStats();
|
|
583
|
+
}
|
|
584
|
+
async loadStats() {
|
|
585
|
+
if (!this._db)
|
|
586
|
+
return;
|
|
587
|
+
const tableName = 'hook_stats';
|
|
588
|
+
let table;
|
|
589
|
+
try {
|
|
590
|
+
table = await this._db.openTable(tableName);
|
|
591
|
+
}
|
|
592
|
+
catch {
|
|
593
|
+
// table not exist = first run, nothing to load
|
|
594
|
+
console.log('[HooksEngine] hook_stats table not found, skipping loadStats');
|
|
595
|
+
return;
|
|
596
|
+
}
|
|
597
|
+
const results = await table.query().limit(10000).toArray();
|
|
598
|
+
for (const row of results) {
|
|
599
|
+
const keyword = row['keyword'];
|
|
600
|
+
if (!keyword)
|
|
601
|
+
continue; // 防禦 DB 污染
|
|
602
|
+
const record = {
|
|
603
|
+
keyword,
|
|
604
|
+
triggerCount: row['triggerCount'],
|
|
605
|
+
successCount: row['successCount'],
|
|
606
|
+
failCount: row['failCount'],
|
|
607
|
+
lastTriggeredAt: row['lastTriggeredAt'] || null,
|
|
608
|
+
currentNumericWeight: row['currentNumericWeight'],
|
|
609
|
+
adjustedNumericWeight: row['adjustedNumericWeight'],
|
|
610
|
+
};
|
|
611
|
+
this.qualityTracker.set(keyword, record);
|
|
612
|
+
}
|
|
613
|
+
console.log(`[HooksEngine] loadStats: loaded ${results.length} records`);
|
|
614
|
+
}
|
|
615
|
+
async persistStats() {
|
|
616
|
+
if (!this._db)
|
|
617
|
+
return;
|
|
618
|
+
const tableName = 'hook_stats';
|
|
619
|
+
const db = this._db;
|
|
620
|
+
let table;
|
|
621
|
+
try {
|
|
622
|
+
table = await db.openTable(tableName);
|
|
623
|
+
}
|
|
624
|
+
catch {
|
|
625
|
+
// table not exist = create with schema using apache-arrow types
|
|
626
|
+
const schema = new Schema([
|
|
627
|
+
new Field('keyword', new Utf8(), false),
|
|
628
|
+
new Field('triggerCount', new Int32()),
|
|
629
|
+
new Field('successCount', new Int32()),
|
|
630
|
+
new Field('failCount', new Int32()),
|
|
631
|
+
new Field('lastTriggeredAt', new Int64()),
|
|
632
|
+
new Field('currentNumericWeight', new Float32()),
|
|
633
|
+
new Field('adjustedNumericWeight', new Float32()),
|
|
634
|
+
new Field('updatedAt', new Int64()),
|
|
635
|
+
]);
|
|
636
|
+
await db.createEmptyTable(tableName, schema);
|
|
637
|
+
table = await db.openTable(tableName);
|
|
638
|
+
}
|
|
639
|
+
const now = Date.now();
|
|
640
|
+
const rows = Array.from(this.qualityTracker.values()).map((record) => ({
|
|
641
|
+
keyword: record.keyword,
|
|
642
|
+
triggerCount: record.triggerCount,
|
|
643
|
+
successCount: record.successCount,
|
|
644
|
+
failCount: record.failCount,
|
|
645
|
+
lastTriggeredAt: record.lastTriggeredAt ?? 0,
|
|
646
|
+
currentNumericWeight: record.currentNumericWeight,
|
|
647
|
+
adjustedNumericWeight: record.adjustedNumericWeight,
|
|
648
|
+
updatedAt: now,
|
|
649
|
+
}));
|
|
650
|
+
// Use makeArrowTable to build an Arrow table, then pass to mergeInsert.execute
|
|
651
|
+
const arrowTable = lancedb.makeArrowTable
|
|
652
|
+
? lancedb.makeArrowTable(rows, {
|
|
653
|
+
schema: new Schema([
|
|
654
|
+
new Field('keyword', new Utf8(), false),
|
|
655
|
+
new Field('triggerCount', new Int32()),
|
|
656
|
+
new Field('successCount', new Int32()),
|
|
657
|
+
new Field('failCount', new Int32()),
|
|
658
|
+
new Field('lastTriggeredAt', new Int64()),
|
|
659
|
+
new Field('currentNumericWeight', new Float32()),
|
|
660
|
+
new Field('adjustedNumericWeight', new Float32()),
|
|
661
|
+
new Field('updatedAt', new Int64()),
|
|
662
|
+
]),
|
|
663
|
+
})
|
|
664
|
+
: rows;
|
|
665
|
+
await table.mergeInsert(['keyword'])
|
|
666
|
+
.whenMatchedUpdateAll()
|
|
667
|
+
.whenNotMatchedInsertAll()
|
|
668
|
+
.execute(arrowTable);
|
|
669
|
+
await recordAuxTableWrite(table, "ram:hook_stats");
|
|
670
|
+
console.log(`[HooksEngine] persistStats: wrote ${rows.length} records`);
|
|
671
|
+
}
|
|
672
|
+
}
|