@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.
- package/README.md +91 -19
- package/dist/src/config.d.ts +16 -2
- package/dist/src/config.js +78 -15
- package/dist/src/contracts.d.ts +18 -0
- package/dist/src/manager.d.ts +16 -8
- package/dist/src/manager.js +175 -25
- package/dist/src/plugin.js +53 -4
- package/dist/src/runtime.d.ts +2 -1
- package/dist/src/runtime.js +30 -7
- package/dist/src/session-projector.d.ts +21 -0
- package/dist/src/session-projector.js +163 -0
- package/dist/src/session-sync.d.ts +43 -0
- package/dist/src/session-sync.js +302 -0
- package/dist/src/sources.d.ts +7 -1
- package/dist/src/sources.js +32 -2
- package/openclaw.plugin.json +48 -8
- package/package.json +2 -2
|
@@ -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
|
+
}
|
package/dist/src/sources.d.ts
CHANGED
|
@@ -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;
|
package/dist/src/sources.js
CHANGED
|
@@ -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());
|
package/openclaw.plugin.json
CHANGED
|
@@ -1,20 +1,21 @@
|
|
|
1
1
|
{
|
|
2
2
|
"id": "unblock-memory",
|
|
3
3
|
"name": "Unblock Memory",
|
|
4
|
-
"version": "0.1
|
|
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
|
-
"
|
|
16
|
-
"label": "Memory
|
|
17
|
-
"help": "
|
|
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
|
-
"
|
|
29
|
+
"corpora": {
|
|
29
30
|
"type": "array",
|
|
30
|
-
"
|
|
31
|
-
"
|
|
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",
|