@chainlesschain/personal-data-hub 0.1.0 → 0.2.1
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/__tests__/adapters/ai-chat-cookie-capture-spec.test.js +211 -0
- package/__tests__/adapters/ai-chat-health-checker.test.js +262 -0
- package/__tests__/adapters/ai-chat-history.test.js +396 -0
- package/__tests__/adapters/ai-chat-http-client.test.js +242 -0
- package/__tests__/adapters/ai-chat-vendors.test.js +874 -0
- package/__tests__/adapters/alipay-bill-adapter.test.js +538 -0
- package/__tests__/adapters/email-adapter.test.js +138 -1
- package/__tests__/adapters/email-classifier.test.js +347 -0
- package/__tests__/adapters/email-pdf-extractor.test.js +529 -0
- package/__tests__/adapters/email-retry-progress.test.js +294 -0
- package/__tests__/adapters/email-templates.test.js +699 -0
- package/__tests__/adapters/social-toutiao-kuaishou-scaffold.test.js +269 -0
- package/__tests__/adapters/system-data-adapter.test.js +440 -0
- package/__tests__/adapters/system-data-android-ingest.test.js +144 -0
- package/__tests__/adapters/system-data-android.test.js +387 -0
- package/__tests__/adapters/system-data-disclosure.test.js +153 -0
- package/__tests__/adapters/wechat-bootstrap.test.js +240 -0
- package/__tests__/adapters/wechat-env-probe.test.js +162 -0
- package/__tests__/adapters/wechat-frida-agent.test.js +191 -0
- package/__tests__/adapters/wechat-frida-integration.test.js +149 -0
- package/__tests__/adapters/wechat-frida-key-provider.test.js +188 -0
- package/__tests__/adapters/wechat-md5-key-provider.test.js +101 -0
- package/__tests__/analysis-skills.test.js +556 -0
- package/__tests__/analysis.test.js +329 -1
- package/__tests__/e2e/ai-chat-cross-source-journey.test.js +213 -0
- package/__tests__/e2e/full-user-journey.test.js +188 -0
- package/__tests__/entity-resolver-ingest-hook.test.js +177 -0
- package/__tests__/entity-resolver-stages.test.js +411 -0
- package/__tests__/entity-resolver-vault.test.js +246 -0
- package/__tests__/entity-resolver.test.js +526 -0
- package/__tests__/fixtures/entity-resolver-200-mock.json +96 -0
- package/__tests__/integration/ai-chat-history-registry.test.js +228 -0
- package/__tests__/integration/aichat-wizard-end-to-end.test.js +282 -0
- package/__tests__/integration/cross-adapter-pipelines.test.js +396 -0
- package/__tests__/integration/wechat-bootstrap-end-to-end.test.js +390 -0
- package/__tests__/longtail-adapters.test.js +217 -0
- package/__tests__/mobile-extractor.test.js +288 -0
- package/__tests__/registry.test.js +4 -2
- package/__tests__/shopping-adapters.test.js +296 -0
- package/__tests__/sidecar-contacts-cross-validate.test.js +163 -0
- package/__tests__/sidecar-supervisor.test.js +120 -0
- package/__tests__/social-adapters.test.js +206 -0
- package/__tests__/travel-adapters.test.js +325 -0
- package/__tests__/vault.test.js +3 -3
- package/__tests__/wechat-adapter.test.js +476 -0
- package/__tests__/whatsapp-adapter.test.js +135 -0
- package/lib/adapter-spec.js +12 -0
- package/lib/adapters/_python-sidecar-base.js +207 -0
- package/lib/adapters/ai-chat-history/ai-chat-adapter.js +374 -0
- package/lib/adapters/ai-chat-history/cookie-auth.js +109 -0
- package/lib/adapters/ai-chat-history/cookie-capture-spec.js +331 -0
- package/lib/adapters/ai-chat-history/health-checker.js +210 -0
- package/lib/adapters/ai-chat-history/http-client.js +211 -0
- package/lib/adapters/ai-chat-history/index.js +28 -0
- package/lib/adapters/ai-chat-history/schema-map.js +258 -0
- package/lib/adapters/ai-chat-history/vendor-spec.js +86 -0
- package/lib/adapters/ai-chat-history/vendors/coze.js +179 -0
- package/lib/adapters/ai-chat-history/vendors/deepseek.js +199 -0
- package/lib/adapters/ai-chat-history/vendors/doubao.js +255 -0
- package/lib/adapters/ai-chat-history/vendors/dreamina.js +174 -0
- package/lib/adapters/ai-chat-history/vendors/hunyuan.js +176 -0
- package/lib/adapters/ai-chat-history/vendors/kimi.js +182 -0
- package/lib/adapters/ai-chat-history/vendors/qianfan.js +160 -0
- package/lib/adapters/ai-chat-history/vendors/tongyi.js +193 -0
- package/lib/adapters/ai-chat-history/vendors/zhipu.js +202 -0
- package/lib/adapters/ai-chat-history/wizard-controller.js +473 -0
- package/lib/adapters/alipay-bill/alipay-bill-adapter.js +311 -0
- package/lib/adapters/alipay-bill/counterparty.js +129 -0
- package/lib/adapters/alipay-bill/csv-parser.js +217 -0
- package/lib/adapters/alipay-bill/index.js +41 -0
- package/lib/adapters/alipay-bill/zip-decryptor.js +111 -0
- package/lib/adapters/email-imap/classifier.js +495 -0
- package/lib/adapters/email-imap/email-adapter.js +419 -8
- package/lib/adapters/email-imap/index.js +42 -0
- package/lib/adapters/email-imap/pdf-extractor.js +192 -0
- package/lib/adapters/email-imap/templates/bill.js +232 -0
- package/lib/adapters/email-imap/templates/government.js +120 -0
- package/lib/adapters/email-imap/templates/index.js +78 -0
- package/lib/adapters/email-imap/templates/order.js +186 -0
- package/lib/adapters/email-imap/templates/other.js +114 -0
- package/lib/adapters/email-imap/templates/register.js +113 -0
- package/lib/adapters/email-imap/templates/travel.js +157 -0
- package/lib/adapters/email-imap/templates/utils.js +275 -0
- package/lib/adapters/email-imap/transactions.js +234 -0
- package/lib/adapters/messaging-qq/index.js +158 -0
- package/lib/adapters/messaging-telegram/index.js +142 -0
- package/lib/adapters/messaging-whatsapp/index.js +189 -0
- package/lib/adapters/shopping-base/index.js +208 -0
- package/lib/adapters/shopping-jd/index.js +150 -0
- package/lib/adapters/shopping-meituan/index.js +154 -0
- package/lib/adapters/shopping-taobao/index.js +176 -0
- package/lib/adapters/social-bilibili/index.js +171 -0
- package/lib/adapters/social-douyin/index.js +116 -0
- package/lib/adapters/social-kuaishou/index.js +237 -0
- package/lib/adapters/social-toutiao/index.js +236 -0
- package/lib/adapters/social-weibo/index.js +164 -0
- package/lib/adapters/social-xiaohongshu/index.js +96 -0
- package/lib/adapters/system-data/disclosure.js +166 -0
- package/lib/adapters/system-data/index.js +34 -0
- package/lib/adapters/system-data/system-data-adapter.js +344 -0
- package/lib/adapters/system-data-android/adapter.js +348 -0
- package/lib/adapters/system-data-android/index.js +76 -0
- package/lib/adapters/travel-12306/index.js +151 -0
- package/lib/adapters/travel-amap/index.js +164 -0
- package/lib/adapters/travel-baidu-map/index.js +162 -0
- package/lib/adapters/travel-base/index.js +240 -0
- package/lib/adapters/travel-ctrip/index.js +151 -0
- package/lib/adapters/wechat/bootstrap.js +146 -0
- package/lib/adapters/wechat/content-parser.js +326 -0
- package/lib/adapters/wechat/db-reader.js +209 -0
- package/lib/adapters/wechat/env-probe.js +218 -0
- package/lib/adapters/wechat/frida-agent/loader.js +67 -0
- package/lib/adapters/wechat/frida-agent/wechat-key-hook.js +126 -0
- package/lib/adapters/wechat/index.js +37 -0
- package/lib/adapters/wechat/key-extractor.js +158 -0
- package/lib/adapters/wechat/key-providers/frida-key-provider.js +244 -0
- package/lib/adapters/wechat/key-providers/index.js +22 -0
- package/lib/adapters/wechat/key-providers/key-provider-base.js +44 -0
- package/lib/adapters/wechat/key-providers/md5-key-provider.js +81 -0
- package/lib/adapters/wechat/normalize.js +220 -0
- package/lib/adapters/wechat/wechat-adapter.js +205 -0
- package/lib/analysis-skills/base.js +113 -0
- package/lib/analysis-skills/footprint.js +167 -0
- package/lib/analysis-skills/index.js +58 -0
- package/lib/analysis-skills/interests.js +161 -0
- package/lib/analysis-skills/relations.js +226 -0
- package/lib/analysis-skills/spending.js +219 -0
- package/lib/analysis-skills/timeline.js +167 -0
- package/lib/analysis.js +191 -2
- package/lib/entity-resolver/embedding-stage.js +198 -0
- package/lib/entity-resolver/entity-resolver.js +384 -0
- package/lib/entity-resolver/index.js +42 -0
- package/lib/entity-resolver/llm-stage.js +191 -0
- package/lib/entity-resolver/rule-stage.js +208 -0
- package/lib/entity-resolver/worker.js +149 -0
- package/lib/index.js +131 -0
- package/lib/migrations.js +73 -0
- package/lib/mobile-extractor/android.js +193 -0
- package/lib/mobile-extractor/index.js +9 -0
- package/lib/mobile-extractor/ios.js +223 -0
- package/lib/prompt-builder.js +11 -1
- package/lib/query-parser.js +7 -1
- package/lib/registry.js +42 -0
- package/lib/sidecar/index.js +15 -0
- package/lib/sidecar/supervisor.js +359 -0
- package/lib/vault.js +343 -0
- package/package.json +36 -3
- package/scripts/_make-fixture-all.js +126 -0
- package/scripts/_make-fixture-contacts.js +84 -0
- package/scripts/evaluate-entity-resolver.js +213 -0
- package/scripts/smoke-phase-5-5.js +196 -0
- package/scripts/smoke-phase-5-7.js +181 -0
- package/scripts/smoke-system-data-contacts.js +309 -0
- package/scripts/smoke-system-data.js +312 -0
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Phase 11 — analysis.timeline skill.
|
|
3
|
+
*
|
|
4
|
+
* Cross-source narrative timeline. Given a time window + optional topic
|
|
5
|
+
* keyword, weaves Events from all adapters into a chronological story
|
|
6
|
+
* with adapter-aware glyphs (so "邮件" / "支付" / "出行" are
|
|
7
|
+
* visually distinguishable in the UI).
|
|
8
|
+
*
|
|
9
|
+
* LLM (optional) produces a 1-paragraph synthesis: "你这周买过 X 也去过
|
|
10
|
+
* Y, 给妈妈转账过 Z" rather than just a list.
|
|
11
|
+
*
|
|
12
|
+
* Inputs:
|
|
13
|
+
* - timeWindow: required (default last 7 days)
|
|
14
|
+
* - topicFilter: optional substring match against title / counterparty
|
|
15
|
+
* - personId: optional — scope to events involving this person
|
|
16
|
+
* (merge-group expanded)
|
|
17
|
+
* - limit: default 100 events
|
|
18
|
+
*
|
|
19
|
+
* Output:
|
|
20
|
+
* {
|
|
21
|
+
* entries: [{ id, occurredAt, title, kind, amount?, adapter, snippet }],
|
|
22
|
+
* summary: { totalEvents, byAdapter, byDay, period },
|
|
23
|
+
* citations,
|
|
24
|
+
* llm_narrative: "..." | null,
|
|
25
|
+
* }
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
"use strict";
|
|
29
|
+
|
|
30
|
+
const { AnalysisSkill } = require("./base");
|
|
31
|
+
|
|
32
|
+
class TimelineSkill extends AnalysisSkill {
|
|
33
|
+
constructor(opts) {
|
|
34
|
+
super({ ...opts, name: "analysis.timeline" });
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async run(options = {}) {
|
|
38
|
+
const window = this.resolveTimeWindow({
|
|
39
|
+
sinceDays: options.sinceDays ?? (options.since ? null : 7), // default 7d
|
|
40
|
+
...options,
|
|
41
|
+
});
|
|
42
|
+
const limit = Number.isFinite(options.limit) && options.limit > 0
|
|
43
|
+
? Math.min(options.limit, 1000)
|
|
44
|
+
: 100;
|
|
45
|
+
|
|
46
|
+
let events = this._fetchEvents(window, limit);
|
|
47
|
+
events = this._applyFilters(events, options);
|
|
48
|
+
const entries = events.map((e) => this._toEntry(e));
|
|
49
|
+
const summary = this._summarize(entries, window);
|
|
50
|
+
|
|
51
|
+
let narrative = null;
|
|
52
|
+
if (options.narrative !== false && this.llm && entries.length > 0) {
|
|
53
|
+
narrative = await this._llmNarrative(entries, summary, options);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return {
|
|
57
|
+
skill: "analysis.timeline",
|
|
58
|
+
entries,
|
|
59
|
+
summary,
|
|
60
|
+
citations: entries.slice(0, 50).map((e) => e.id),
|
|
61
|
+
llm_narrative: narrative,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
_fetchEvents({ since, until }, limit) {
|
|
66
|
+
const q = { limit };
|
|
67
|
+
if (since != null) q.since = since;
|
|
68
|
+
if (until != null) q.until = until;
|
|
69
|
+
const events = this.vault.queryEvents(q) || [];
|
|
70
|
+
// queryEvents orders DESC; reverse for narrative (oldest first)
|
|
71
|
+
return events.slice().sort((a, b) => (a.occurredAt || 0) - (b.occurredAt || 0));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
_applyFilters(events, options) {
|
|
75
|
+
let out = events;
|
|
76
|
+
if (typeof options.topicFilter === "string" && options.topicFilter.length > 0) {
|
|
77
|
+
const needle = options.topicFilter.toLowerCase();
|
|
78
|
+
out = out.filter((e) => {
|
|
79
|
+
const title = (e.content && e.content.title) || "";
|
|
80
|
+
const counterparty = (e.extra && e.extra.counterparty) || "";
|
|
81
|
+
const text = (e.content && e.content.text) || "";
|
|
82
|
+
return title.toLowerCase().includes(needle)
|
|
83
|
+
|| counterparty.toLowerCase().includes(needle)
|
|
84
|
+
|| text.toLowerCase().includes(needle);
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
if (typeof options.personId === "string" && options.personId.length > 0) {
|
|
88
|
+
const memberSet = new Set(this.expandToMergeGroup(options.personId));
|
|
89
|
+
out = out.filter((e) => {
|
|
90
|
+
if (memberSet.has(e.actor)) return true;
|
|
91
|
+
if (Array.isArray(e.participants) && e.participants.some((p) => memberSet.has(p))) return true;
|
|
92
|
+
return false;
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
return out;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
_toEntry(event) {
|
|
99
|
+
const adapter = (event.source && event.source.adapter) || "unknown";
|
|
100
|
+
return {
|
|
101
|
+
id: event.id,
|
|
102
|
+
occurredAt: event.occurredAt,
|
|
103
|
+
title: (event.content && event.content.title) || "(无标题)",
|
|
104
|
+
kind: event.subtype || "event",
|
|
105
|
+
amount: event.content?.amount || null,
|
|
106
|
+
adapter,
|
|
107
|
+
snippet: this._buildSnippet(event),
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
_buildSnippet(event) {
|
|
112
|
+
const parts = [];
|
|
113
|
+
const text = (event.content && event.content.text) || "";
|
|
114
|
+
if (text) parts.push(text.slice(0, 100));
|
|
115
|
+
if (event.extra) {
|
|
116
|
+
if (event.extra.counterparty) parts.push(`@${event.extra.counterparty}`);
|
|
117
|
+
if (event.extra.from && event.extra.to) parts.push(`${event.extra.from} → ${event.extra.to}`);
|
|
118
|
+
}
|
|
119
|
+
return parts.join(" · ").slice(0, 200);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
_summarize(entries, window) {
|
|
123
|
+
const byAdapter = {};
|
|
124
|
+
const byDay = {};
|
|
125
|
+
for (const e of entries) {
|
|
126
|
+
byAdapter[e.adapter] = (byAdapter[e.adapter] || 0) + 1;
|
|
127
|
+
const d = new Date(e.occurredAt);
|
|
128
|
+
if (Number.isFinite(d.getTime())) {
|
|
129
|
+
const day = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
|
130
|
+
byDay[day] = (byDay[day] || 0) + 1;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return {
|
|
134
|
+
totalEvents: entries.length,
|
|
135
|
+
byAdapter,
|
|
136
|
+
byDay,
|
|
137
|
+
period: {
|
|
138
|
+
since: window.since || null,
|
|
139
|
+
until: window.until || null,
|
|
140
|
+
},
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async _llmNarrative(entries, summary, options) {
|
|
145
|
+
if (entries.length === 0) return null;
|
|
146
|
+
// Cap to 30 entries for prompt size
|
|
147
|
+
const sampled = entries.slice(-30);
|
|
148
|
+
const lines = sampled.map((e) => {
|
|
149
|
+
const d = new Date(e.occurredAt).toISOString().slice(0, 10);
|
|
150
|
+
const amt = e.amount ? ` ¥${e.amount.value}(${e.amount.direction})` : "";
|
|
151
|
+
return `- ${d} [${e.adapter}/${e.kind}] ${e.title}${amt}`;
|
|
152
|
+
}).join("\n");
|
|
153
|
+
|
|
154
|
+
const userMsg = `用户的事件时间线(共 ${summary.totalEvents} 条, 显示最近 ${sampled.length}):
|
|
155
|
+
|
|
156
|
+
${lines}
|
|
157
|
+
|
|
158
|
+
请用 3-5 句话讲清楚这段时间发生了什么、出现的人物 / 地点、有没有明显的主题或事件。中文回答,平实叙述,不评价。`;
|
|
159
|
+
|
|
160
|
+
return await this.callLlmCommentary([
|
|
161
|
+
{ role: "system", content: "你是一个克制的时间线叙述助手。基于事实串联事件,不引申、不评价。" },
|
|
162
|
+
{ role: "user", content: userMsg },
|
|
163
|
+
], { acceptNonLocal: options.acceptNonLocal });
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
module.exports = { TimelineSkill };
|
package/lib/analysis.js
CHANGED
|
@@ -136,14 +136,26 @@ class AnalysisEngine {
|
|
|
136
136
|
intent: parsed.intent,
|
|
137
137
|
timeWindow: parsed.timeWindow,
|
|
138
138
|
maxFacts: this.maxFacts,
|
|
139
|
+
vaultTotals: this._gatherVaultTotals(),
|
|
139
140
|
});
|
|
140
141
|
|
|
141
|
-
// Call LLM.
|
|
142
|
+
// Call LLM. **skipCache: true** is critical: PDH answers depend on
|
|
143
|
+
// current vault state (new contacts / events / items ingested between
|
|
144
|
+
// asks). The desktop LLMManager has a 7-day ResponseCache keyed on
|
|
145
|
+
// sha256(messages); if a stale entry from before the latest sync hits,
|
|
146
|
+
// the user sees yesterday's hallucinated count after fixing _gatherFacts
|
|
147
|
+
// and never finds out (real-device verify 2026-05-21 Xiaomi 24115RA8EC:
|
|
148
|
+
// "几个联系人" served from cache, returned the pre-Path-C-fix wrong
|
|
149
|
+
// answer of "32" even though vault now had real contact data). PDH's
|
|
150
|
+
// freshness-over-latency tradeoff makes the cache strictly counter-
|
|
151
|
+
// productive at this layer. The cache for OTHER LLM uses (chat /
|
|
152
|
+
// skill orchestration / autonomous-agent) is unaffected.
|
|
142
153
|
let llmResp;
|
|
143
154
|
try {
|
|
144
155
|
llmResp = await this.llm.chat(messages, {
|
|
145
156
|
temperature: 0.2,
|
|
146
157
|
purpose: "personal-data-hub.analysis.ask",
|
|
158
|
+
skipCache: true,
|
|
147
159
|
});
|
|
148
160
|
} catch (err) {
|
|
149
161
|
const e = toError(err, "llm.chat");
|
|
@@ -195,6 +207,109 @@ class AnalysisEngine {
|
|
|
195
207
|
};
|
|
196
208
|
}
|
|
197
209
|
|
|
210
|
+
/**
|
|
211
|
+
* Retrieve the prompt context for a question WITHOUT calling the LLM.
|
|
212
|
+
*
|
|
213
|
+
* Mirrors the front half of `ask()` (parseQuery → gatherFacts → ragRetriever
|
|
214
|
+
* → buildPrompt) and returns the assembled messages + facts. The caller is
|
|
215
|
+
* responsible for invoking its own LLM with the returned messages and then
|
|
216
|
+
* (optionally) running citation validation on the answer.
|
|
217
|
+
*
|
|
218
|
+
* Why: lets a mobile / browser front-end host the LLM call locally (e.g.
|
|
219
|
+
* Android-side Volcengine Doubao adapter via API key) while keeping the
|
|
220
|
+
* vault + retrieval on the desktop. The privacy gate does NOT apply here
|
|
221
|
+
* because no LLM is contacted — the caller's gate is the gate.
|
|
222
|
+
*
|
|
223
|
+
* @param {string} question
|
|
224
|
+
* @param {object} [options]
|
|
225
|
+
* @param {number} [options.now]
|
|
226
|
+
* @param {boolean} [options.skipAudit=false]
|
|
227
|
+
* @returns {Promise<RetrieveContextResult>}
|
|
228
|
+
*
|
|
229
|
+
* @typedef {object} RetrieveContextResult
|
|
230
|
+
* @property {string} question
|
|
231
|
+
* @property {object} parsed
|
|
232
|
+
* @property {Array<object>} facts
|
|
233
|
+
* @property {string[]} factIds
|
|
234
|
+
* @property {number} factCount
|
|
235
|
+
* @property {boolean} truncated
|
|
236
|
+
* @property {string[]} ragContextIds
|
|
237
|
+
* @property {Array<{role: string, content: string}>} messages prompt-builder output, LLM-ready
|
|
238
|
+
* @property {string} systemPrompt
|
|
239
|
+
* @property {number} retrievedAt Date.now() at start
|
|
240
|
+
* @property {number} durationMs
|
|
241
|
+
*/
|
|
242
|
+
async retrieveContext(question, options = {}) {
|
|
243
|
+
if (typeof question !== "string" || question.length === 0) {
|
|
244
|
+
throw new Error("AnalysisEngine.retrieveContext: question must be a non-empty string");
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const startedAt = Date.now();
|
|
248
|
+
const parsed = parseQuery(question, { now: options.now });
|
|
249
|
+
const facts = this._gatherFacts(parsed);
|
|
250
|
+
|
|
251
|
+
const ragContextIds = [];
|
|
252
|
+
if (this.ragRetriever) {
|
|
253
|
+
try {
|
|
254
|
+
const docs = await this.ragRetriever(question, parsed);
|
|
255
|
+
if (Array.isArray(docs)) {
|
|
256
|
+
for (const doc of docs) {
|
|
257
|
+
if (!doc || !doc.id) continue;
|
|
258
|
+
const e = this.vault.getEvent(doc.id);
|
|
259
|
+
if (e && !facts.find((f) => f.id === e.id)) {
|
|
260
|
+
facts.push(e);
|
|
261
|
+
ragContextIds.push(doc.id);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
} catch (err) {
|
|
266
|
+
const e = toError(err, "ragRetriever");
|
|
267
|
+
try {
|
|
268
|
+
this.vault.audit("analysis.rag_failed", question, { error: e.message });
|
|
269
|
+
} catch (_e) { /* audit failures are non-fatal */ }
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const { messages, factIds, factCount, truncated } = buildPrompt({
|
|
274
|
+
question,
|
|
275
|
+
facts,
|
|
276
|
+
systemPrompt: this.systemPrompt,
|
|
277
|
+
intent: parsed.intent,
|
|
278
|
+
timeWindow: parsed.timeWindow,
|
|
279
|
+
maxFacts: this.maxFacts,
|
|
280
|
+
vaultTotals: this._gatherVaultTotals(),
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
const durationMs = Date.now() - startedAt;
|
|
284
|
+
|
|
285
|
+
if (!options.skipAudit) {
|
|
286
|
+
try {
|
|
287
|
+
this.vault.audit("analysis.retrieve_context", question, {
|
|
288
|
+
factCount,
|
|
289
|
+
truncated,
|
|
290
|
+
ragContextIds: ragContextIds.length,
|
|
291
|
+
durationMs,
|
|
292
|
+
});
|
|
293
|
+
} catch (_e) { /* audit failures are non-fatal */ }
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
return {
|
|
297
|
+
question,
|
|
298
|
+
parsed,
|
|
299
|
+
facts,
|
|
300
|
+
// buildPrompt returns factIds as a Set; flatten to Array so the result
|
|
301
|
+
// round-trips through IPC / WS JSON serialization without becoming `{}`.
|
|
302
|
+
factIds: Array.from(factIds),
|
|
303
|
+
factCount,
|
|
304
|
+
truncated,
|
|
305
|
+
ragContextIds,
|
|
306
|
+
messages,
|
|
307
|
+
systemPrompt: this.systemPrompt,
|
|
308
|
+
retrievedAt: startedAt,
|
|
309
|
+
durationMs,
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
|
|
198
313
|
// ─── Internals ─────────────────────────────────────────────────────
|
|
199
314
|
|
|
200
315
|
_gatherFacts(parsed) {
|
|
@@ -215,7 +330,81 @@ class AnalysisEngine {
|
|
|
215
330
|
if (Number.isFinite(parsed.timeWindow.since)) q.since = parsed.timeWindow.since;
|
|
216
331
|
if (Number.isFinite(parsed.timeWindow.until)) q.until = parsed.timeWindow.until;
|
|
217
332
|
}
|
|
218
|
-
|
|
333
|
+
const events = this.vault.queryEvents(q);
|
|
334
|
+
|
|
335
|
+
// Path C follow-up — events alone miss whole categories of facts:
|
|
336
|
+
// - contacts (system-data-android) land in `persons`, not `events`
|
|
337
|
+
// - installed apps land in `items`, not `events`
|
|
338
|
+
// - places (visited locations) live in `places`
|
|
339
|
+
// Without these the LLM gets 0 facts for "我有几个联系人" style questions
|
|
340
|
+
// and hallucinates a count. We pull a bounded slice of each entity type
|
|
341
|
+
// and append; prompt-builder.summarizeFact already handles `person` /
|
|
342
|
+
// `place` / fallback `item` shapes, so this is additive with no schema
|
|
343
|
+
// change to the LLM-facing prompt.
|
|
344
|
+
//
|
|
345
|
+
// Sizing: keep events as the majority (existing behavior is unchanged for
|
|
346
|
+
// event-heavy queries like 消费 / 通话); split the remaining 1/2 budget
|
|
347
|
+
// between persons + items. Time window + adapter filters don't apply to
|
|
348
|
+
// these tables (persons aren't time-stamped events) — they're current-
|
|
349
|
+
// state snapshots that should always be visible. Adapter filter is also
|
|
350
|
+
// skipped because users asking "我有几个联系人" don't say "from
|
|
351
|
+
// system-data-android".
|
|
352
|
+
const remaining = Math.max(0, this.maxFacts - events.length);
|
|
353
|
+
const sideBudget = Math.floor(remaining / 2);
|
|
354
|
+
const personBudget = sideBudget > 0 ? sideBudget : 0;
|
|
355
|
+
const itemBudget = remaining - personBudget;
|
|
356
|
+
|
|
357
|
+
let persons = [];
|
|
358
|
+
if (personBudget > 0) {
|
|
359
|
+
try {
|
|
360
|
+
persons = this.vault.queryPersons({ limit: personBudget });
|
|
361
|
+
} catch (_e) {
|
|
362
|
+
// Older vaults / forks without queryPersons — fall back gracefully.
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
let items = [];
|
|
366
|
+
if (itemBudget > 0) {
|
|
367
|
+
try {
|
|
368
|
+
items = this.vault.queryItems({ limit: itemBudget });
|
|
369
|
+
} catch (_e) {
|
|
370
|
+
/* same fallback */
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
return [...events, ...persons, ...items];
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* Pull authoritative entity counts from the vault. These go into the
|
|
379
|
+
* prompt's TOTALS block so the LLM can answer "how many X" questions
|
|
380
|
+
* correctly even when the FACTS sample is truncated (maxFacts cap).
|
|
381
|
+
*
|
|
382
|
+
* 2026-05-21 bug: LLM said "32 contacts" when vault actually had ~500.
|
|
383
|
+
* Root cause was a mix of (a) FACTS not including persons (fixed in
|
|
384
|
+
* _gatherFacts), and (b) LLM still counting FACTS array length even after
|
|
385
|
+
* persons were included — capped at the 80-fact ceiling. TOTALS bypasses
|
|
386
|
+
* both: it gives the LLM the real number to quote directly.
|
|
387
|
+
*
|
|
388
|
+
* Wrapped in try because legacy vault forks / mock vaults in tests may
|
|
389
|
+
* not expose `stats()`; falling back to undefined makes prompt-builder
|
|
390
|
+
* skip the block entirely.
|
|
391
|
+
*/
|
|
392
|
+
_gatherVaultTotals() {
|
|
393
|
+
if (typeof this.vault.stats !== "function") return undefined;
|
|
394
|
+
try {
|
|
395
|
+
const s = this.vault.stats();
|
|
396
|
+
// Trim to the fields useful for question answering — schemaVersion /
|
|
397
|
+
// mergeGroups / audit log size are noise here.
|
|
398
|
+
return {
|
|
399
|
+
events: s.events,
|
|
400
|
+
persons: s.persons,
|
|
401
|
+
places: s.places,
|
|
402
|
+
items: s.items,
|
|
403
|
+
topics: s.topics,
|
|
404
|
+
};
|
|
405
|
+
} catch (_e) {
|
|
406
|
+
return undefined;
|
|
407
|
+
}
|
|
219
408
|
}
|
|
220
409
|
}
|
|
221
410
|
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Phase 8.3 — embedding stage.
|
|
3
|
+
*
|
|
4
|
+
* Pluggable function that takes two Person rows + returns
|
|
5
|
+
* `{ sim, profileA, profileB }` where `sim` is a cosine similarity in
|
|
6
|
+
* [0, 1]. Caller (EntityResolver.drain) uses thresholds to decide
|
|
7
|
+
* auto-same / auto-different / "send to LLM stage".
|
|
8
|
+
*
|
|
9
|
+
* Profile encoding (per design doc §4.2):
|
|
10
|
+
* "{type}: {primary_name} | aliases: {a1, a2} | identifiers: {phone,
|
|
11
|
+
* email} | recent: {top-3 event titles}"
|
|
12
|
+
*
|
|
13
|
+
* Embedding backend: Ollama HTTP API by default (compatible with
|
|
14
|
+
* `nomic-embed-text` / `bge-m3` / `bge-large-zh`). Caller can inject any
|
|
15
|
+
* `embedFn: async (text) => Float32Array | number[]` via opts.
|
|
16
|
+
*
|
|
17
|
+
* Privacy: same gate as AnalysisEngine — local Ollama default, accept-
|
|
18
|
+
* NonLocal flag required for hosted. Phase 8.4 LLM stage carries the
|
|
19
|
+
* same invariant; this module is dumb to that and trusts caller.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
"use strict";
|
|
23
|
+
|
|
24
|
+
const DEFAULT_OLLAMA_URL = "http://localhost:11434";
|
|
25
|
+
const DEFAULT_MODEL = "nomic-embed-text";
|
|
26
|
+
|
|
27
|
+
class EmbeddingStage {
|
|
28
|
+
constructor(opts = {}) {
|
|
29
|
+
if (!opts || typeof opts !== "object") {
|
|
30
|
+
throw new Error("EmbeddingStage: opts required");
|
|
31
|
+
}
|
|
32
|
+
this._embedFn = typeof opts.embedFn === "function" ? opts.embedFn : null;
|
|
33
|
+
this._ollamaUrl = typeof opts.ollamaUrl === "string" && opts.ollamaUrl.length > 0
|
|
34
|
+
? opts.ollamaUrl
|
|
35
|
+
: DEFAULT_OLLAMA_URL;
|
|
36
|
+
this._model = typeof opts.model === "string" && opts.model.length > 0
|
|
37
|
+
? opts.model
|
|
38
|
+
: DEFAULT_MODEL;
|
|
39
|
+
// Caller-supplied vault lets us pull recent events for richer profiles
|
|
40
|
+
// (per design doc §4.2 — "recent: top-3 event titles"). Optional.
|
|
41
|
+
this._vault = opts.vault || null;
|
|
42
|
+
// LRU-ish in-memory cache: personId → embedding. Cheap perf win for
|
|
43
|
+
// re-using the same person across many pair comparisons in one drain.
|
|
44
|
+
this._cache = new Map();
|
|
45
|
+
this._cacheMaxSize = Number.isFinite(opts.cacheMaxSize) ? opts.cacheMaxSize : 1000;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The function EntityResolver.drain expects.
|
|
50
|
+
* Signature: `async (a, b) => { sim, profileA, profileB }`
|
|
51
|
+
*/
|
|
52
|
+
async compare(a, b) {
|
|
53
|
+
const [vecA, profileA] = await this._embedPerson(a);
|
|
54
|
+
const [vecB, profileB] = await this._embedPerson(b);
|
|
55
|
+
const sim = cosineSimilarity(vecA, vecB);
|
|
56
|
+
return { sim, profileA, profileB };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Returns a stage function bound to this instance, suitable for passing
|
|
61
|
+
* as `opts.embeddingStage` to EntityResolver.
|
|
62
|
+
*/
|
|
63
|
+
asStageFn() {
|
|
64
|
+
return (a, b) => this.compare(a, b);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async _embedPerson(person) {
|
|
68
|
+
if (!person || !person.id) throw new Error("EmbeddingStage: person required");
|
|
69
|
+
const profile = this.buildProfile(person);
|
|
70
|
+
if (this._cache.has(person.id)) {
|
|
71
|
+
return [this._cache.get(person.id), profile];
|
|
72
|
+
}
|
|
73
|
+
const vec = await this._embed(profile);
|
|
74
|
+
if (!Array.isArray(vec) && !(vec instanceof Float32Array)) {
|
|
75
|
+
throw new Error("EmbeddingStage: embedFn must return Array<number> or Float32Array");
|
|
76
|
+
}
|
|
77
|
+
if (this._cache.size >= this._cacheMaxSize) {
|
|
78
|
+
// FIFO eviction
|
|
79
|
+
const first = this._cache.keys().next().value;
|
|
80
|
+
if (first !== undefined) this._cache.delete(first);
|
|
81
|
+
}
|
|
82
|
+
this._cache.set(person.id, vec);
|
|
83
|
+
return [vec, profile];
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Build the textual profile that gets embedded. Public for tests +
|
|
88
|
+
* for callers that want to feed the same string to LLM stage.
|
|
89
|
+
*/
|
|
90
|
+
buildProfile(person) {
|
|
91
|
+
const parts = [];
|
|
92
|
+
parts.push(`${person.type || "person"}: ${(person.names && person.names[0]) || "(unknown)"}`);
|
|
93
|
+
if (person.names && person.names.length > 1) {
|
|
94
|
+
parts.push(`aliases: ${person.names.slice(1).join(", ")}`);
|
|
95
|
+
}
|
|
96
|
+
const ids = person.identifiers || {};
|
|
97
|
+
const idStrs = [];
|
|
98
|
+
for (const key of Object.keys(ids)) {
|
|
99
|
+
const v = ids[key];
|
|
100
|
+
if (Array.isArray(v)) {
|
|
101
|
+
for (const x of v) idStrs.push(`${key}:${x}`);
|
|
102
|
+
} else if (typeof v === "string") {
|
|
103
|
+
idStrs.push(`${key}:${v}`);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
if (idStrs.length > 0) {
|
|
107
|
+
parts.push(`identifiers: ${idStrs.join(", ")}`);
|
|
108
|
+
}
|
|
109
|
+
if (this._vault) {
|
|
110
|
+
try {
|
|
111
|
+
const recent = this._recentEvents(person.id, 3);
|
|
112
|
+
if (recent.length > 0) {
|
|
113
|
+
parts.push(`recent: ${recent.map((e) => e.content?.title || "(no title)").join("; ")}`);
|
|
114
|
+
}
|
|
115
|
+
} catch (_e) {
|
|
116
|
+
// Vault read failure is non-fatal — embedding still works without events
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return parts.join(" | ");
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
_recentEvents(personId, limit) {
|
|
123
|
+
if (!this._vault || typeof this._vault.queryEvents !== "function") return [];
|
|
124
|
+
// Pull events where this person is actor or participant
|
|
125
|
+
const events = this._vault.queryEvents({ actor: personId, limit });
|
|
126
|
+
return Array.isArray(events) ? events : [];
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async _embed(text) {
|
|
130
|
+
if (this._embedFn) return this._embedFn(text);
|
|
131
|
+
// Default backend: Ollama HTTP API
|
|
132
|
+
return await ollamaEmbed(this._ollamaUrl, this._model, text);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Clear the embedding cache (e.g. after batch). */
|
|
136
|
+
clearCache() {
|
|
137
|
+
this._cache.clear();
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// ─── helpers ────────────────────────────────────────────────────────────
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Cosine similarity ∈ [-1, 1], clamped to [0, 1] for embeddings (they
|
|
145
|
+
* tend to live in non-negative space but we don't trust that).
|
|
146
|
+
*/
|
|
147
|
+
function cosineSimilarity(a, b) {
|
|
148
|
+
if (!a || !b) return 0;
|
|
149
|
+
const len = Math.min(a.length, b.length);
|
|
150
|
+
if (len === 0) return 0;
|
|
151
|
+
let dot = 0;
|
|
152
|
+
let normA = 0;
|
|
153
|
+
let normB = 0;
|
|
154
|
+
for (let i = 0; i < len; i += 1) {
|
|
155
|
+
const x = Number(a[i]) || 0;
|
|
156
|
+
const y = Number(b[i]) || 0;
|
|
157
|
+
dot += x * y;
|
|
158
|
+
normA += x * x;
|
|
159
|
+
normB += y * y;
|
|
160
|
+
}
|
|
161
|
+
if (normA === 0 || normB === 0) return 0;
|
|
162
|
+
const sim = dot / (Math.sqrt(normA) * Math.sqrt(normB));
|
|
163
|
+
// Clamp to [0, 1] for the threshold-comparison call site
|
|
164
|
+
return Math.max(0, Math.min(1, sim));
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Call Ollama's /api/embeddings endpoint. Throws on failure; caller
|
|
169
|
+
* (EntityResolver.drain → errorResolve) handles retry-vs-fatal.
|
|
170
|
+
*/
|
|
171
|
+
async function ollamaEmbed(baseUrl, model, text) {
|
|
172
|
+
const url = `${baseUrl.replace(/\/$/, "")}/api/embeddings`;
|
|
173
|
+
let resp;
|
|
174
|
+
try {
|
|
175
|
+
resp = await fetch(url, {
|
|
176
|
+
method: "POST",
|
|
177
|
+
headers: { "Content-Type": "application/json" },
|
|
178
|
+
body: JSON.stringify({ model, prompt: text }),
|
|
179
|
+
});
|
|
180
|
+
} catch (err) {
|
|
181
|
+
throw new Error(`Ollama embed call failed (${url}): ${err && err.message ? err.message : err}`);
|
|
182
|
+
}
|
|
183
|
+
if (!resp.ok) {
|
|
184
|
+
const body = await resp.text().catch(() => "");
|
|
185
|
+
throw new Error(`Ollama embed returned ${resp.status}: ${body.slice(0, 200)}`);
|
|
186
|
+
}
|
|
187
|
+
const data = await resp.json();
|
|
188
|
+
if (!data || !Array.isArray(data.embedding)) {
|
|
189
|
+
throw new Error(`Ollama embed response missing 'embedding' array`);
|
|
190
|
+
}
|
|
191
|
+
return data.embedding;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
module.exports = {
|
|
195
|
+
EmbeddingStage,
|
|
196
|
+
cosineSimilarity,
|
|
197
|
+
ollamaEmbed,
|
|
198
|
+
};
|