@unblocklabs/unblock-memory 0.1.2 → 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.
@@ -0,0 +1,163 @@
1
+ import { createHash } from "node:crypto";
2
+ function record(value) {
3
+ return value !== null && typeof value === "object" && !Array.isArray(value)
4
+ ? value
5
+ : undefined;
6
+ }
7
+ function nonEmptyString(value) {
8
+ return typeof value === "string" && value.trim() ? value.trim() : undefined;
9
+ }
10
+ function timestamp(value) {
11
+ if (typeof value === "number" && Number.isFinite(value))
12
+ return value;
13
+ if (typeof value !== "string")
14
+ return undefined;
15
+ const parsed = Date.parse(value);
16
+ return Number.isFinite(parsed) ? parsed : undefined;
17
+ }
18
+ function textContent(value) {
19
+ if (typeof value === "string")
20
+ return value.trim() || undefined;
21
+ if (!Array.isArray(value))
22
+ return undefined;
23
+ const text = value.flatMap((item) => {
24
+ const block = record(item);
25
+ if (!block || typeof block.type !== "string")
26
+ throw new Error("invalid transcript content block");
27
+ if (!["text", "toolCall", "toolResult", "thinking", "image"].includes(block.type)) {
28
+ throw new Error(`unsupported transcript content block: ${block.type}`);
29
+ }
30
+ if (block.type === "text" && typeof block.text !== "string") {
31
+ throw new Error("invalid transcript text block");
32
+ }
33
+ return block?.type === "text" && typeof block.text === "string" ? [block.text] : [];
34
+ }).join("\n").trim();
35
+ return text || undefined;
36
+ }
37
+ function projectMessage(row, input) {
38
+ let event;
39
+ try {
40
+ event = JSON.parse(row.eventJson);
41
+ }
42
+ catch {
43
+ throw new Error("invalid transcript event JSON");
44
+ }
45
+ const eventRecord = record(event);
46
+ if (eventRecord?.type !== "message")
47
+ return undefined;
48
+ const message = record(eventRecord?.message);
49
+ if (!message)
50
+ throw new Error("invalid transcript message event");
51
+ const role = message.role;
52
+ if (role !== "user" && role !== "assistant")
53
+ return undefined;
54
+ if (typeof message.content !== "string" && !Array.isArray(message.content)) {
55
+ throw new Error("invalid transcript message content");
56
+ }
57
+ let text = textContent(message.content);
58
+ if (!text)
59
+ return undefined;
60
+ if (text === "HEARTBEAT_OK")
61
+ return undefined;
62
+ if (role === "user" && (text === "[OpenClaw heartbeat poll]" ||
63
+ text.startsWith("[Subagent Context]") ||
64
+ text.startsWith("<relevant-memories>")))
65
+ return undefined;
66
+ const metadata = record(message.__openclaw);
67
+ const speaker = role === "assistant"
68
+ ? input.agentName
69
+ : nonEmptyString(metadata?.senderName) ??
70
+ nonEmptyString(metadata?.senderUsername) ??
71
+ nonEmptyString(metadata?.senderId) ??
72
+ nonEmptyString(message.senderName) ??
73
+ nonEmptyString(message.senderLabel) ??
74
+ nonEmptyString(message.senderId) ??
75
+ "User";
76
+ if (role === "user" && speaker !== "User") {
77
+ text = text.replace(/^From:[^\n]*\n/u, "").trim();
78
+ }
79
+ if (!text)
80
+ return undefined;
81
+ return {
82
+ speaker: speaker.replace(/[\r\n]+/gu, " "),
83
+ text,
84
+ timestamp: timestamp(eventRecord.timestamp) ?? row.createdAt ?? timestamp(message.timestamp) ?? input.startedAt,
85
+ };
86
+ }
87
+ function formatTimestamp(value, timezone) {
88
+ const parts = new Intl.DateTimeFormat("en-CA", {
89
+ timeZone: timezone,
90
+ year: "numeric",
91
+ month: "2-digit",
92
+ day: "2-digit",
93
+ hour: "2-digit",
94
+ minute: "2-digit",
95
+ second: "2-digit",
96
+ hourCycle: "h23",
97
+ timeZoneName: "short",
98
+ }).formatToParts(value);
99
+ const part = (type) => parts.find((entry) => entry.type === type)?.value ?? "";
100
+ return `${part("year")}-${part("month")}-${part("day")} ` +
101
+ `${part("hour")}:${part("minute")}:${part("second")} ${part("timeZoneName")}`.trim();
102
+ }
103
+ function inline(value) {
104
+ return value.replace(/[\r\n]+/gu, " ").replaceAll("`", "\\`");
105
+ }
106
+ export function projectSession(input) {
107
+ const messages = input.events.flatMap((event) => {
108
+ const projected = projectMessage(event, input);
109
+ return projected ? [projected] : [];
110
+ });
111
+ if (messages.length === 0)
112
+ return undefined;
113
+ const provider = input.provider ?? "unknown";
114
+ const header = [
115
+ "# Session",
116
+ "",
117
+ `- Session ID: \`${inline(input.sessionId)}\``,
118
+ `- Provider: ${inline(provider)}`,
119
+ `- Chat type: ${input.chatType}`,
120
+ ...(input.label ? [`- Conversation: ${inline(input.label)}`] : []),
121
+ ...(input.conversationId ? [`- Conversation ID: \`${inline(input.conversationId)}\``] : []),
122
+ `- Started: ${new Date(input.startedAt).toISOString()}`,
123
+ "",
124
+ "## Transcript",
125
+ "",
126
+ ];
127
+ const transcript = messages.map((message) => `${formatTimestamp(message.timestamp, input.timezone)} — ${message.speaker}: ${message.text}`);
128
+ return `${[...header, ...transcript].join("\n\n")}\n`;
129
+ }
130
+ function hash(value) {
131
+ return createHash("sha256").update(value).digest("hex").slice(0, 16);
132
+ }
133
+ function pathComponent(value, fallback, privateId = false) {
134
+ const normalized = value?.trim();
135
+ if (!normalized)
136
+ return fallback;
137
+ if (privateId || normalized.includes("@") || /^\+?\d{6,}$/u.test(normalized)) {
138
+ return `id-${hash(normalized)}`;
139
+ }
140
+ const safe = normalized.replace(/[^a-zA-Z0-9._-]+/gu, "-").replace(/^-+|-+$/gu, "");
141
+ return safe && safe !== "." && safe !== ".." ? safe.slice(0, 80) : `id-${hash(normalized)}`;
142
+ }
143
+ export function sessionDocumentPath(metadata) {
144
+ const provider = pathComponent(metadata.provider?.toLowerCase(), "unknown");
145
+ const privateConversation = provider === "imessage";
146
+ const account = pathComponent(metadata.accountId, "default", privateConversation);
147
+ const conversation = pathComponent(metadata.conversationId, `session-${hash(metadata.sessionId)}`, privateConversation);
148
+ const started = new Date(metadata.startedAt).toISOString().replaceAll(":", "-").replace(/\.\d{3}Z$/u, "Z");
149
+ const sessionId = pathComponent(metadata.sessionId, hash(metadata.sessionId));
150
+ return `${provider}/${metadata.chatType}/${account}/${conversation}/${started}--${sessionId}.md`;
151
+ }
152
+ export function resolveTimezone(configured) {
153
+ if (configured) {
154
+ try {
155
+ new Intl.DateTimeFormat("en-US", { timeZone: configured }).format();
156
+ return configured;
157
+ }
158
+ catch {
159
+ // OpenClaw normally validates this; use the host timezone for stale config.
160
+ }
161
+ }
162
+ return Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
163
+ }
@@ -0,0 +1,43 @@
1
+ import type { ChatType } from "./config.js";
2
+ import { type SessionMetadata } from "./session-projector.js";
3
+ type IndexedSession = SessionMetadata & {
4
+ sourceGeneration: string;
5
+ maxSeq: number;
6
+ activeEventCount: number;
7
+ sizeBytes: number;
8
+ projectionHash: string;
9
+ documentPath: string;
10
+ projectorVersion: number;
11
+ };
12
+ export type SessionManifest = {
13
+ version: number;
14
+ lastSuccessfulSyncAt?: number;
15
+ sessions: Record<string, IndexedSession>;
16
+ };
17
+ export type SessionSyncResult = {
18
+ scanned: number;
19
+ unchanged: number;
20
+ updated: number;
21
+ removed: number;
22
+ skipped: number;
23
+ failed: number;
24
+ embedded: number;
25
+ lastSuccessfulSyncAt: number;
26
+ };
27
+ export declare function readSessionManifest(path: string): Promise<SessionManifest>;
28
+ export declare function sessionMetadataByPath(manifest: SessionManifest): Map<string, SessionMetadata>;
29
+ export declare function syncSessionProjections(params: {
30
+ databasePath: string;
31
+ outputDir: string;
32
+ manifestPath: string;
33
+ agentId: string;
34
+ agentName: string;
35
+ timezone: string;
36
+ chatTypes: readonly ChatType[];
37
+ force?: boolean;
38
+ index?: () => Promise<number>;
39
+ }): Promise<{
40
+ result: SessionSyncResult;
41
+ manifest: SessionManifest;
42
+ }>;
43
+ export {};
@@ -0,0 +1,302 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { existsSync, lstatSync } from "node:fs";
3
+ import { chmod, mkdir, readFile, rename, unlink, writeFile } from "node:fs/promises";
4
+ import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
5
+ import { DatabaseSync } from "node:sqlite";
6
+ import { projectSession, sessionDocumentPath, } from "./session-projector.js";
7
+ const MANIFEST_VERSION = 1;
8
+ const PROJECTOR_VERSION = 1;
9
+ const SUPPORTED_SCHEMA_VERSION = 17;
10
+ const REQUIRED_COLUMNS = {
11
+ schema_meta: ["meta_key", "role", "schema_version", "agent_id", "app_version"],
12
+ session_windows: [
13
+ "session_id", "session_key", "chat_type", "channel", "account_id",
14
+ "primary_conversation_id", "created_at", "started_at", "ended_at",
15
+ ],
16
+ conversations: [
17
+ "conversation_id", "channel", "account_id", "kind", "peer_id",
18
+ "thread_id", "native_channel_id", "native_direct_user_id", "label",
19
+ ],
20
+ transcript_events: ["session_id", "seq", "event_json", "created_at"],
21
+ session_transcript_active_events: ["session_id", "active_position", "event_seq", "message_position"],
22
+ transcript_rewrite_watermarks: ["session_id", "generation"],
23
+ };
24
+ function projectionPath(outputDir, documentPath) {
25
+ const root = resolve(outputDir);
26
+ const target = resolve(root, documentPath);
27
+ const pathFromRoot = relative(root, target);
28
+ if (!pathFromRoot || pathFromRoot === ".." || pathFromRoot.startsWith(`..${sep}`) || isAbsolute(pathFromRoot)) {
29
+ throw new Error(`invalid unblock-memory session document path: ${documentPath}`);
30
+ }
31
+ let current = root;
32
+ for (const component of ["", ...pathFromRoot.split(sep)]) {
33
+ current = component ? join(current, component) : current;
34
+ try {
35
+ if (lstatSync(current).isSymbolicLink()) {
36
+ throw new Error(`invalid unblock-memory session document path: ${documentPath}`);
37
+ }
38
+ }
39
+ catch (error) {
40
+ if (error.code !== "ENOENT")
41
+ throw error;
42
+ }
43
+ }
44
+ return target;
45
+ }
46
+ function assertSchema(db, expectedAgentId) {
47
+ const pragma = db.prepare("PRAGMA user_version").get();
48
+ if (pragma?.user_version !== SUPPORTED_SCHEMA_VERSION) {
49
+ throw new Error(`unsupported OpenClaw agent database schema: expected ${SUPPORTED_SCHEMA_VERSION}, ` +
50
+ `found ${String(pragma?.user_version ?? "unknown")}`);
51
+ }
52
+ for (const [table, required] of Object.entries(REQUIRED_COLUMNS)) {
53
+ const columns = new Set(db.prepare(`PRAGMA table_info(${table})`).all()
54
+ .map((column) => column.name));
55
+ const missing = required.find((column) => !columns.has(column));
56
+ if (missing)
57
+ throw new Error(`unsupported OpenClaw agent database: missing ${table}.${missing}`);
58
+ }
59
+ const meta = db.prepare("SELECT role, schema_version AS schemaVersion, agent_id AS agentId " +
60
+ "FROM schema_meta WHERE meta_key = 'primary' LIMIT 1").get();
61
+ if (meta?.role !== "agent" || meta.schemaVersion !== SUPPORTED_SCHEMA_VERSION) {
62
+ throw new Error("unsupported OpenClaw agent database primary schema metadata");
63
+ }
64
+ if (meta.agentId !== expectedAgentId) {
65
+ throw new Error(`OpenClaw agent database belongs to ${String(meta.agentId)}, not ${expectedAgentId}`);
66
+ }
67
+ }
68
+ function readSnapshot(params) {
69
+ const db = new DatabaseSync(params.databasePath, { readOnly: true });
70
+ try {
71
+ db.exec("PRAGMA query_only = ON; PRAGMA busy_timeout = 5000; BEGIN");
72
+ assertSchema(db, params.agentId);
73
+ const placeholders = params.chatTypes.map(() => "?").join(", ");
74
+ const windows = db.prepare(`
75
+ SELECT
76
+ window.session_id AS sessionId,
77
+ window.chat_type AS chatType,
78
+ COALESCE(window.channel, conversation.channel) AS provider,
79
+ COALESCE(window.account_id, conversation.account_id) AS accountId,
80
+ COALESCE(conversation.native_channel_id, conversation.native_direct_user_id,
81
+ conversation.peer_id, window.primary_conversation_id) AS conversationId,
82
+ conversation.label AS label,
83
+ COALESCE(window.started_at, window.created_at) AS startedAt,
84
+ rewrite.generation AS sourceGeneration,
85
+ MAX(active.event_seq) AS maxSeq,
86
+ COUNT(active.event_seq) AS activeEventCount
87
+ FROM session_windows AS window
88
+ LEFT JOIN conversations AS conversation
89
+ ON conversation.conversation_id = window.primary_conversation_id
90
+ LEFT JOIN transcript_rewrite_watermarks AS rewrite
91
+ ON rewrite.session_id = window.session_id
92
+ LEFT JOIN session_transcript_active_events AS active
93
+ ON active.session_id = window.session_id
94
+ WHERE window.chat_type IN (${placeholders})
95
+ GROUP BY window.session_id
96
+ ORDER BY window.created_at, window.session_id
97
+ `).all(...params.chatTypes);
98
+ const readEvents = db.prepare(`
99
+ SELECT active.session_id AS sessionId, event.event_json AS eventJson,
100
+ event.created_at AS createdAt
101
+ FROM session_transcript_active_events AS active
102
+ JOIN transcript_events AS event
103
+ ON event.session_id = active.session_id AND event.seq = active.event_seq
104
+ WHERE active.session_id = ?
105
+ ORDER BY active.active_position
106
+ `);
107
+ const events = new Map();
108
+ for (const window of windows) {
109
+ const metadata = {
110
+ sessionId: window.sessionId,
111
+ provider: window.provider ?? undefined,
112
+ chatType: window.chatType,
113
+ accountId: window.accountId ?? undefined,
114
+ conversationId: window.conversationId ?? undefined,
115
+ startedAt: window.startedAt,
116
+ };
117
+ const previous = params.previousManifest.sessions[window.sessionId];
118
+ const documentPath = sessionDocumentPath(metadata);
119
+ const unchanged = !params.force &&
120
+ previous?.sourceGeneration === window.sourceGeneration &&
121
+ previous.maxSeq === (window.maxSeq ?? 0) &&
122
+ previous.projectorVersion === PROJECTOR_VERSION &&
123
+ previous.documentPath === documentPath &&
124
+ existsSync(projectionPath(params.outputDir, documentPath));
125
+ if (!unchanged) {
126
+ events.set(window.sessionId, readEvents.all(window.sessionId));
127
+ }
128
+ }
129
+ db.exec("COMMIT");
130
+ return { windows, events };
131
+ }
132
+ catch (error) {
133
+ try {
134
+ db.exec("ROLLBACK");
135
+ }
136
+ catch { /* transaction may not have started */ }
137
+ throw error;
138
+ }
139
+ finally {
140
+ db.close();
141
+ }
142
+ }
143
+ function emptyManifest() {
144
+ return { version: MANIFEST_VERSION, sessions: {} };
145
+ }
146
+ export async function readSessionManifest(path) {
147
+ if (!existsSync(path))
148
+ return emptyManifest();
149
+ let value;
150
+ try {
151
+ value = JSON.parse(await readFile(path, "utf8"));
152
+ }
153
+ catch {
154
+ throw new Error(`invalid unblock-memory session manifest: ${path}`);
155
+ }
156
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
157
+ throw new Error(`invalid unblock-memory session manifest: ${path}`);
158
+ }
159
+ const manifest = value;
160
+ if (manifest.version !== MANIFEST_VERSION || !manifest.sessions ||
161
+ typeof manifest.sessions !== "object" || Array.isArray(manifest.sessions)) {
162
+ throw new Error(`unsupported unblock-memory session manifest: ${path}`);
163
+ }
164
+ return manifest;
165
+ }
166
+ async function atomicWrite(path, content, mode) {
167
+ await mkdir(dirname(path), { recursive: true, mode: 0o700 });
168
+ const temporary = `${path}.${process.pid}.${randomUUID()}.tmp`;
169
+ try {
170
+ await writeFile(temporary, content, { encoding: "utf8", mode });
171
+ await rename(temporary, path);
172
+ await chmod(path, mode);
173
+ }
174
+ catch (error) {
175
+ await unlink(temporary).catch(() => undefined);
176
+ throw error;
177
+ }
178
+ }
179
+ async function remove(path) {
180
+ try {
181
+ await unlink(path);
182
+ return true;
183
+ }
184
+ catch (error) {
185
+ if (error.code === "ENOENT")
186
+ return false;
187
+ throw error;
188
+ }
189
+ }
190
+ function projectionHash(content) {
191
+ return createHash("sha256").update(content).digest("hex");
192
+ }
193
+ export function sessionMetadataByPath(manifest) {
194
+ return new Map(Object.values(manifest.sessions).map((session) => [session.documentPath, {
195
+ sessionId: session.sessionId,
196
+ provider: session.provider,
197
+ chatType: session.chatType,
198
+ accountId: session.accountId,
199
+ conversationId: session.conversationId,
200
+ startedAt: session.startedAt,
201
+ }]));
202
+ }
203
+ export async function syncSessionProjections(params) {
204
+ const previousManifest = await readSessionManifest(params.manifestPath);
205
+ const snapshot = readSnapshot({
206
+ ...params,
207
+ force: params.force === true,
208
+ previousManifest,
209
+ });
210
+ const sessions = {};
211
+ const counts = { unchanged: 0, updated: 0, removed: 0, skipped: 0, failed: 0 };
212
+ await mkdir(params.outputDir, { recursive: true, mode: 0o700 });
213
+ await chmod(params.outputDir, 0o700);
214
+ for (const window of snapshot.windows) {
215
+ const previous = previousManifest.sessions[window.sessionId];
216
+ const events = snapshot.events.get(window.sessionId);
217
+ const metadata = {
218
+ sessionId: window.sessionId,
219
+ provider: window.provider ?? undefined,
220
+ chatType: window.chatType,
221
+ accountId: window.accountId ?? undefined,
222
+ conversationId: window.conversationId ?? undefined,
223
+ startedAt: window.startedAt,
224
+ };
225
+ const documentPath = sessionDocumentPath(metadata);
226
+ if (events === undefined) {
227
+ sessions[window.sessionId] = previous;
228
+ counts.unchanged += 1;
229
+ continue;
230
+ }
231
+ if (events.length > 0 && (!window.sourceGeneration || window.maxSeq === null)) {
232
+ counts.failed += 1;
233
+ if (previous)
234
+ sessions[window.sessionId] = previous;
235
+ continue;
236
+ }
237
+ let content;
238
+ try {
239
+ const input = {
240
+ ...metadata,
241
+ label: window.label ?? undefined,
242
+ agentName: params.agentName,
243
+ timezone: params.timezone,
244
+ events,
245
+ };
246
+ content = projectSession(input);
247
+ }
248
+ catch {
249
+ counts.failed += 1;
250
+ if (previous)
251
+ sessions[window.sessionId] = previous;
252
+ continue;
253
+ }
254
+ if (!content) {
255
+ counts.skipped += 1;
256
+ if (previous) {
257
+ await remove(projectionPath(params.outputDir, previous.documentPath));
258
+ counts.removed += 1;
259
+ }
260
+ continue;
261
+ }
262
+ const target = projectionPath(params.outputDir, documentPath);
263
+ await atomicWrite(target, content, 0o600);
264
+ if (previous?.documentPath && previous.documentPath !== documentPath) {
265
+ await remove(projectionPath(params.outputDir, previous.documentPath));
266
+ }
267
+ sessions[window.sessionId] = {
268
+ ...metadata,
269
+ sourceGeneration: window.sourceGeneration,
270
+ maxSeq: window.maxSeq,
271
+ activeEventCount: window.activeEventCount,
272
+ sizeBytes: Buffer.byteLength(content),
273
+ projectionHash: projectionHash(content),
274
+ documentPath,
275
+ projectorVersion: PROJECTOR_VERSION,
276
+ };
277
+ counts.updated += 1;
278
+ }
279
+ for (const [sessionId, session] of Object.entries(previousManifest.sessions)) {
280
+ if (sessions[sessionId] || snapshot.windows.some((window) => window.sessionId === sessionId))
281
+ continue;
282
+ await remove(projectionPath(params.outputDir, session.documentPath));
283
+ counts.removed += 1;
284
+ }
285
+ const embedded = await params.index?.() ?? 0;
286
+ const lastSuccessfulSyncAt = Date.now();
287
+ const manifest = {
288
+ version: MANIFEST_VERSION,
289
+ lastSuccessfulSyncAt,
290
+ sessions,
291
+ };
292
+ await atomicWrite(params.manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, 0o600);
293
+ return {
294
+ result: {
295
+ scanned: snapshot.windows.length,
296
+ ...counts,
297
+ embedded,
298
+ lastSuccessfulSyncAt,
299
+ },
300
+ manifest,
301
+ };
302
+ }
@@ -1,11 +1,17 @@
1
+ import type { ChatType, FileCorpusConfig } from "./config.js";
1
2
  export type ResolvedSource = {
2
3
  collection: string;
4
+ corpus: string;
3
5
  configuredPath: string;
6
+ kind: "files" | "sessions";
4
7
  root: string;
5
8
  pattern: string;
6
9
  watchPath: string;
10
+ chatTypes?: readonly ChatType[];
7
11
  };
