@hyperspell/openclaw-hyperspell 0.12.0 → 0.13.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.
Files changed (50) hide show
  1. package/README.md +37 -1
  2. package/dist/client.js +341 -0
  3. package/dist/commands/setup.js +568 -0
  4. package/dist/commands/slash.js +159 -0
  5. package/dist/config.js +349 -0
  6. package/{graph/cron.ts → dist/graph/cron.js} +11 -15
  7. package/dist/graph/index.js +4 -0
  8. package/dist/graph/ops.js +221 -0
  9. package/dist/graph/state.js +68 -0
  10. package/dist/graph/tools.js +87 -0
  11. package/dist/hooks/auto-context.js +199 -0
  12. package/dist/hooks/auto-trace.js +143 -0
  13. package/dist/hooks/emotional-state.js +155 -0
  14. package/dist/hooks/memory-sync.js +54 -0
  15. package/dist/hooks/startup-orientation.js +236 -0
  16. package/dist/index.js +132 -0
  17. package/dist/lib/browser.js +29 -0
  18. package/dist/lib/sender.js +173 -0
  19. package/dist/lib/voice-id.js +22 -0
  20. package/dist/logger.js +32 -0
  21. package/dist/sync/markdown.js +151 -0
  22. package/dist/tools/remember.js +97 -0
  23. package/dist/tools/search.js +87 -0
  24. package/openclaw.plugin.json +25 -1
  25. package/package.json +7 -12
  26. package/client.ts +0 -566
  27. package/commands/setup.ts +0 -673
  28. package/commands/slash.ts +0 -198
  29. package/config.test.ts +0 -202
  30. package/config.ts +0 -497
  31. package/graph/index.ts +0 -5
  32. package/graph/ops.ts +0 -259
  33. package/graph/state.ts +0 -79
  34. package/graph/tools.ts +0 -117
  35. package/hooks/auto-context.ts +0 -272
  36. package/hooks/auto-trace.test.ts +0 -81
  37. package/hooks/auto-trace.ts +0 -197
  38. package/hooks/emotional-state.test.ts +0 -160
  39. package/hooks/emotional-state.ts +0 -179
  40. package/hooks/memory-sync.ts +0 -65
  41. package/index.ts +0 -152
  42. package/lib/browser.ts +0 -31
  43. package/lib/sender.test.ts +0 -234
  44. package/lib/sender.ts +0 -234
  45. package/lib/voice-id.ts +0 -39
  46. package/logger.ts +0 -41
  47. package/sync/markdown.ts +0 -186
  48. package/tools/remember.ts +0 -132
  49. package/tools/search.ts +0 -131
  50. package/types/openclaw.d.ts +0 -76
