@unblocklabs/unblock-memory 0.6.2 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -0
- package/dist/src/config.d.ts +2 -0
- package/dist/src/config.js +4 -1
- package/dist/src/contracts.d.ts +5 -0
- package/dist/src/extraction-chunk.d.ts +29 -0
- package/dist/src/extraction-chunk.js +130 -0
- package/dist/src/extraction-config.d.ts +13 -0
- package/dist/src/extraction-config.js +39 -0
- package/dist/src/extraction-index.d.ts +9 -0
- package/dist/src/extraction-index.js +44 -0
- package/dist/src/extraction-model.d.ts +58 -0
- package/dist/src/extraction-model.js +140 -0
- package/dist/src/extraction-runtime.d.ts +4 -0
- package/dist/src/extraction-runtime.js +121 -0
- package/dist/src/extraction-source.d.ts +26 -0
- package/dist/src/extraction-source.js +66 -0
- package/dist/src/extraction-store.d.ts +52 -0
- package/dist/src/extraction-store.js +138 -0
- package/dist/src/extraction-worker.d.ts +51 -0
- package/dist/src/extraction-worker.js +128 -0
- package/dist/src/manager.d.ts +3 -0
- package/dist/src/manager.js +72 -12
- package/dist/src/plugin.js +6 -1
- package/dist/src/response-judge.d.ts +1 -1
- package/dist/src/response-stages.d.ts +4 -4
- package/dist/src/runtime.d.ts +2 -0
- package/dist/src/runtime.js +3 -0
- package/dist/src/session-projector.d.ts +10 -0
- package/dist/src/session-projector.js +1 -1
- package/docs/configuration.md +9 -0
- package/docs/extraction.md +124 -0
- package/openclaw.plugin.json +15 -1
- package/package.json +3 -1
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { Type } from "typebox";
|
|
3
|
+
import { Value } from "typebox/value";
|
|
4
|
+
import { callGatewayFromCli, errorShape, ErrorCodes } from "openclaw/plugin-sdk/gateway-runtime";
|
|
5
|
+
import { listAgentIds, resolveAgentIdentity } from "openclaw/plugin-sdk/agent-runtime";
|
|
6
|
+
import { resolveAgentDir, resolveStateDir } from "openclaw/plugin-sdk/memory-core-host-engine-foundation";
|
|
7
|
+
import { MEMORY_DATABASE, hasMemoryTable } from "./memory-database.js";
|
|
8
|
+
import { ExtractionStore } from "./extraction-store.js";
|
|
9
|
+
import { extractionSessions } from "./extraction-source.js";
|
|
10
|
+
export function registerExtraction(api, config, runtime) {
|
|
11
|
+
let lifetime = new AbortController();
|
|
12
|
+
let timer, running;
|
|
13
|
+
const options = (cfg, agentId) => {
|
|
14
|
+
if (!listAgentIds(cfg).includes(agentId))
|
|
15
|
+
throw new Error("Unknown extraction agent");
|
|
16
|
+
return { agentId, agentName: resolveAgentIdentity(cfg, agentId)?.name?.trim() || agentId,
|
|
17
|
+
config, runtime: api.runtime, storePath: join(resolveStateDir(), "agents", agentId, "unblock-memory", MEMORY_DATABASE),
|
|
18
|
+
sessions: () => extractionSessions(join(resolveAgentDir(cfg, agentId), "openclaw-agent.sqlite"), agentId, config.extraction.chatTypes) };
|
|
19
|
+
};
|
|
20
|
+
const run = async (cfg, agentId, signal, since, sessionId, scheduled = false) => {
|
|
21
|
+
const opts = options(cfg, agentId);
|
|
22
|
+
// Keep the extraction tokenizer/model modules out of normal plugin startup.
|
|
23
|
+
const { runExtraction } = await import("./extraction-worker.js");
|
|
24
|
+
const result = await runExtraction({ ...opts, signal, since, scheduled, sessionId });
|
|
25
|
+
if (result.status === "completed" && config.extraction.publish && runtime) {
|
|
26
|
+
const { manager, error } = await runtime.getMemorySearchManager({ cfg, agentId });
|
|
27
|
+
if (!manager)
|
|
28
|
+
throw new Error(error ?? "Extraction index unavailable");
|
|
29
|
+
await manager.syncExtracted();
|
|
30
|
+
}
|
|
31
|
+
return result;
|
|
32
|
+
};
|
|
33
|
+
api.registerCli(({ program, config: cfg }) => {
|
|
34
|
+
const root = program.command("memory-extract").description("Optional session fact extraction; no daily Markdown writes");
|
|
35
|
+
root.command("run").option("--agent <id>", "Agent id", "main")
|
|
36
|
+
.option("--since <date>", "Explicit bounded backfill from YYYY-MM-DD UTC; repeat to resume")
|
|
37
|
+
.option("--session <id>", "Restrict this run to one session")
|
|
38
|
+
.action(async (opts) => {
|
|
39
|
+
let since;
|
|
40
|
+
if (opts.since !== undefined) {
|
|
41
|
+
since = Date.parse(opts.since);
|
|
42
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(opts.since) || !Number.isFinite(since) || new Date(since).toISOString().slice(0, 10) !== opts.since) {
|
|
43
|
+
throw new Error("since must be a valid YYYY-MM-DD UTC date");
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
try {
|
|
47
|
+
// Native harnesses live in the Gateway, not the cold CLI metadata loader.
|
|
48
|
+
const result = await callGatewayFromCli("unblock-memory.extract", { timeout: "600000" }, { agentId: opts.agent, ...(since === undefined ? {} : { since }), ...(opts.session ? { sessionId: opts.session } : {}) });
|
|
49
|
+
console.log(JSON.stringify(result, null, 2));
|
|
50
|
+
if (result.status === "unavailable" || (result.status === "completed" && result.failed))
|
|
51
|
+
process.exitCode = 1;
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
console.log(JSON.stringify({ status: "unavailable", reason: "Extraction failed; check the running Gateway, host prerequisites and operator report" }));
|
|
55
|
+
process.exitCode = 1;
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
root.command("report").option("--agent <id>", "Agent id", "main").action((opts) => {
|
|
59
|
+
const { storePath } = options(cfg, opts.agent);
|
|
60
|
+
if (!hasMemoryTable(storePath, "extracted_memories")) {
|
|
61
|
+
console.log(JSON.stringify({ status: "not_run" }));
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
const store = new ExtractionStore(storePath);
|
|
65
|
+
try {
|
|
66
|
+
console.log(JSON.stringify(store.report(), null, 2));
|
|
67
|
+
}
|
|
68
|
+
finally {
|
|
69
|
+
store.close();
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
}, { descriptors: [{ name: "memory-extract", description: "Extract and inspect source-linked session facts", hasSubcommands: true }] });
|
|
73
|
+
if (api.registrationMode === "cli-metadata")
|
|
74
|
+
return;
|
|
75
|
+
const runParams = Type.Object({ agentId: Type.String(), since: Type.Optional(Type.Integer({ minimum: 0 })),
|
|
76
|
+
sessionId: Type.Optional(Type.String()) }, { additionalProperties: false });
|
|
77
|
+
api.registerGatewayMethod("unblock-memory.extract", async ({ params, respond }) => {
|
|
78
|
+
if (!Value.Check(runParams, params)) {
|
|
79
|
+
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "Invalid extraction parameters"));
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
try {
|
|
83
|
+
respond(true, await run(api.config, params.agentId, lifetime.signal, params.since, params.sessionId));
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, "Extraction failed; check host prerequisites and operator report"));
|
|
87
|
+
}
|
|
88
|
+
}, { scope: "operator.admin" });
|
|
89
|
+
if (!config.extraction.enabled)
|
|
90
|
+
return;
|
|
91
|
+
api.on("gateway_stop", async () => { lifetime.abort(); if (timer)
|
|
92
|
+
clearInterval(timer); timer = undefined; await running; });
|
|
93
|
+
if (!config.extraction.intervalMinutes || !config.typesafe.enabled)
|
|
94
|
+
return;
|
|
95
|
+
const tick = () => {
|
|
96
|
+
if (running || lifetime.signal.aborted)
|
|
97
|
+
return;
|
|
98
|
+
running = (async () => {
|
|
99
|
+
for (const agentId of listAgentIds(api.config)) {
|
|
100
|
+
if (lifetime.signal.aborted)
|
|
101
|
+
break;
|
|
102
|
+
try {
|
|
103
|
+
const result = await run(api.config, agentId, lifetime.signal, undefined, undefined, true);
|
|
104
|
+
if (result.status === "unavailable" || (result.status === "completed" && result.failed))
|
|
105
|
+
api.logger.warn("unblock-memory extraction unavailable; inspect memory-extract report");
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
api.logger.warn("unblock-memory extraction unavailable");
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
})().finally(() => { running = undefined; });
|
|
112
|
+
};
|
|
113
|
+
api.on("gateway_start", () => {
|
|
114
|
+
if (timer)
|
|
115
|
+
clearInterval(timer);
|
|
116
|
+
lifetime = new AbortController();
|
|
117
|
+
timer = setInterval(tick, 60_000);
|
|
118
|
+
timer.unref();
|
|
119
|
+
tick();
|
|
120
|
+
});
|
|
121
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { ChatType } from "./config.js";
|
|
2
|
+
import type { ExtractionSession } from "./extraction-store.js";
|
|
3
|
+
import type { ExtractionMessage } from "./extraction-model.js";
|
|
4
|
+
export declare function extractionSessions(databasePath: string, agentId: string, chatTypes: ChatType[]): ExtractionSession[];
|
|
5
|
+
export type ExtractionPage = {
|
|
6
|
+
kind: "page";
|
|
7
|
+
cursor: string;
|
|
8
|
+
hasMore: boolean;
|
|
9
|
+
messages: ExtractionMessage[];
|
|
10
|
+
entryCount: number;
|
|
11
|
+
} | {
|
|
12
|
+
kind: "reset";
|
|
13
|
+
cursor: string;
|
|
14
|
+
} | {
|
|
15
|
+
kind: "missing";
|
|
16
|
+
} | {
|
|
17
|
+
kind: "unavailable";
|
|
18
|
+
};
|
|
19
|
+
export declare function readExtractionPage(agentId: string, agentName: string, session: ExtractionSession, cursor: string | null, read?: (params: {
|
|
20
|
+
agentId: string;
|
|
21
|
+
sessionId: string;
|
|
22
|
+
sessionKey: string;
|
|
23
|
+
cursor?: string;
|
|
24
|
+
maxMessages: number;
|
|
25
|
+
maxBytes: number;
|
|
26
|
+
}) => Promise<unknown>): Promise<ExtractionPage>;
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { DatabaseSync } from "node:sqlite";
|
|
2
|
+
import { Type } from "typebox";
|
|
3
|
+
import { Value } from "typebox/value";
|
|
4
|
+
import { projectMessage } from "./session-projector.js";
|
|
5
|
+
export function extractionSessions(databasePath, agentId, chatTypes) {
|
|
6
|
+
const db = new DatabaseSync(databasePath, { readOnly: true });
|
|
7
|
+
try {
|
|
8
|
+
db.exec("PRAGMA query_only=ON; PRAGMA busy_timeout=5000");
|
|
9
|
+
if (db.prepare("SELECT agent_id FROM schema_meta WHERE meta_key='primary'").get()?.agent_id !== agentId) {
|
|
10
|
+
throw new Error("Wrong extraction source agent");
|
|
11
|
+
}
|
|
12
|
+
return db.prepare(`SELECT w.session_id,w.session_key,w.chat_type,COALESCE(w.channel,c.channel) AS provider,
|
|
13
|
+
COALESCE(w.account_id,c.account_id) AS account_id,COALESCE(c.native_channel_id,c.native_direct_user_id,c.peer_id,w.primary_conversation_id) AS conversation_id,
|
|
14
|
+
COALESCE(w.started_at,w.created_at) AS started_at, COALESCE(w.transcript_updated_at,w.updated_at,w.created_at) AS changed_at FROM session_windows w
|
|
15
|
+
LEFT JOIN conversations c ON c.conversation_id=w.primary_conversation_id
|
|
16
|
+
WHERE w.chat_type IN (${chatTypes.map(() => "?").join(",")}) ORDER BY w.created_at,w.session_id`)
|
|
17
|
+
.all(...chatTypes).map(r => ({ sessionId: String(r.session_id), sessionKey: String(r.session_key),
|
|
18
|
+
chatType: r.chat_type, provider: r.provider === null ? undefined : String(r.provider),
|
|
19
|
+
accountId: r.account_id === null ? undefined : String(r.account_id),
|
|
20
|
+
conversationId: r.conversation_id === null ? undefined : String(r.conversation_id), startedAt: Number(r.started_at), changedAt: Number(r.changed_at) }));
|
|
21
|
+
}
|
|
22
|
+
finally {
|
|
23
|
+
db.close();
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
const pageSchema = Type.Object({ kind: Type.Literal("page"), cursor: Type.String(), hasMore: Type.Boolean(),
|
|
27
|
+
requiredBytes: Type.Optional(Type.Number()), entries: Type.Array(Type.Object({ entryId: Type.String(),
|
|
28
|
+
message: Type.Unknown(), createdAt: Type.Optional(Type.String()) })) });
|
|
29
|
+
export async function readExtractionPage(agentId, agentName, session, cursor, read) {
|
|
30
|
+
// Runtime-only import: the supported host export ships JS without a declaration file.
|
|
31
|
+
const sdkPath = "openclaw/plugin-sdk/session-transcript-runtime";
|
|
32
|
+
const sdk = read ? undefined : await import(sdkPath);
|
|
33
|
+
const readDelta = read ?? sdk?.readSessionTranscriptVisibleMessageDelta;
|
|
34
|
+
if (!readDelta)
|
|
35
|
+
throw new Error("Host visible transcript API unavailable");
|
|
36
|
+
let maxBytes = 1_000_000;
|
|
37
|
+
let result;
|
|
38
|
+
for (;;) {
|
|
39
|
+
result = await readDelta({ agentId, sessionId: session.sessionId,
|
|
40
|
+
sessionKey: session.sessionKey, ...(cursor ? { cursor } : {}), maxMessages: 40, maxBytes });
|
|
41
|
+
if (!Value.Check(pageSchema, result) || result.entries.length || !result.requiredBytes)
|
|
42
|
+
break;
|
|
43
|
+
// The host caps reads at 64 MiB. This is an allocation bound, not a model budget.
|
|
44
|
+
if (!Number.isSafeInteger(result.requiredBytes) || result.requiredBytes <= maxBytes || result.requiredBytes > 64 * 1024 * 1024) {
|
|
45
|
+
throw new Error("Transcript entry exceeds host read capacity");
|
|
46
|
+
}
|
|
47
|
+
maxBytes = result.requiredBytes;
|
|
48
|
+
}
|
|
49
|
+
if (Value.Check(pageSchema, result)) {
|
|
50
|
+
const messages = result.entries.flatMap(entry => {
|
|
51
|
+
const createdAt = Date.parse(entry.createdAt ?? "");
|
|
52
|
+
if (!Number.isFinite(createdAt))
|
|
53
|
+
throw new Error("Extraction message has no source date");
|
|
54
|
+
const projected = projectMessage({ eventJson: JSON.stringify({ type: "message", timestamp: entry.createdAt, message: entry.message }), createdAt }, { ...session, agentName, timezone: "UTC", events: [] });
|
|
55
|
+
return projected?.text ? [{ id: entry.entryId, speaker: projected.speaker, role: projected.role, text: projected.text, timestamp: projected.timestamp }] : [];
|
|
56
|
+
});
|
|
57
|
+
return { kind: "page", cursor: result.cursor, hasMore: result.hasMore, messages, entryCount: result.entries.length };
|
|
58
|
+
}
|
|
59
|
+
const resetSchema = Type.Object({ kind: Type.Literal("reset"), cursor: Type.String() });
|
|
60
|
+
if (Value.Check(resetSchema, result))
|
|
61
|
+
return { kind: "reset", cursor: result.cursor };
|
|
62
|
+
const missingSchema = Type.Object({ kind: Type.Union([Type.Literal("missing"), Type.Literal("unavailable")]) });
|
|
63
|
+
if (Value.Check(missingSchema, result))
|
|
64
|
+
return { kind: result.kind };
|
|
65
|
+
throw new Error("Invalid host transcript page");
|
|
66
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { ExtractionMessage, MemoryProposal } from "./extraction-model.js";
|
|
2
|
+
import type { SessionMetadata } from "./session-projector.js";
|
|
3
|
+
export type ExtractionSession = SessionMetadata & {
|
|
4
|
+
sessionKey: string;
|
|
5
|
+
changedAt?: number;
|
|
6
|
+
};
|
|
7
|
+
type Checkpoint = {
|
|
8
|
+
cursor: string | null;
|
|
9
|
+
context: ExtractionMessage[];
|
|
10
|
+
since: number;
|
|
11
|
+
};
|
|
12
|
+
export type ExtractedRecord = {
|
|
13
|
+
id: string;
|
|
14
|
+
revision: number;
|
|
15
|
+
text: string;
|
|
16
|
+
sessionId: string;
|
|
17
|
+
observedAt: number;
|
|
18
|
+
evidence: MemoryProposal["evidence"];
|
|
19
|
+
metadata: ExtractionSession;
|
|
20
|
+
judgment: unknown;
|
|
21
|
+
};
|
|
22
|
+
export declare class ExtractionStore {
|
|
23
|
+
readonly db: import("node:sqlite").DatabaseSync;
|
|
24
|
+
constructor(path: string);
|
|
25
|
+
close(): void;
|
|
26
|
+
liveSince(): number;
|
|
27
|
+
claim(scheduled: boolean, intervalMs: number): string | undefined;
|
|
28
|
+
renew(owner: string): void;
|
|
29
|
+
release(owner: string): void;
|
|
30
|
+
checkpoint(session: ExtractionSession, since?: number): Checkpoint;
|
|
31
|
+
records(sessionId?: string, paths?: readonly string[]): ExtractedRecord[];
|
|
32
|
+
reset(sessionId: string, cursor: string | null, owner: string): void;
|
|
33
|
+
commit(params: {
|
|
34
|
+
session: ExtractionSession;
|
|
35
|
+
expected: string | null;
|
|
36
|
+
cursor: string;
|
|
37
|
+
context: ExtractionMessage[];
|
|
38
|
+
accepted: {
|
|
39
|
+
proposal: MemoryProposal;
|
|
40
|
+
observedAt: number;
|
|
41
|
+
judgment: unknown;
|
|
42
|
+
}[];
|
|
43
|
+
owner: string;
|
|
44
|
+
version: string;
|
|
45
|
+
}): number;
|
|
46
|
+
error(sessionId: string, code: string): void;
|
|
47
|
+
report(): {
|
|
48
|
+
memories: ExtractedRecord[];
|
|
49
|
+
sessions: Record<string, import("node:sqlite").SQLOutputValue>[];
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
export {};
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { openMemoryDatabase } from "./memory-database.js";
|
|
3
|
+
function memoryDigest(text) {
|
|
4
|
+
return createHash("sha256").update(text.trim().toLowerCase().replace(/\s+/g, " ").replace(/[.!]+$/, "")).digest("hex");
|
|
5
|
+
}
|
|
6
|
+
export class ExtractionStore {
|
|
7
|
+
db;
|
|
8
|
+
constructor(path) {
|
|
9
|
+
this.db = openMemoryDatabase(path);
|
|
10
|
+
try {
|
|
11
|
+
const version = this.db.prepare("SELECT version FROM memory_schema WHERE component='extraction'").get()?.version;
|
|
12
|
+
if (version !== undefined && version !== 1)
|
|
13
|
+
throw new Error("Unsupported extraction schema");
|
|
14
|
+
this.db.exec(`
|
|
15
|
+
CREATE TABLE IF NOT EXISTS extraction_worker (
|
|
16
|
+
id INTEGER PRIMARY KEY CHECK(id=1), owner TEXT, lease_until INTEGER NOT NULL DEFAULT 0,
|
|
17
|
+
next_run INTEGER NOT NULL DEFAULT 0, live_since INTEGER NOT NULL
|
|
18
|
+
) STRICT;
|
|
19
|
+
CREATE TABLE IF NOT EXISTS extraction_sessions (
|
|
20
|
+
session_id TEXT PRIMARY KEY, metadata TEXT NOT NULL CHECK(json_valid(metadata)), cursor TEXT,
|
|
21
|
+
extract_since INTEGER NOT NULL,
|
|
22
|
+
context TEXT NOT NULL DEFAULT '[]' CHECK(json_valid(context)), updated_at INTEGER NOT NULL,
|
|
23
|
+
error TEXT
|
|
24
|
+
) STRICT;
|
|
25
|
+
CREATE TABLE IF NOT EXISTS extracted_memories (
|
|
26
|
+
id TEXT NOT NULL, revision INTEGER NOT NULL, session_id TEXT NOT NULL REFERENCES extraction_sessions(session_id),
|
|
27
|
+
text TEXT NOT NULL, digest TEXT NOT NULL, evidence TEXT NOT NULL CHECK(json_valid(evidence)),
|
|
28
|
+
observed_at INTEGER NOT NULL, status TEXT NOT NULL CHECK(status IN ('active','superseded','withdrawn')),
|
|
29
|
+
judgment TEXT NOT NULL CHECK(json_valid(judgment)), prompt_version TEXT NOT NULL, created_at INTEGER NOT NULL,
|
|
30
|
+
PRIMARY KEY(id,revision)
|
|
31
|
+
) STRICT;
|
|
32
|
+
CREATE UNIQUE INDEX IF NOT EXISTS extracted_current ON extracted_memories(id) WHERE status='active';
|
|
33
|
+
CREATE UNIQUE INDEX IF NOT EXISTS extracted_duplicate ON extracted_memories(session_id,digest) WHERE status='active';
|
|
34
|
+
INSERT OR IGNORE INTO memory_schema VALUES('extraction',1);
|
|
35
|
+
`);
|
|
36
|
+
this.db.prepare("INSERT OR IGNORE INTO extraction_worker(id,live_since) VALUES(1,?)").run(Date.now());
|
|
37
|
+
}
|
|
38
|
+
catch (error) {
|
|
39
|
+
this.db.close();
|
|
40
|
+
throw error;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
close() { this.db.close(); }
|
|
44
|
+
liveSince() { return Number(this.db.prepare("SELECT live_since FROM extraction_worker WHERE id=1").get().live_since); }
|
|
45
|
+
claim(scheduled, intervalMs) {
|
|
46
|
+
const owner = randomUUID(), now = Date.now();
|
|
47
|
+
const result = this.db.prepare(`UPDATE extraction_worker SET owner=?,lease_until=?,next_run=?
|
|
48
|
+
WHERE id=1 AND lease_until<? AND (?=0 OR next_run<=?)`).run(owner, now + 180_000, now + intervalMs, now, Number(scheduled), now);
|
|
49
|
+
return result.changes ? owner : undefined;
|
|
50
|
+
}
|
|
51
|
+
renew(owner) {
|
|
52
|
+
const now = Date.now();
|
|
53
|
+
if (!this.db.prepare("UPDATE extraction_worker SET lease_until=? WHERE id=1 AND owner=? AND lease_until>=?")
|
|
54
|
+
.run(now + 180_000, owner, now).changes)
|
|
55
|
+
throw new Error("Extraction lease lost");
|
|
56
|
+
}
|
|
57
|
+
release(owner) { this.db.prepare("UPDATE extraction_worker SET owner=NULL,lease_until=0 WHERE id=1 AND owner=?").run(owner); }
|
|
58
|
+
checkpoint(session, since) {
|
|
59
|
+
this.db.prepare(`INSERT INTO extraction_sessions(session_id,metadata,updated_at,extract_since) VALUES(?,?,?,?)
|
|
60
|
+
ON CONFLICT(session_id) DO UPDATE SET metadata=excluded.metadata`).run(session.sessionId, JSON.stringify(session), Date.now(), since ?? this.liveSince());
|
|
61
|
+
const row = this.db.prepare("SELECT cursor,context,extract_since FROM extraction_sessions WHERE session_id=?").get(session.sessionId);
|
|
62
|
+
if (since !== undefined && since < Number(row.extract_since)) {
|
|
63
|
+
// A broader explicitly approved backfill replays this session. Existing facts are
|
|
64
|
+
// supplied to Luna and exact duplicates are ignored; history is never deleted.
|
|
65
|
+
this.db.prepare("UPDATE extraction_sessions SET cursor=NULL,context='[]',extract_since=? WHERE session_id=?").run(since, session.sessionId);
|
|
66
|
+
return { cursor: null, context: [], since };
|
|
67
|
+
}
|
|
68
|
+
return { cursor: row.cursor, context: JSON.parse(String(row.context)), since: Number(row.extract_since) };
|
|
69
|
+
}
|
|
70
|
+
records(sessionId, paths) {
|
|
71
|
+
const keys = paths?.filter(path => /^[a-f0-9-]+\/[1-9]\d*$/.test(path)).map(path => path.split("/"));
|
|
72
|
+
if (keys && !keys.length)
|
|
73
|
+
return [];
|
|
74
|
+
return this.db.prepare(`SELECT m.*,s.metadata FROM extracted_memories m JOIN extraction_sessions s USING(session_id)
|
|
75
|
+
WHERE status='active' AND (? IS NULL OR session_id=?)
|
|
76
|
+
${keys ? `AND (m.id,m.revision) IN (VALUES ${keys.map(() => "(?,?)").join(",")})` : ""}
|
|
77
|
+
ORDER BY observed_at,id`).all(sessionId ?? null, sessionId ?? null, ...(keys?.flat() ?? []))
|
|
78
|
+
.map(r => ({ id: String(r.id), revision: Number(r.revision), text: String(r.text), sessionId: String(r.session_id),
|
|
79
|
+
observedAt: Number(r.observed_at), evidence: JSON.parse(String(r.evidence)), metadata: JSON.parse(String(r.metadata)), judgment: JSON.parse(String(r.judgment)) }));
|
|
80
|
+
}
|
|
81
|
+
reset(sessionId, cursor, owner) {
|
|
82
|
+
this.db.exec("BEGIN IMMEDIATE");
|
|
83
|
+
try {
|
|
84
|
+
this.renew(owner);
|
|
85
|
+
this.db.prepare("UPDATE extracted_memories SET status='withdrawn' WHERE session_id=? AND status='active'").run(sessionId);
|
|
86
|
+
this.db.prepare("UPDATE extraction_sessions SET cursor=?,context='[]',error=NULL WHERE session_id=?").run(cursor, sessionId);
|
|
87
|
+
this.db.exec("COMMIT");
|
|
88
|
+
}
|
|
89
|
+
catch (error) {
|
|
90
|
+
this.db.exec("ROLLBACK");
|
|
91
|
+
throw error;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
commit(params) {
|
|
95
|
+
this.db.exec("BEGIN IMMEDIATE");
|
|
96
|
+
let written = 0;
|
|
97
|
+
try {
|
|
98
|
+
this.renew(params.owner);
|
|
99
|
+
const current = this.db.prepare("SELECT cursor FROM extraction_sessions WHERE session_id=?").get(params.session.sessionId);
|
|
100
|
+
if (current?.cursor !== params.expected)
|
|
101
|
+
throw new Error("Extraction checkpoint changed");
|
|
102
|
+
for (const { proposal, observedAt, judgment } of params.accepted) {
|
|
103
|
+
const digest = memoryDigest(proposal.text);
|
|
104
|
+
const duplicate = this.db.prepare("SELECT id FROM extracted_memories WHERE session_id=? AND digest=? AND status='active'")
|
|
105
|
+
.get(params.session.sessionId, digest);
|
|
106
|
+
if (duplicate)
|
|
107
|
+
continue;
|
|
108
|
+
let id = randomUUID(), revision = 1;
|
|
109
|
+
if (proposal.replaces) {
|
|
110
|
+
const previous = this.db.prepare("SELECT revision,observed_at FROM extracted_memories WHERE id=? AND session_id=? AND status='active'")
|
|
111
|
+
.get(proposal.replaces, params.session.sessionId);
|
|
112
|
+
if (!previous || Number(previous.observed_at) > observedAt)
|
|
113
|
+
throw new Error("Invalid or stale memory replacement");
|
|
114
|
+
id = proposal.replaces;
|
|
115
|
+
revision = Number(previous.revision) + 1;
|
|
116
|
+
this.db.prepare("UPDATE extracted_memories SET status='superseded' WHERE id=? AND status='active'").run(id);
|
|
117
|
+
}
|
|
118
|
+
this.db.prepare(`INSERT INTO extracted_memories VALUES(?,?,?,?,?,?,?,'active',?,?,?)`)
|
|
119
|
+
.run(id, revision, params.session.sessionId, proposal.text.trim(), digest, JSON.stringify(proposal.evidence), observedAt, JSON.stringify(judgment), params.version, Date.now());
|
|
120
|
+
written++;
|
|
121
|
+
}
|
|
122
|
+
this.db.prepare("UPDATE extraction_sessions SET cursor=?,context=?,updated_at=?,error=NULL WHERE session_id=?")
|
|
123
|
+
.run(params.cursor, JSON.stringify(params.context), Date.now(), params.session.sessionId);
|
|
124
|
+
this.db.exec("COMMIT");
|
|
125
|
+
return written;
|
|
126
|
+
}
|
|
127
|
+
catch (error) {
|
|
128
|
+
this.db.exec("ROLLBACK");
|
|
129
|
+
throw error;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
error(sessionId, code) {
|
|
133
|
+
this.db.prepare("UPDATE extraction_sessions SET error=?,updated_at=? WHERE session_id=?").run(code, Date.now(), sessionId);
|
|
134
|
+
}
|
|
135
|
+
report() {
|
|
136
|
+
return { memories: this.records(), sessions: this.db.prepare("SELECT session_id,updated_at,error,cursor IS NOT NULL AS checkpointed FROM extraction_sessions").all() };
|
|
137
|
+
}
|
|
138
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type { UnblockMemoryConfig } from "./config.js";
|
|
2
|
+
import { extractWithLuna, validateExtractedMemory } from "./extraction-model.js";
|
|
3
|
+
import { type ExtractionSession } from "./extraction-store.js";
|
|
4
|
+
import { readExtractionPage } from "./extraction-source.js";
|
|
5
|
+
export declare function runExtraction(params: {
|
|
6
|
+
config: UnblockMemoryConfig;
|
|
7
|
+
storePath: string;
|
|
8
|
+
agentId: string;
|
|
9
|
+
runtime: unknown;
|
|
10
|
+
agentName: string;
|
|
11
|
+
sessions: () => ExtractionSession[];
|
|
12
|
+
signal: AbortSignal;
|
|
13
|
+
since?: number;
|
|
14
|
+
sessionId?: string;
|
|
15
|
+
scheduled?: boolean;
|
|
16
|
+
readPage?: typeof readExtractionPage;
|
|
17
|
+
extract?: typeof extractWithLuna;
|
|
18
|
+
validate?: typeof validateExtractedMemory;
|
|
19
|
+
}): Promise<{
|
|
20
|
+
status: "disabled";
|
|
21
|
+
reason?: undefined;
|
|
22
|
+
processed?: undefined;
|
|
23
|
+
accepted?: undefined;
|
|
24
|
+
rejected?: undefined;
|
|
25
|
+
unchanged?: undefined;
|
|
26
|
+
failed?: undefined;
|
|
27
|
+
} | {
|
|
28
|
+
status: "unavailable";
|
|
29
|
+
reason: string;
|
|
30
|
+
processed?: undefined;
|
|
31
|
+
accepted?: undefined;
|
|
32
|
+
rejected?: undefined;
|
|
33
|
+
unchanged?: undefined;
|
|
34
|
+
failed?: undefined;
|
|
35
|
+
} | {
|
|
36
|
+
status: "not_due_or_busy";
|
|
37
|
+
reason?: undefined;
|
|
38
|
+
processed?: undefined;
|
|
39
|
+
accepted?: undefined;
|
|
40
|
+
rejected?: undefined;
|
|
41
|
+
unchanged?: undefined;
|
|
42
|
+
failed?: undefined;
|
|
43
|
+
} | {
|
|
44
|
+
status: "completed";
|
|
45
|
+
processed: number;
|
|
46
|
+
accepted: number;
|
|
47
|
+
rejected: number;
|
|
48
|
+
unchanged: number;
|
|
49
|
+
failed: number;
|
|
50
|
+
reason?: undefined;
|
|
51
|
+
}>;
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { resolveTypeSafeApiKey } from "./typesafe-client.js";
|
|
2
|
+
import { extractWithLuna, validateExtractedMemory, EXTRACTION_VERSION } from "./extraction-model.js";
|
|
3
|
+
import { ExtractionStore } from "./extraction-store.js";
|
|
4
|
+
import { readExtractionPage } from "./extraction-source.js";
|
|
5
|
+
import { extractionHistory, readExtractionChunk } from "./extraction-chunk.js";
|
|
6
|
+
export async function runExtraction(params) {
|
|
7
|
+
const config = params.config.extraction;
|
|
8
|
+
if (!config.enabled)
|
|
9
|
+
return { status: "disabled" };
|
|
10
|
+
const apiKey = await resolveTypeSafeApiKey(params.config.typesafe);
|
|
11
|
+
if (!apiKey)
|
|
12
|
+
return { status: "unavailable", reason: "TypeSafe API key not configured" };
|
|
13
|
+
const store = new ExtractionStore(params.storePath);
|
|
14
|
+
const owner = store.claim(params.scheduled ?? false, config.intervalMinutes * 60_000);
|
|
15
|
+
if (!owner) {
|
|
16
|
+
store.close();
|
|
17
|
+
return { status: "not_due_or_busy" };
|
|
18
|
+
}
|
|
19
|
+
const leaseAbort = new AbortController();
|
|
20
|
+
const signal = AbortSignal.any([params.signal, leaseAbort.signal, AbortSignal.timeout(600_000)]);
|
|
21
|
+
const heartbeat = setInterval(() => { try {
|
|
22
|
+
store.renew(owner);
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
leaseAbort.abort();
|
|
26
|
+
} }, 30_000);
|
|
27
|
+
heartbeat.unref();
|
|
28
|
+
let processed = 0, acceptedCount = 0, rejectedCount = 0, unchanged = 0, failed = 0;
|
|
29
|
+
const readPage = params.readPage ?? readExtractionPage;
|
|
30
|
+
try {
|
|
31
|
+
const sessions = params.sessions();
|
|
32
|
+
const visible = new Set(sessions.map(s => s.sessionId));
|
|
33
|
+
if (params.sessionId && !visible.has(params.sessionId))
|
|
34
|
+
throw new Error("Unknown or unapproved extraction session");
|
|
35
|
+
for (const memory of store.records())
|
|
36
|
+
if (!visible.has(memory.sessionId))
|
|
37
|
+
store.reset(memory.sessionId, null, owner);
|
|
38
|
+
// Oldest last-checked first: bounded runs must not starve later sessions.
|
|
39
|
+
const checked = new Map(store.db.prepare("SELECT session_id,updated_at FROM extraction_sessions").all()
|
|
40
|
+
.map(r => [String(r.session_id), Number(r.updated_at)]));
|
|
41
|
+
sessions.sort((a, b) => (checked.get(a.sessionId) ?? 0) - (checked.get(b.sessionId) ?? 0));
|
|
42
|
+
for (const session of sessions) {
|
|
43
|
+
signal.throwIfAborted();
|
|
44
|
+
if (params.sessionId && params.sessionId !== session.sessionId)
|
|
45
|
+
continue;
|
|
46
|
+
if (!checked.has(session.sessionId) && (session.changedAt ?? Infinity) < (params.since ?? store.liveSince()))
|
|
47
|
+
continue;
|
|
48
|
+
if (processed >= config.maxBatches)
|
|
49
|
+
break;
|
|
50
|
+
const checkpoint = store.checkpoint(session, params.since);
|
|
51
|
+
let stage = "read";
|
|
52
|
+
try {
|
|
53
|
+
const existing = store.records(session.sessionId);
|
|
54
|
+
if (existing.length > 100 || JSON.stringify(existing.map(m => ({ id: m.id, text: m.text }))).length > 40_000) {
|
|
55
|
+
throw new Error("Existing memories exceed review budget");
|
|
56
|
+
}
|
|
57
|
+
const page = await readExtractionChunk({ agentId: params.agentId, agentName: params.agentName, session,
|
|
58
|
+
cursor: checkpoint.cursor, context: extractionHistory(checkpoint.context, config.historyMessages),
|
|
59
|
+
existing: existing.map(m => ({ id: m.id, text: m.text, observedAt: m.observedAt })), signal, readPage });
|
|
60
|
+
if (page.kind === "unavailable") {
|
|
61
|
+
store.error(session.sessionId, "source_unavailable");
|
|
62
|
+
failed++;
|
|
63
|
+
processed++;
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
if (page.kind === "reset" || page.kind === "missing") {
|
|
67
|
+
store.reset(session.sessionId, page.kind === "reset" ? page.cursor : null, owner);
|
|
68
|
+
processed++;
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
if (!page.entryCount) {
|
|
72
|
+
store.db.prepare("UPDATE extraction_sessions SET updated_at=? WHERE session_id=?").run(Date.now(), session.sessionId);
|
|
73
|
+
unchanged++;
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
const since = checkpoint.since;
|
|
77
|
+
const freshIds = new Set(page.newIds);
|
|
78
|
+
const fresh = page.messages.filter(m => freshIds.has(m.id) && m.timestamp >= since);
|
|
79
|
+
const messages = page.messages;
|
|
80
|
+
const newIds = fresh.map(m => m.id);
|
|
81
|
+
stage = "propose";
|
|
82
|
+
const proposals = newIds.length ? await (params.extract ?? extractWithLuna)(params.runtime, params.agentId, messages, newIds, existing.map(m => ({ id: m.id, text: m.text, observedAt: m.observedAt })), signal) : [];
|
|
83
|
+
const accepted = [];
|
|
84
|
+
stage = "validate";
|
|
85
|
+
for (const proposal of proposals) {
|
|
86
|
+
signal.throwIfAborted();
|
|
87
|
+
const judgment = await (params.validate ?? validateExtractedMemory)({ proposal, messages, newIds,
|
|
88
|
+
existing, apiKey, signal, thresholds: config });
|
|
89
|
+
const observedAt = judgment.accepted ? Math.max(...proposal.evidence.map(e => messages.find(m => m.id === e.messageId).timestamp)) : 0;
|
|
90
|
+
const prior = existing.find(m => m.id === proposal.replaces);
|
|
91
|
+
if (judgment.accepted && (!prior || prior.observedAt <= observedAt))
|
|
92
|
+
accepted.push({ proposal: { ...proposal,
|
|
93
|
+
evidence: proposal.evidence.map(e => ({ ...e, messageId: messages.find(m => m.id === e.messageId).sourceMessageId ?? e.messageId })) }, judgment, observedAt });
|
|
94
|
+
else
|
|
95
|
+
rejectedCount++;
|
|
96
|
+
}
|
|
97
|
+
// A branch rewrite during inference invalidates its proposals. Appends are safe and processed next time.
|
|
98
|
+
const check = await readPage(params.agentId, params.agentName, session, page.fence);
|
|
99
|
+
if (check.kind === "reset" || check.kind === "missing") {
|
|
100
|
+
store.reset(session.sessionId, check.kind === "reset" ? check.cursor : null, owner);
|
|
101
|
+
processed++;
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
if (check.kind === "unavailable")
|
|
105
|
+
throw new Error("Source changed during extraction");
|
|
106
|
+
signal.throwIfAborted();
|
|
107
|
+
const context = extractionHistory(messages, config.historyMessages);
|
|
108
|
+
stage = "commit";
|
|
109
|
+
const written = store.commit({ session, expected: checkpoint.cursor, cursor: page.cursor,
|
|
110
|
+
context, accepted, owner, version: EXTRACTION_VERSION });
|
|
111
|
+
processed++;
|
|
112
|
+
acceptedCount += written;
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
signal.throwIfAborted();
|
|
116
|
+
failed++;
|
|
117
|
+
processed++;
|
|
118
|
+
store.error(session.sessionId, `${stage}_failed`);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return { status: "completed", processed, accepted: acceptedCount, rejected: rejectedCount, unchanged, failed };
|
|
122
|
+
}
|
|
123
|
+
finally {
|
|
124
|
+
clearInterval(heartbeat);
|
|
125
|
+
store.release(owner);
|
|
126
|
+
store.close();
|
|
127
|
+
}
|
|
128
|
+
}
|
package/dist/src/manager.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { ExtractionConfig } from "./extraction-config.js";
|
|
1
2
|
import type { QMDStore } from "@unblocklabs/qmd";
|
|
2
3
|
import { type AnalysisRunner, type MemoryAnalysisSummary, type MemoryClusterDetail, type MemoryClusterList, type MemoryClusterSort, type MemoryReclusterOptions } from "./analysis.js";
|
|
3
4
|
import type { CorpusMemorySearchResult, CorpusSearchOptions, MemoryEmbeddingProbeResult, MemoryProviderStatus, MemoryReadResult, MemoryRequestContext, MemorySearchManagerContract, MemorySyncParams } from "./contracts.js";
|
|
@@ -77,6 +78,7 @@ export declare class QmdMemoryManager implements MemorySearchManagerContract {
|
|
|
77
78
|
constructor(params: {
|
|
78
79
|
dbPath: string;
|
|
79
80
|
curationPath?: string;
|
|
81
|
+
extraction?: ExtractionConfig;
|
|
80
82
|
workspaceDir: string;
|
|
81
83
|
sources: readonly ResolvedSource[];
|
|
82
84
|
storeFactory?: () => Promise<ManagerStore>;
|
|
@@ -86,6 +88,7 @@ export declare class QmdMemoryManager implements MemorySearchManagerContract {
|
|
|
86
88
|
sessions?: ManagerSessionConfig;
|
|
87
89
|
});
|
|
88
90
|
start(): Promise<void>;
|
|
91
|
+
syncExtracted(): Promise<void>;
|
|
89
92
|
sync(params?: MemorySyncParams): Promise<void>;
|
|
90
93
|
syncSessions(force?: boolean, onPhase?: (phase: "projecting" | "indexing") => void): Promise<SessionSyncResult>;
|
|
91
94
|
recluster(options?: MemoryReclusterOptions, signal?: AbortSignal): Promise<MemoryAnalysisSummary>;
|