8
- export declare function resolveSource(workspaceDir: string, configuredPath: string): ResolvedSource;
12
+ export declare function resolveSource(workspaceDir: string, configuredPath: string, corpus?: string): ResolvedSource;
13
+ export declare function resolveSessionSource(sessionsDir: string, chatTypes: readonly ChatType[]): ResolvedSource;
14
+ export declare function resolveSources(workspaceDir: string, corpora: readonly FileCorpusConfig[]): ResolvedSource[];
9
15
  export declare function parseSafeVirtualPath(virtualPath: string, sources: ReadonlyMap<string, ResolvedSource>): {
10
16
  source: ResolvedSource;
11
17
  relativePath: string;
@@ -29,7 +29,7 @@ function assertWorkspaceSourceHasNoSymlinkRoot(workspaceDir, configuredPath, roo
29
29
  current = parent;
30
30
  }
31
31
  }
32
- export function resolveSource(workspaceDir, configuredPath) {
32
+ export function resolveSource(workspaceDir, configuredPath, corpus = "memory") {
33
33
  const expanded = expandHome(configuredPath);
34
34
  const absolute = isAbsolute(expanded) ? resolve(expanded) : resolve(workspaceDir, expanded);
35
35
  if (existsSync(absolute)) {
@@ -39,7 +39,9 @@ export function resolveSource(workspaceDir, configuredPath) {
39
39
  assertWorkspaceSourceHasNoSymlinkRoot(workspaceDir, configuredPath, root);
40
40
  return {
41
41
  collection: collectionName(absolute),
42
+ corpus,
42
43
  configuredPath,
44
+ kind: "files",
43
45
  root,
44
46
  pattern,
45
47
  watchPath: absolute,
@@ -52,7 +54,9 @@ export function resolveSource(workspaceDir, configuredPath) {
52
54
  assertWorkspaceSourceHasNoSymlinkRoot(workspaceDir, configuredPath, root);
53
55
  return {
54
56
  collection: collectionName(absolute),
57
+ corpus,
55
58
  configuredPath,
59
+ kind: "files",
56
60
  root,
57
61
  pattern: isExactMarkdownFile ? basename(absolute) : "**/*.md",
58
62
  watchPath: absolute,
@@ -62,7 +66,33 @@ export function resolveSource(workspaceDir, configuredPath) {
62
66
  const root = prefix.slice(0, prefix.lastIndexOf(sep)) || sep;
63
67
  const pattern = relative(root, absolute).split(sep).join("/");
64
68
  assertWorkspaceSourceHasNoSymlinkRoot(workspaceDir, configuredPath, root);
65
- return { collection: collectionName(absolute), configuredPath, root, pattern, watchPath: root };
69
+ return { collection: collectionName(absolute), corpus, configuredPath, kind: "files", root, pattern, watchPath: root };
70
+ }
71
+ export function resolveSessionSource(sessionsDir, chatTypes) {
72
+ return {
73
+ ...resolveSource(sessionsDir, sessionsDir, "sessions"),
74
+ configuredPath: sessionsDir,
75
+ kind: "sessions",
76
+ chatTypes,
77
+ };
78
+ }
79
+ export function resolveSources(workspaceDir, corpora) {
80
+ const sources = [];
81
+ const configured = new Map();
82
+ for (const corpus of corpora) {
83
+ for (const path of corpus.paths) {
84
+ const source = resolveSource(workspaceDir, path, corpus.name);
85
+ const identity = `${source.root}\0${source.pattern}`;
86
+ const duplicate = configured.get(identity);
87
+ if (duplicate) {
88
+ throw new Error(`unblock-memory source ${path} in corpus ${corpus.name} duplicates ` +
89
+ `${duplicate.configuredPath} in corpus ${duplicate.corpus}`);
90
+ }
91
+ configured.set(identity, source);
92
+ sources.push(source);
93
+ }
94
+ }
95
+ return sources;
66
96
  }
67
97
  export function parseSafeVirtualPath(virtualPath, sources) {
68
98
  const match = /^qmd:\/\/([^/]+)\/(.+)$/.exec(virtualPath.trim());
@@ -1,20 +1,21 @@
1
1
  {
2
2
  "id": "unblock-memory",
3
3
  "name": "Unblock Memory",
4
- "version": "0.1.2",
4
+ "version": "0.2.1",
5
5
  "description": "Indexes, retrieves, and analyzes configured workspace memory with existing QMD vectors.",
6
6
  "kind": "memory",
7
7
  "activation": { "onStartup": false },
8
- "contracts": { "tools": ["memory_search", "memory_get", "memory_recluster", "memory_list_clusters", "memory_fetch_cluster"] },
8
+ "contracts": { "tools": ["memory_search", "memory_get", "memory_sync_sessions", "memory_recluster", "memory_list_clusters", "memory_fetch_cluster"] },
9
9
  "toolMetadata": {
10
+ "memory_sync_sessions": { "sideEffecting": true },
10
11
  "memory_recluster": { "sideEffecting": true },
11
12
  "memory_list_clusters": { "replaySafe": true },
12
13
  "memory_fetch_cluster": { "replaySafe": true }
13
14
  },
14
15
  "uiHints": {
15
- "paths": {
16
- "label": "Memory Paths",
17
- "help": "Exact Markdown files, directories, or globs. Relative paths resolve from each agent workspace."
16
+ "corpora": {
17
+ "label": "Memory Corpora",
18
+ "help": "Named groups of exact Markdown files, directories, or globs. Relative paths resolve from each agent workspace."
18
19
  },
19
20
  "analysis.executable": {
20
21
  "label": "Memory Analysis Worker",
@@ -25,10 +26,49 @@
25
26
  "type": "object",
26
27
  "additionalProperties": false,
27
28
  "properties": {
28
- "paths": {
29
+ "corpora": {
29
30
  "type": "array",
30
- "items": { "type": "string", "minLength": 1 },
31
- "default": ["MEMORY.md", "USER.md", "memory/**/*.md"]
31
+ "minItems": 1,
32
+ "items": {
33
+ "oneOf": [
34
+ {
35
+ "type": "object",
36
+ "additionalProperties": false,
37
+ "required": ["name", "kind", "paths"],
38
+ "properties": {
39
+ "name": { "type": "string", "pattern": "\\S" },
40
+ "kind": { "const": "files" },
41
+ "paths": {
42
+ "type": "array",
43
+ "minItems": 1,
44
+ "items": { "type": "string", "pattern": "\\S" }
45
+ }
46
+ }
47
+ },
48
+ {
49
+ "type": "object",
50
+ "additionalProperties": false,
51
+ "required": ["name", "kind"],
52
+ "properties": {
53
+ "name": { "const": "sessions" },
54
+ "kind": { "const": "sessions" },
55
+ "chatTypes": {
56
+ "type": "array",
57
+ "minItems": 1,
58
+ "items": { "enum": ["channel", "group", "direct"] },
59
+ "default": ["channel", "group"]
60
+ }
61
+ }
62
+ }
63
+ ]
64
+ },
65
+ "default": [
66
+ {
67
+ "name": "memory",
68
+ "kind": "files",
69
+ "paths": ["MEMORY.md", "USER.md", "memory/**/*.md"]
70
+ }
71
+ ]
32
72
  },
33
73
  "analysis": {
34
74
  "type": "object",