@@ -0,0 +1,221 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import { getAllUserIds } from "../lib/sender.js";
4
+ import { log } from "../logger.js";
5
+ const ENTITY_TYPES = ["people", "projects", "organizations", "topics"];
6
+ export function slugify(name) {
7
+ return name
8
+ .toLowerCase()
9
+ .trim()
10
+ .replace(/[^a-z0-9]+/g, "-")
11
+ .replace(/^-|-$/g, "");
12
+ }
13
+ function summarizeMemoryData(data, source) {
14
+ if (!Array.isArray(data) || data.length === 0)
15
+ return "";
16
+ const items = data;
17
+ if (source === "slack" || source === "google_mail") {
18
+ const senders = new Map();
19
+ const messages = [];
20
+ for (const item of items) {
21
+ const sender = item.sender;
22
+ if (sender?.name && sender?.email) {
23
+ senders.set(String(sender.email), String(sender.name));
24
+ }
25
+ if (item.content && messages.length < 5) {
26
+ messages.push(String(item.content).slice(0, 200));
27
+ }
28
+ }
29
+ const parts = [];
30
+ if (senders.size > 0) {
31
+ parts.push(`Participants: ${[...senders.entries()].map(([e, n]) => `${n} <${e}>`).join(", ")}`);
32
+ }
33
+ if (messages.length > 0) {
34
+ parts.push(`Recent messages:\n${messages.join("\n---\n")}`);
35
+ }
36
+ return parts.join("\n\n");
37
+ }
38
+ if (source === "notion") {
39
+ const parts = [];
40
+ for (const item of items.slice(0, 10)) {
41
+ if (item.__type === "Title" && item.text) {
42
+ parts.push(`## ${item.text}`);
43
+ }
44
+ else if (item.__type === "Markdown" && item.text) {
45
+ parts.push(String(item.text).slice(0, 300));
46
+ }
47
+ else if (item.__type === "Table" && item.table_rows) {
48
+ const rows = item.table_rows;
49
+ if (rows[0])
50
+ parts.push(`Table: ${rows[0].join(" | ")}`);
51
+ }
52
+ }
53
+ return parts.join("\n");
54
+ }
55
+ const parts = [];
56
+ for (const item of items.slice(0, 10)) {
57
+ if (item.text) {
58
+ parts.push(String(item.text).slice(0, 300));
59
+ }
60
+ }
61
+ return parts.join("\n");
62
+ }
63
+ export async function scanMemories(client, stateManager, batchSize, cfg) {
64
+ const unprocessed = [];
65
+ const userIds = cfg ? getAllUserIds(cfg) : [undefined];
66
+ outer: for (const userId of userIds) {
67
+ for await (const mem of client.listMemories({ userId })) {
68
+ if (stateManager.isProcessed(mem.resourceId))
69
+ continue;
70
+ if (mem.metadata?.graph_entity === "true" || mem.metadata?.graph_entity === true)
71
+ continue;
72
+ if (mem.metadata?.status !== "completed")
73
+ continue;
74
+ let summary = "";
75
+ try {
76
+ const full = await client.getMemory(mem.resourceId, mem.source, { userId });
77
+ const data = full.data;
78
+ const participants = full.participants;
79
+ const participantLine = participants?.length
80
+ ? `Participants: ${participants.map((p) => `${p.name || ""} <${p.email || ""}>`).join(", ")}`
81
+ : "";
82
+ const dataSummary = data ? summarizeMemoryData(data, mem.source) : "";
83
+ summary = [participantLine, dataSummary].filter(Boolean).join("\n\n");
84
+ }
85
+ catch {
86
+ summary = "(content unavailable)";
87
+ }
88
+ unprocessed.push({
89
+ resourceId: mem.resourceId,
90
+ source: mem.source,
91
+ title: mem.title,
92
+ summary: summary.slice(0, 1000),
93
+ });
94
+ if (unprocessed.length >= batchSize)
95
+ break outer;
96
+ }
97
+ }
98
+ return unprocessed;
99
+ }
100
+ export function formatScanResults(memories, processedCount, lastScan) {
101
+ if (memories.length === 0) {
102
+ return `No unprocessed memories found. ${processedCount} memories already processed. Last scan: ${lastScan || "never"}`;
103
+ }
104
+ const formatted = memories
105
+ .map((m) => {
106
+ const lines = [`[${m.source}] ${m.title || "(untitled)"} (id: ${m.resourceId})`];
107
+ if (m.summary)
108
+ lines.push(m.summary);
109
+ return lines.join("\n");
110
+ })
111
+ .join("\n\n---\n\n");
112
+ return `Found ${memories.length} unprocessed memories:\n\n${formatted}`;
113
+ }
114
+ export function writeEntity(workspaceDir, params) {
115
+ const slug = slugify(params.slug);
116
+ const dir = path.join(workspaceDir, "memory", params.type);
117
+ const filePath = path.join(dir, `${slug}.md`);
118
+ fs.mkdirSync(dir, { recursive: true });
119
+ // Check for existing file and merge
120
+ let existingSourceMemories = {};
121
+ let existingRelationships = [];
122
+ let hyperspellId = "";
123
+ if (fs.existsSync(filePath)) {
124
+ const existing = fs.readFileSync(filePath, "utf-8");
125
+ const fmMatch = existing.match(/^---\n([\s\S]*?)\n---\n?/);
126
+ if (fmMatch) {
127
+ const fmText = fmMatch[1];
128
+ for (const line of fmText.split("\n")) {
129
+ const idx = line.indexOf(":");
130
+ if (idx <= 0)
131
+ continue;
132
+ const key = line.slice(0, idx).trim();
133
+ const val = line.slice(idx + 1).trim();
134
+ if (key === "hyperspell_id")
135
+ hyperspellId = val;
136
+ if (key === "source_memories") {
137
+ try {
138
+ existingSourceMemories = JSON.parse(val);
139
+ }
140
+ catch { }
141
+ }
142
+ if (key === "relationships") {
143
+ try {
144
+ existingRelationships = JSON.parse(val);
145
+ }
146
+ catch { }
147
+ }
148
+ }
149
+ }
150
+ }
151
+ // Merge source memories
152
+ const mergedSources = { ...existingSourceMemories };
153
+ if (params.sourceMemories) {
154
+ for (const [source, ids] of Object.entries(params.sourceMemories)) {
155
+ const existing = mergedSources[source] || [];
156
+ const merged = [...new Set([...existing, ...ids])];
157
+ mergedSources[source] = merged;
158
+ }
159
+ }
160
+ // Merge relationships
161
+ const mergedRelationships = [
162
+ ...new Set([...existingRelationships, ...(params.relationships || [])]),
163
+ ];
164
+ // Build frontmatter
165
+ const fm = {
166
+ title: params.name,
167
+ type: params.type.slice(0, -1), // "people" → "person"
168
+ graph_entity: "true",
169
+ source_memories: JSON.stringify(mergedSources),
170
+ last_extracted: new Date().toISOString(),
171
+ };
172
+ if (hyperspellId)
173
+ fm.hyperspell_id = hyperspellId;
174
+ if (mergedRelationships.length > 0) {
175
+ fm.relationships = JSON.stringify(mergedRelationships);
176
+ }
177
+ if (params.email)
178
+ fm.email = params.email;
179
+ if (params.phone)
180
+ fm.phone = params.phone;
181
+ if (params.domain)
182
+ fm.domain = params.domain;
183
+ // Build body
184
+ const bodyParts = [`# ${params.name}\n`, params.description];
185
+ const contactParts = [];
186
+ if (params.email)
187
+ contactParts.push(`- Email: ${params.email}`);
188
+ if (params.phone)
189
+ contactParts.push(`- Phone: ${params.phone}`);
190
+ if (params.domain)
191
+ contactParts.push(`- Domain: ${params.domain}`);
192
+ if (contactParts.length > 0) {
193
+ bodyParts.push("\n## Contact\n");
194
+ bodyParts.push(...contactParts);
195
+ }
196
+ if (mergedRelationships.length > 0) {
197
+ bodyParts.push("\n## Relationships\n");
198
+ for (const rel of mergedRelationships) {
199
+ const [relationship, target] = rel.split(":");
200
+ if (target) {
201
+ const targetName = target.split("/").pop()?.replace(/-/g, " ") || target;
202
+ bodyParts.push(`- ${relationship}: [${targetName}](../${target}.md)`);
203
+ }
204
+ else {
205
+ bodyParts.push(`- ${rel}`);
206
+ }
207
+ }
208
+ }
209
+ // Build file content
210
+ const fmLines = Object.entries(fm).map(([k, v]) => `${k}: ${v}`);
211
+ const content = `---\n${fmLines.join("\n")}\n---\n${bodyParts.join("\n")}\n`;
212
+ fs.writeFileSync(filePath, content);
213
+ log.info(`Wrote entity: ${params.type}/${slug}.md`);
214
+ return `${params.type}/${slug}.md`;
215
+ }
216
+ export function completeMemories(stateManager, memoryIds) {
217
+ const newCount = stateManager.markProcessed(memoryIds);
218
+ stateManager.updateLastScan();
219
+ stateManager.save();
220
+ return { newCount, totalCount: stateManager.getProcessedCount() };
221
+ }
@@ -0,0 +1,68 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import { log } from "../logger.js";
4
+ const STATE_VERSION = 1;
5
+ const STATE_FILENAME = ".network-state.json";
6
+ export class NetworkStateManager {
7
+ statePath;
8
+ state;
9
+ constructor(workspaceDir) {
10
+ const memoryDir = path.join(workspaceDir, "memory");
11
+ fs.mkdirSync(memoryDir, { recursive: true });
12
+ this.statePath = path.join(memoryDir, STATE_FILENAME);
13
+ this.state = this.load();
14
+ }
15
+ load() {
16
+ try {
17
+ if (fs.existsSync(this.statePath)) {
18
+ const raw = fs.readFileSync(this.statePath, "utf-8");
19
+ const parsed = JSON.parse(raw);
20
+ if (parsed.version === STATE_VERSION) {
21
+ return parsed;
22
+ }
23
+ log.warn(`Network state version mismatch (got ${parsed.version}, want ${STATE_VERSION}), resetting`);
24
+ }
25
+ }
26
+ catch (err) {
27
+ log.warn("Failed to load network state, starting fresh", err);
28
+ }
29
+ return { processedIds: {}, lastScanAt: null, version: STATE_VERSION };
30
+ }
31
+ save() {
32
+ const tmpPath = this.statePath + ".tmp";
33
+ try {
34
+ fs.writeFileSync(tmpPath, JSON.stringify(this.state, null, 2));
35
+ fs.renameSync(tmpPath, this.statePath);
36
+ }
37
+ catch (err) {
38
+ log.error("Failed to save network state", err);
39
+ try {
40
+ fs.unlinkSync(tmpPath);
41
+ }
42
+ catch { }
43
+ }
44
+ }
45
+ isProcessed(resourceId) {
46
+ return resourceId in this.state.processedIds;
47
+ }
48
+ markProcessed(resourceIds) {
49
+ let count = 0;
50
+ const now = new Date().toISOString();
51
+ for (const id of resourceIds) {
52
+ if (!(id in this.state.processedIds)) {
53
+ this.state.processedIds[id] = now;
54
+ count++;
55
+ }
56
+ }
57
+ return count;
58
+ }
59
+ updateLastScan() {
60
+ this.state.lastScanAt = new Date().toISOString();
61
+ }
62
+ getProcessedCount() {
63
+ return Object.keys(this.state.processedIds).length;
64
+ }
65
+ getLastScanAt() {
66
+ return this.state.lastScanAt;
67
+ }
68
+ }
@@ -0,0 +1,87 @@
1
+ import { Type } from "@sinclair/typebox";
2
+ import { getWorkspaceDir } from "../config.js";
3
+ import { log } from "../logger.js";
4
+ import { NetworkStateManager } from "./state.js";
5
+ import { scanMemories, formatScanResults, writeEntity, completeMemories } from "./ops.js";
6
+ const ENTITY_TYPES = ["people", "projects", "organizations", "topics"];
7
+ export function registerNetworkTools(api, client, cfg) {
8
+ const workspaceDir = getWorkspaceDir();
9
+ const stateManager = new NetworkStateManager(workspaceDir);
10
+ api.registerTool({
11
+ name: "hyperspell_network_scan",
12
+ label: "Memory Network Scan",
13
+ description: "Scan Hyperspell memories and return a batch of unprocessed ones with their content summaries. Use this to find new memories that need entity extraction.",
14
+ parameters: Type.Object({
15
+ batchSize: Type.Optional(Type.Number({ description: "Max memories to return (default: 20)" })),
16
+ }),
17
+ async execute(_toolCallId, params) {
18
+ const batchSize = params.batchSize ?? cfg.knowledgeGraph.batchSize;
19
+ try {
20
+ const memories = await scanMemories(client, stateManager, batchSize, cfg);
21
+ const text = formatScanResults(memories, stateManager.getProcessedCount(), stateManager.getLastScanAt());
22
+ return {
23
+ content: [{ type: "text", text }],
24
+ details: { count: memories.length, memories },
25
+ };
26
+ }
27
+ catch (err) {
28
+ log.error("network scan failed", err);
29
+ return {
30
+ content: [{ type: "text", text: `Scan failed: ${err instanceof Error ? err.message : String(err)}` }],
31
+ };
32
+ }
33
+ },
34
+ }, { name: "hyperspell_network_scan" });
35
+ api.registerTool({
36
+ name: "hyperspell_network_write",
37
+ label: "Memory Network Write",
38
+ description: "Write or update an entity file in the memory network. Creates markdown files in memory/people/, memory/projects/, memory/organizations/, or memory/topics/.",
39
+ parameters: Type.Object({
40
+ type: Type.Union(ENTITY_TYPES.map((t) => Type.Literal(t)), {
41
+ description: "Entity type: people, projects, organizations, or topics",
42
+ }),
43
+ slug: Type.String({ description: "URL-safe identifier (lowercase with hyphens, e.g. 'alice-chen')" }),
44
+ name: Type.String({ description: "Display name of the entity" }),
45
+ description: Type.String({ description: "Description of the entity" }),
46
+ relationships: Type.Optional(Type.Array(Type.String(), {
47
+ description: "Relationships in format 'relationship:type/slug', e.g. 'works-at:organizations/hyperspell'",
48
+ })),
49
+ sourceMemories: Type.Optional(Type.Record(Type.String(), Type.Array(Type.String()), {
50
+ description: "Source memories by provider, e.g. { slack: ['C073WR69EPM'], google_mail: ['19bbe68026553623'] }",
51
+ })),
52
+ email: Type.Optional(Type.String({ description: "Email address (for people)" })),
53
+ phone: Type.Optional(Type.String({ description: "Phone number (for people)" })),
54
+ domain: Type.Optional(Type.String({ description: "Domain/homepage (for organizations)" })),
55
+ }),
56
+ async execute(_toolCallId, params) {
57
+ try {
58
+ const result = writeEntity(workspaceDir, params);
59
+ return { content: [{ type: "text", text: `Wrote ${result} (${params.name})` }] };
60
+ }
61
+ catch (err) {
62
+ log.error(`network write failed: ${params.type}/${params.slug}`, err);
63
+ return { content: [{ type: "text", text: `Write failed: ${err instanceof Error ? err.message : String(err)}` }] };
64
+ }
65
+ },
66
+ }, { name: "hyperspell_network_write" });
67
+ api.registerTool({
68
+ name: "hyperspell_network_complete",
69
+ label: "Memory Network Complete",
70
+ description: "Mark a batch of memory IDs as processed so they won't appear in future scans.",
71
+ parameters: Type.Object({
72
+ memoryIds: Type.Array(Type.String(), { description: "List of resource_ids to mark as processed" }),
73
+ }),
74
+ async execute(_toolCallId, params) {
75
+ try {
76
+ const { newCount, totalCount } = completeMemories(stateManager, params.memoryIds);
77
+ return {
78
+ content: [{ type: "text", text: `Marked ${newCount} new memories as processed (${totalCount} total). Last scan: ${stateManager.getLastScanAt()}` }],
79
+ };
80
+ }
81
+ catch (err) {
82
+ log.error("network complete failed", err);
83
+ return { content: [{ type: "text", text: `Complete failed: ${err instanceof Error ? err.message : String(err)}` }] };
84
+ }
85
+ },
86
+ }, { name: "hyperspell_network_complete" });
87
+ }
@@ -0,0 +1,199 @@
1
+ import { buildScopeFilter, getCanReadScopes, resolveUser, } from "../lib/sender.js";
2
+ import { log } from "../logger.js";
3
+ /**
4
+ * Memories produced by session-end hooks (auto-trace, emotional-state) are
5
+ * tagged `source: "openclaw_agent_end"`. The emotional-state hook already has
6
+ * a dedicated fetch path (`getEmotionalState` + `<hyperspell-emotional-context>`
7
+ * injection), so those memories should NOT also surface via generic retrieval —
8
+ * double-injection dilutes results and replays the conversation verbatim.
9
+ * Exclude them here at the search filter.
10
+ */
11
+ const EXCLUDE_SESSION_END_FILTER = {
12
+ source: { $ne: "openclaw_agent_end" },
13
+ };
14
+ function mergeWithExclude(base) {
15
+ if (!base)
16
+ return EXCLUDE_SESSION_END_FILTER;
17
+ return { $and: [base, EXCLUDE_SESSION_END_FILTER] };
18
+ }
19
+ function formatRelativeTime(isoTimestamp) {
20
+ try {
21
+ const dt = new Date(isoTimestamp);
22
+ const now = new Date();
23
+ const seconds = (now.getTime() - dt.getTime()) / 1000;
24
+ const minutes = seconds / 60;
25
+ const hours = seconds / 3600;
26
+ const days = seconds / 86400;
27
+ if (minutes < 30)
28
+ return "just now";
29
+ if (minutes < 60)
30
+ return `${Math.floor(minutes)}mins ago`;
31
+ if (hours < 24)
32
+ return `${Math.floor(hours)} hrs ago`;
33
+ if (days < 7)
34
+ return `${Math.floor(days)}d ago`;
35
+ const month = dt.toLocaleString("en", { month: "short" });
36
+ if (dt.getFullYear() === now.getFullYear()) {
37
+ return `${dt.getDate()} ${month}`;
38
+ }
39
+ return `${dt.getDate()} ${month}, ${dt.getFullYear()}`;
40
+ }
41
+ catch {
42
+ return "";
43
+ }
44
+ }
45
+ /**
46
+ * Format a list of search results as per-highlight bullets, filtered by relevance threshold.
47
+ * Returns null if nothing passes the threshold.
48
+ */
49
+ function formatHighlightBullets(results, maxResults, threshold) {
50
+ const sections = [];
51
+ for (const r of results.slice(0, maxResults)) {
52
+ if ((r.score ?? 0) < threshold)
53
+ continue;
54
+ const aboveThreshold = r.highlights.filter((h) => h.score >= threshold);
55
+ if (aboveThreshold.length === 0)
56
+ continue;
57
+ const title = r.title ?? `[${r.source}]`;
58
+ const bullets = aboveThreshold
59
+ .map((h) => `- ${h.text.replace(/\n/g, " ")} [${Math.round(h.score * 100)}%]`)
60
+ .join("\n");
61
+ sections.push(`### ${title} (resource_id: ${r.resourceId}, source: ${r.source})\n\n${bullets}`);
62
+ }
63
+ if (sections.length === 0)
64
+ return null;
65
+ return sections.join("\n\n");
66
+ }
67
+ const INTRO = "The following is context from the user's connected sources. Reference it only when relevant to the conversation.";
68
+ const DISCLAIMER = "Use this context naturally when relevant — including indirect connections — but don't force it into every response or make assumptions beyond what's stated.";
69
+ function wrapSingle(body) {
70
+ return `<hyperspell-context>\n${INTRO}\n\n${body}\n\n${DISCLAIMER}\n</hyperspell-context>`;
71
+ }
72
+ export function buildAutoContextHandler(client, cfg) {
73
+ return async (event, ctx) => {
74
+ const prompt = event.prompt;
75
+ if (!prompt || prompt.length < 5)
76
+ return;
77
+ // Multi-user path
78
+ if (cfg.multiUser) {
79
+ const resolved = resolveUser(ctx, cfg);
80
+ return multiUserSearch(client, cfg, prompt, resolved);
81
+ }
82
+ // Single-user path — preserves main's highlights + threshold behavior
83
+ log.debug(`auto-context: searching for "${prompt.slice(0, 50)}..."`);
84
+ try {
85
+ const results = await client.search(prompt, {
86
+ limit: cfg.maxResults,
87
+ filter: EXCLUDE_SESSION_END_FILTER,
88
+ });
89
+ const formatted = formatHighlightBullets(results, cfg.maxResults, cfg.relevanceThreshold);
90
+ if (!formatted) {
91
+ log.debug("auto-context: no relevant memories found");
92
+ return;
93
+ }
94
+ log.debug(`auto-context: injecting ${results.length} memories`);
95
+ return { prependContext: wrapSingle(formatted) };
96
+ }
97
+ catch (err) {
98
+ log.error("auto-context failed", err);
99
+ return;
100
+ }
101
+ };
102
+ }
103
+ async function multiUserSearch(client, cfg, prompt, resolved) {
104
+ const multiUser = cfg.multiUser;
105
+ const isKnownSender = !!resolved?.resolved;
106
+ const includeShared = multiUser.includeSharedInSearch;
107
+ // Determine scope filter for the shared-space search based on caller's role.
108
+ // Unknown senders fall back to least-sensitive scopes; absent scoping config →
109
+ // filter is undefined → PR #6 behavior preserved.
110
+ const canRead = multiUser.scoping
111
+ ? isKnownSender
112
+ ? getCanReadScopes(resolved, cfg)
113
+ : ["family", "kid_shared"]
114
+ : ["*"];
115
+ const scopeFilter = buildScopeFilter(canRead, resolved?.userId ?? "");
116
+ log.debug(`auto-context: searching for "${prompt.slice(0, 50)}..." user=${resolved?.userId ?? "unknown"} canRead=${JSON.stringify(canRead)}`);
117
+ // Build parallel searches — personal (known senders only) + shared
118
+ const personalSearch = isKnownSender
119
+ ? client.search(prompt, {
120
+ limit: cfg.maxResults,
121
+ userId: resolved.userId,
122
+ filter: EXCLUDE_SESSION_END_FILTER,
123
+ })
124
+ : null;
125
+ // Always search shared for unknown senders, even if includeSharedInSearch is false
126
+ const sharedLimit = isKnownSender
127
+ ? Math.ceil(cfg.maxResults / 2)
128
+ : cfg.maxResults;
129
+ const sharedSearch = includeShared || !isKnownSender
130
+ ? client.search(prompt, {
131
+ limit: sharedLimit,
132
+ userId: multiUser.sharedUserId,
133
+ filter: mergeWithExclude(scopeFilter),
134
+ })
135
+ : null;
136
+ const searches = [personalSearch, sharedSearch].filter(Boolean);
137
+ const settled = await Promise.allSettled(searches);
138
+ let idx = 0;
139
+ let personalResults = [];
140
+ let sharedResults = [];
141
+ if (personalSearch) {
142
+ const r = settled[idx++];
143
+ if (r.status === "fulfilled") {
144
+ personalResults = r.value;
145
+ }
146
+ else {
147
+ log.error("auto-context: personal search failed", r.reason);
148
+ }
149
+ }
150
+ if (sharedSearch) {
151
+ const r = settled[idx++];
152
+ if (r.status === "fulfilled") {
153
+ sharedResults = r.value;
154
+ }
155
+ else {
156
+ log.error("auto-context: shared search failed", r.reason);
157
+ }
158
+ }
159
+ const sections = [];
160
+ // User identity preamble
161
+ if (isKnownSender && resolved) {
162
+ const contextLine = resolved.context ? ` ${resolved.context}` : "";
163
+ sections.push(`You are speaking with ${resolved.name}.${contextLine}`);
164
+ }
165
+ // Personal section (threshold-filtered per PR #11/#12 format)
166
+ if (isKnownSender && personalResults.length > 0 && resolved) {
167
+ const formatted = formatHighlightBullets(personalResults, cfg.maxResults, cfg.relevanceThreshold);
168
+ if (formatted) {
169
+ sections.push(`<personal-context>\nMemories from ${resolved.name}'s personal sources and history.\n\n${formatted}\n</personal-context>`);
170
+ }
171
+ }
172
+ // Shared section
173
+ if (sharedResults.length > 0) {
174
+ const sharedDisplayLimit = isKnownSender
175
+ ? Math.ceil(cfg.maxResults / 2)
176
+ : cfg.maxResults;
177
+ const formatted = formatHighlightBullets(sharedResults, sharedDisplayLimit, cfg.relevanceThreshold);
178
+ if (formatted) {
179
+ sections.push(`<shared-context>\nShared memories available to all users.\n\n${formatted}\n</shared-context>`);
180
+ }
181
+ }
182
+ // If only the identity preamble is present (no memory sections), still inject identity
183
+ const haveMemorySections = sections.some((s) => s.startsWith("<personal-context>") || s.startsWith("<shared-context>"));
184
+ if (!haveMemorySections) {
185
+ log.debug("auto-context: no relevant memories found");
186
+ if (isKnownSender && resolved) {
187
+ const contextLine = resolved.context ? ` ${resolved.context}` : "";
188
+ return {
189
+ prependContext: `<hyperspell-context>\nYou are speaking with ${resolved.name}.${contextLine}\n</hyperspell-context>`,
190
+ };
191
+ }
192
+ return;
193
+ }
194
+ const totalCount = personalResults.length + sharedResults.length;
195
+ log.debug(`auto-context: injecting ${totalCount} memories (${personalResults.length} personal, ${sharedResults.length} shared)`);
196
+ return {
197
+ prependContext: `<hyperspell-context>\n${sections.join("\n\n")}\n\n${DISCLAIMER}\n</hyperspell-context>`,
198
+ };
199
+ }