@unblocklabs/unblock-memory 0.7.0 → 0.7.2
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/dist/src/analysis.js +11 -12
- package/dist/src/contracts.d.ts +1 -1
- package/dist/src/extraction-chunk.d.ts +1 -0
- package/dist/src/extraction-chunk.js +1 -0
- package/dist/src/extraction-model.js +5 -4
- package/dist/src/extraction-runtime.js +2 -7
- package/dist/src/extraction-source.js +6 -2
- package/dist/src/extraction-store.d.ts +3 -0
- package/dist/src/extraction-store.js +14 -10
- package/dist/src/extraction-worker.js +31 -15
- package/dist/src/manager.d.ts +1 -1
- package/dist/src/manager.js +63 -79
- package/dist/src/memory-whisperer.js +6 -10
- package/dist/src/people-primer.js +6 -8
- package/dist/src/people-store.js +2 -0
- package/dist/src/people-tools.js +2 -6
- package/dist/src/plugin.js +6 -14
- package/dist/src/query-contract.js +18 -10
- package/dist/src/response-audit.js +3 -3
- package/dist/src/response-judge.js +1 -2
- package/dist/src/response-store.js +2 -1
- package/dist/src/retrieval-telemetry.d.ts +1 -1
- package/dist/src/review-tools.d.ts +24 -0
- package/dist/src/review-tools.js +22 -26
- package/dist/src/session-projector.js +8 -4
- package/dist/src/skill-whisperer.js +44 -15
- package/dist/src/training-candidates.d.ts +12 -13
- package/dist/src/training-candidates.js +11 -81
- package/dist/src/training-gate.js +2 -2
- package/dist/src/training-queries.js +5 -2
- package/dist/src/training-retrieval.d.ts +15 -2
- package/dist/src/training-retrieval.js +63 -39
- package/dist/src/typesafe-client.d.ts +5 -0
- package/dist/src/typesafe-client.js +4 -0
- package/dist/src/typesafe-review.js +4 -6
- package/dist/src/typesafe.d.ts +1 -9
- package/dist/src/typesafe.js +4 -10
- package/docs/configuration.md +3 -2
- package/docs/retrieval.md +15 -1
- package/openclaw.plugin.json +2 -2
- package/package.json +2 -2
package/dist/src/analysis.js
CHANGED
|
@@ -200,22 +200,21 @@ export function latestAnalysisCollections(db) {
|
|
|
200
200
|
return undefined;
|
|
201
201
|
}
|
|
202
202
|
}
|
|
203
|
-
function count(db, sql, runId) {
|
|
204
|
-
return db.prepare(sql).get(runId)?.count ?? 0;
|
|
205
|
-
}
|
|
206
203
|
function analysisSummary(db, run) {
|
|
207
|
-
const clusters
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
const
|
|
212
|
-
|
|
213
|
-
|
|
204
|
+
const { clusters, expectedNonNoise } = db.prepare(`
|
|
205
|
+
SELECT COUNT(*) AS clusters, COALESCE(SUM(size), 0) AS expectedNonNoise
|
|
206
|
+
FROM memory_analysis_clusters WHERE run_id = ?
|
|
207
|
+
`).get(run.id);
|
|
208
|
+
const { members, nonNoise, noise, unassigned } = db.prepare(`
|
|
209
|
+
SELECT COUNT(*) AS members,
|
|
210
|
+
COALESCE(SUM(m.cluster_id <> -1), 0) AS nonNoise,
|
|
211
|
+
COALESCE(SUM(m.cluster_id = -1), 0) AS noise,
|
|
212
|
+
COALESCE(SUM(m.cluster_id <> -1 AND c.cluster_id IS NULL), 0) AS unassigned
|
|
214
213
|
FROM memory_analysis_memberships m
|
|
215
214
|
LEFT JOIN memory_analysis_clusters c
|
|
216
215
|
ON c.run_id = m.run_id AND c.cluster_id = m.cluster_id
|
|
217
|
-
WHERE m.run_id = ?
|
|
218
|
-
|
|
216
|
+
WHERE m.run_id = ?
|
|
217
|
+
`).get(run.id);
|
|
219
218
|
if (nonNoise !== expectedNonNoise || members !== nonNoise + noise || unassigned > 0)
|
|
220
219
|
return undefined;
|
|
221
220
|
return {
|
package/dist/src/contracts.d.ts
CHANGED
|
@@ -29,7 +29,7 @@ export type SessionSearchFilter = {
|
|
|
29
29
|
conversationId?: string;
|
|
30
30
|
};
|
|
31
31
|
export type MemoryRequestContext = Pick<OpenClawPluginToolContext, "sessionKey" | "sessionId" | "messageChannel" | "agentAccountId" | "nativeChannelId" | "deliveryContext">;
|
|
32
|
-
export type CorpusSearchOptions = NonNullable<Parameters<MemorySearchManagerContract["search"]>[1]> & {
|
|
32
|
+
export type CorpusSearchOptions = Omit<NonNullable<Parameters<MemorySearchManagerContract["search"]>[1]>, "lexicalOnly"> & {
|
|
33
33
|
corpora?: readonly string[];
|
|
34
34
|
/** Internal vector-hint budget; oversized matched chunks are omitted, never sliced. */
|
|
35
35
|
maxSnippetChars?: number;
|
|
@@ -98,6 +98,7 @@ export async function readExtractionChunk(params) {
|
|
|
98
98
|
const finish = (index, offset = 0) => {
|
|
99
99
|
const next = page.messages[index];
|
|
100
100
|
return { kind: "page", messages, newIds, entryCount, fence: startingFence ?? page.cursor,
|
|
101
|
+
exhausted: !next && !page.hasMore,
|
|
101
102
|
cursor: next ? PREFIX + JSON.stringify({ before, fence: page.cursor, index, offset, id: next.id, hash: digest(next) }) : page.cursor };
|
|
102
103
|
};
|
|
103
104
|
for (let i = resume?.index ?? 0; i < page.messages.length; i++) {
|
|
@@ -2,7 +2,7 @@ import { Type } from "typebox";
|
|
|
2
2
|
import { Value } from "typebox/value";
|
|
3
3
|
import { Tiktoken } from "js-tiktoken/lite";
|
|
4
4
|
import o200kBase from "js-tiktoken/ranks/o200k_base";
|
|
5
|
-
import { requestTypeSafe } from "./typesafe-client.js";
|
|
5
|
+
import { requestTypeSafe, TYPESAFE_NOUL_SCHEMA as noul } from "./typesafe-client.js";
|
|
6
6
|
export const EXTRACTION_VERSION = "lasting-facts-v11";
|
|
7
7
|
// Jev allows 32k for state plus its longest question. Leave room for validation
|
|
8
8
|
// instructions, proposal evidence and tokenizer differences, not just Luna input.
|
|
@@ -34,6 +34,7 @@ Only extract facts supported by at least one NEW message. Context-only messages
|
|
|
34
34
|
If a fact is already in existing memories, omit it. Source dates control chronology, not processing order: during backfill, never replace a newer observed fact with an older one, or emit the older value as an undated duplicate. If new evidence clearly corrects/changes an existing memory, set replaces to its exact id and emit the concise replacement. Otherwise replaces is null. Never replace unrelated facts. If a previous claim is explicitly withdrawn without a replacement, retain a concise qualified correction rather than inventing a value.
|
|
35
35
|
Cite exact contiguous substrings copied from the supplied messages using their ids. Preserve Markdown markers, mentions, whitespace and punctuation inside each quote. Never join separated spans into one quote or clean up its formatting. Prefer short exact spans; use separate evidence entries when needed. Include enough evidence to resolve corrections and speaker attribution. Output an empty memories array when nothing qualifies. Do not fill a quota.`;
|
|
36
36
|
let tokenizer;
|
|
37
|
+
let promptTokens;
|
|
37
38
|
// o200k_base is an explicit budgeting proxy, not a claim about the host's private tokenizer.
|
|
38
39
|
function extractionTokens(text) {
|
|
39
40
|
return (tokenizer ??= new Tiktoken(o200kBase)).encode(text, [], []).length;
|
|
@@ -45,7 +46,7 @@ function extractionInput(messages, newIds, existing) {
|
|
|
45
46
|
return JSON.stringify({ messages: messages.map(datedMessage), newMessageIds: newIds, existing, schema: proposalSchema });
|
|
46
47
|
}
|
|
47
48
|
export function extractionOverhead(existing) {
|
|
48
|
-
return extractionTokens(EXTRACTION_PROMPT) + extractionTokens(extractionInput([], [], existing)) + 512;
|
|
49
|
+
return (promptTokens ??= extractionTokens(EXTRACTION_PROMPT)) + extractionTokens(extractionInput([], [], existing)) + 512;
|
|
49
50
|
}
|
|
50
51
|
export function extractionMessageTokens(message) {
|
|
51
52
|
return extractionTokens(JSON.stringify(datedMessage(message))) + extractionTokens(JSON.stringify(message.id)) + 8;
|
|
@@ -84,8 +85,8 @@ export async function extractWithLuna(runtime, agentId, messages, newIds, existi
|
|
|
84
85
|
const judgments = Type.Object({ answers: Type.Object({
|
|
85
86
|
supported: Type.Object({ type: Type.Literal("choice"), choice: Type.Union([Type.Literal("supported"), Type.Literal("contradicted"), Type.Literal("unsupported")]),
|
|
86
87
|
probabilities: Type.Object({ supported: Type.Number({ minimum: 0, maximum: 1 }), contradicted: Type.Number({ minimum: 0, maximum: 1 }), unsupported: Type.Number({ minimum: 0, maximum: 1 }) }) }),
|
|
87
|
-
useful:
|
|
88
|
-
replacement: Type.Optional(
|
|
88
|
+
useful: noul,
|
|
89
|
+
replacement: Type.Optional(noul),
|
|
89
90
|
}) });
|
|
90
91
|
export async function validateExtractedMemory(params) {
|
|
91
92
|
const { proposal, messages, existing } = params;
|
|
@@ -7,6 +7,7 @@ import { resolveAgentDir, resolveStateDir } from "openclaw/plugin-sdk/memory-cor
|
|
|
7
7
|
import { MEMORY_DATABASE, hasMemoryTable } from "./memory-database.js";
|
|
8
8
|
import { ExtractionStore } from "./extraction-store.js";
|
|
9
9
|
import { extractionSessions } from "./extraction-source.js";
|
|
10
|
+
import { dateOption } from "./date-option.js";
|
|
10
11
|
export function registerExtraction(api, config, runtime) {
|
|
11
12
|
let lifetime = new AbortController();
|
|
12
13
|
let timer, running;
|
|
@@ -36,13 +37,7 @@ export function registerExtraction(api, config, runtime) {
|
|
|
36
37
|
.option("--since <date>", "Explicit bounded backfill from YYYY-MM-DD UTC; repeat to resume")
|
|
37
38
|
.option("--session <id>", "Restrict this run to one session")
|
|
38
39
|
.action(async (opts) => {
|
|
39
|
-
|
|
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
|
-
}
|
|
40
|
+
const since = opts.since === undefined ? undefined : dateOption(opts.since, 0);
|
|
46
41
|
try {
|
|
47
42
|
// Native harnesses live in the Gateway, not the cold CLI metadata loader.
|
|
48
43
|
const result = await callGatewayFromCli("unblock-memory.extract", { timeout: "600000" }, { agentId: opts.agent, ...(since === undefined ? {} : { since }), ...(opts.session ? { sessionId: opts.session } : {}) });
|
|
@@ -11,13 +11,17 @@ export function extractionSessions(databasePath, agentId, chatTypes) {
|
|
|
11
11
|
}
|
|
12
12
|
return db.prepare(`SELECT w.session_id,w.session_key,w.chat_type,COALESCE(w.channel,c.channel) AS provider,
|
|
13
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
|
|
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,
|
|
15
|
+
r.generation,(SELECT MAX(seq) FROM transcript_events WHERE session_id=w.session_id) AS tail FROM session_windows w
|
|
15
16
|
LEFT JOIN conversations c ON c.conversation_id=w.primary_conversation_id
|
|
17
|
+
LEFT JOIN transcript_rewrite_watermarks r ON r.session_id=w.session_id
|
|
16
18
|
WHERE w.chat_type IN (${chatTypes.map(() => "?").join(",")}) ORDER BY w.created_at,w.session_id`)
|
|
17
19
|
.all(...chatTypes).map(r => ({ sessionId: String(r.session_id), sessionKey: String(r.session_key),
|
|
18
20
|
chatType: r.chat_type, provider: r.provider === null ? undefined : String(r.provider),
|
|
19
21
|
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)
|
|
22
|
+
conversationId: r.conversation_id === null ? undefined : String(r.conversation_id), startedAt: Number(r.started_at), changedAt: Number(r.changed_at),
|
|
23
|
+
// The host uses this append-stable watermark for transcript cache validation.
|
|
24
|
+
sourceRevision: r.generation === null ? undefined : JSON.stringify([r.generation, r.tail]) }));
|
|
21
25
|
}
|
|
22
26
|
finally {
|
|
23
27
|
db.close();
|
|
@@ -3,11 +3,13 @@ import type { SessionMetadata } from "./session-projector.js";
|
|
|
3
3
|
export type ExtractionSession = SessionMetadata & {
|
|
4
4
|
sessionKey: string;
|
|
5
5
|
changedAt?: number;
|
|
6
|
+
sourceRevision?: string;
|
|
6
7
|
};
|
|
7
8
|
type Checkpoint = {
|
|
8
9
|
cursor: string | null;
|
|
9
10
|
context: ExtractionMessage[];
|
|
10
11
|
since: number;
|
|
12
|
+
completeRevision: string | null;
|
|
11
13
|
};
|
|
12
14
|
export type ExtractedRecord = {
|
|
13
15
|
id: string;
|
|
@@ -42,6 +44,7 @@ export declare class ExtractionStore {
|
|
|
42
44
|
}[];
|
|
43
45
|
owner: string;
|
|
44
46
|
version: string;
|
|
47
|
+
completeRevision?: string;
|
|
45
48
|
}): number;
|
|
46
49
|
error(sessionId: string, code: string): void;
|
|
47
50
|
report(): {
|
|
@@ -20,7 +20,7 @@ export class ExtractionStore {
|
|
|
20
20
|
session_id TEXT PRIMARY KEY, metadata TEXT NOT NULL CHECK(json_valid(metadata)), cursor TEXT,
|
|
21
21
|
extract_since INTEGER NOT NULL,
|
|
22
22
|
context TEXT NOT NULL DEFAULT '[]' CHECK(json_valid(context)), updated_at INTEGER NOT NULL,
|
|
23
|
-
error TEXT
|
|
23
|
+
error TEXT, complete_revision TEXT
|
|
24
24
|
) STRICT;
|
|
25
25
|
CREATE TABLE IF NOT EXISTS extracted_memories (
|
|
26
26
|
id TEXT NOT NULL, revision INTEGER NOT NULL, session_id TEXT NOT NULL REFERENCES extraction_sessions(session_id),
|
|
@@ -33,6 +33,9 @@ export class ExtractionStore {
|
|
|
33
33
|
CREATE UNIQUE INDEX IF NOT EXISTS extracted_duplicate ON extracted_memories(session_id,digest) WHERE status='active';
|
|
34
34
|
INSERT OR IGNORE INTO memory_schema VALUES('extraction',1);
|
|
35
35
|
`);
|
|
36
|
+
if (!this.db.prepare("SELECT 1 FROM pragma_table_info('extraction_sessions') WHERE name='complete_revision'").get()) {
|
|
37
|
+
this.db.exec("ALTER TABLE extraction_sessions ADD COLUMN complete_revision TEXT");
|
|
38
|
+
}
|
|
36
39
|
this.db.prepare("INSERT OR IGNORE INTO extraction_worker(id,live_since) VALUES(1,?)").run(Date.now());
|
|
37
40
|
}
|
|
38
41
|
catch (error) {
|
|
@@ -58,23 +61,24 @@ export class ExtractionStore {
|
|
|
58
61
|
checkpoint(session, since) {
|
|
59
62
|
this.db.prepare(`INSERT INTO extraction_sessions(session_id,metadata,updated_at,extract_since) VALUES(?,?,?,?)
|
|
60
63
|
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);
|
|
64
|
+
const row = this.db.prepare("SELECT cursor,context,extract_since,complete_revision FROM extraction_sessions WHERE session_id=?").get(session.sessionId);
|
|
62
65
|
if (since !== undefined && since < Number(row.extract_since)) {
|
|
63
66
|
// A broader explicitly approved backfill replays this session. Existing facts are
|
|
64
67
|
// 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 };
|
|
68
|
+
this.db.prepare("UPDATE extraction_sessions SET cursor=NULL,context='[]',complete_revision=NULL,extract_since=? WHERE session_id=?").run(since, session.sessionId);
|
|
69
|
+
return { cursor: null, context: [], since, completeRevision: null };
|
|
67
70
|
}
|
|
68
|
-
return { cursor: row.cursor, context: JSON.parse(String(row.context)), since: Number(row.extract_since)
|
|
71
|
+
return { cursor: row.cursor, context: JSON.parse(String(row.context)), since: Number(row.extract_since),
|
|
72
|
+
completeRevision: row.complete_revision };
|
|
69
73
|
}
|
|
70
74
|
records(sessionId, paths) {
|
|
71
75
|
const keys = paths?.filter(path => /^[a-f0-9-]+\/[1-9]\d*$/.test(path)).map(path => path.split("/"));
|
|
72
76
|
if (keys && !keys.length)
|
|
73
77
|
return [];
|
|
74
78
|
return this.db.prepare(`SELECT m.*,s.metadata FROM extracted_memories m JOIN extraction_sessions s USING(session_id)
|
|
75
|
-
WHERE status='active'
|
|
79
|
+
WHERE status='active' ${sessionId === undefined ? "" : "AND session_id=?"}
|
|
76
80
|
${keys ? `AND (m.id,m.revision) IN (VALUES ${keys.map(() => "(?,?)").join(",")})` : ""}
|
|
77
|
-
ORDER BY observed_at,id`).all(sessionId
|
|
81
|
+
ORDER BY observed_at,id`).all(...(sessionId === undefined ? [] : [sessionId]), ...(keys?.flat() ?? []))
|
|
78
82
|
.map(r => ({ id: String(r.id), revision: Number(r.revision), text: String(r.text), sessionId: String(r.session_id),
|
|
79
83
|
observedAt: Number(r.observed_at), evidence: JSON.parse(String(r.evidence)), metadata: JSON.parse(String(r.metadata)), judgment: JSON.parse(String(r.judgment)) }));
|
|
80
84
|
}
|
|
@@ -83,7 +87,7 @@ export class ExtractionStore {
|
|
|
83
87
|
try {
|
|
84
88
|
this.renew(owner);
|
|
85
89
|
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);
|
|
90
|
+
this.db.prepare("UPDATE extraction_sessions SET cursor=?,context='[]',complete_revision=NULL,error=NULL WHERE session_id=?").run(cursor, sessionId);
|
|
87
91
|
this.db.exec("COMMIT");
|
|
88
92
|
}
|
|
89
93
|
catch (error) {
|
|
@@ -119,8 +123,8 @@ export class ExtractionStore {
|
|
|
119
123
|
.run(id, revision, params.session.sessionId, proposal.text.trim(), digest, JSON.stringify(proposal.evidence), observedAt, JSON.stringify(judgment), params.version, Date.now());
|
|
120
124
|
written++;
|
|
121
125
|
}
|
|
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);
|
|
126
|
+
this.db.prepare("UPDATE extraction_sessions SET cursor=?,context=?,updated_at=?,complete_revision=?,error=NULL WHERE session_id=?")
|
|
127
|
+
.run(params.cursor, JSON.stringify(params.context), Date.now(), params.completeRevision ?? null, params.session.sessionId);
|
|
124
128
|
this.db.exec("COMMIT");
|
|
125
129
|
return written;
|
|
126
130
|
}
|
|
@@ -32,9 +32,10 @@ export async function runExtraction(params) {
|
|
|
32
32
|
const visible = new Set(sessions.map(s => s.sessionId));
|
|
33
33
|
if (params.sessionId && !visible.has(params.sessionId))
|
|
34
34
|
throw new Error("Unknown or unapproved extraction session");
|
|
35
|
-
for (const
|
|
36
|
-
if (!visible.has(
|
|
37
|
-
store.reset(
|
|
35
|
+
for (const { session_id } of store.db.prepare("SELECT DISTINCT session_id FROM extracted_memories WHERE status='active'").all()) {
|
|
36
|
+
if (!visible.has(String(session_id)))
|
|
37
|
+
store.reset(String(session_id), null, owner);
|
|
38
|
+
}
|
|
38
39
|
// Oldest last-checked first: bounded runs must not starve later sessions.
|
|
39
40
|
const checked = new Map(store.db.prepare("SELECT session_id,updated_at FROM extraction_sessions").all()
|
|
40
41
|
.map(r => [String(r.session_id), Number(r.updated_at)]));
|
|
@@ -48,6 +49,10 @@ export async function runExtraction(params) {
|
|
|
48
49
|
if (processed >= config.maxBatches)
|
|
49
50
|
break;
|
|
50
51
|
const checkpoint = store.checkpoint(session, params.since);
|
|
52
|
+
if (session.sourceRevision !== undefined && checkpoint.completeRevision === session.sourceRevision) {
|
|
53
|
+
unchanged++;
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
51
56
|
let stage = "read";
|
|
52
57
|
try {
|
|
53
58
|
const existing = store.records(session.sessionId);
|
|
@@ -69,7 +74,8 @@ export async function runExtraction(params) {
|
|
|
69
74
|
continue;
|
|
70
75
|
}
|
|
71
76
|
if (!page.entryCount) {
|
|
72
|
-
store.db.prepare("UPDATE extraction_sessions SET updated_at=? WHERE session_id=?")
|
|
77
|
+
store.db.prepare("UPDATE extraction_sessions SET updated_at=?,complete_revision=? WHERE session_id=?")
|
|
78
|
+
.run(Date.now(), page.exhausted ? session.sourceRevision ?? null : null, session.sessionId);
|
|
73
79
|
unchanged++;
|
|
74
80
|
continue;
|
|
75
81
|
}
|
|
@@ -82,17 +88,26 @@ export async function runExtraction(params) {
|
|
|
82
88
|
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
89
|
const accepted = [];
|
|
84
90
|
stage = "validate";
|
|
85
|
-
for (
|
|
91
|
+
for (let start = 0; start < proposals.length; start += 4) {
|
|
86
92
|
signal.throwIfAborted();
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
const
|
|
91
|
-
if (
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
93
|
+
// Isolated judgments overlap, but failed chunks drain before their lease is released.
|
|
94
|
+
const judgments = await Promise.allSettled(proposals.slice(start, start + 4).map(async (proposal) => ({ proposal,
|
|
95
|
+
judgment: await (params.validate ?? validateExtractedMemory)({ proposal, messages, newIds, existing, apiKey, signal, thresholds: config }) })));
|
|
96
|
+
const failure = judgments.find(result => result.status === "rejected");
|
|
97
|
+
if (failure)
|
|
98
|
+
throw failure.reason;
|
|
99
|
+
for (const result of judgments) {
|
|
100
|
+
if (result.status !== "fulfilled")
|
|
101
|
+
continue;
|
|
102
|
+
const { proposal, judgment } = result.value;
|
|
103
|
+
const observedAt = judgment.accepted ? Math.max(...proposal.evidence.map(e => messages.find(m => m.id === e.messageId).timestamp)) : 0;
|
|
104
|
+
const prior = existing.find(m => m.id === proposal.replaces);
|
|
105
|
+
if (judgment.accepted && (!prior || prior.observedAt <= observedAt))
|
|
106
|
+
accepted.push({ proposal: { ...proposal,
|
|
107
|
+
evidence: proposal.evidence.map(e => ({ ...e, messageId: messages.find(m => m.id === e.messageId).sourceMessageId ?? e.messageId })) }, judgment, observedAt });
|
|
108
|
+
else
|
|
109
|
+
rejectedCount++;
|
|
110
|
+
}
|
|
96
111
|
}
|
|
97
112
|
// A branch rewrite during inference invalidates its proposals. Appends are safe and processed next time.
|
|
98
113
|
const check = await readPage(params.agentId, params.agentName, session, page.fence);
|
|
@@ -107,7 +122,8 @@ export async function runExtraction(params) {
|
|
|
107
122
|
const context = extractionHistory(messages, config.historyMessages);
|
|
108
123
|
stage = "commit";
|
|
109
124
|
const written = store.commit({ session, expected: checkpoint.cursor, cursor: page.cursor,
|
|
110
|
-
context, accepted, owner, version: EXTRACTION_VERSION
|
|
125
|
+
context, accepted, owner, version: EXTRACTION_VERSION,
|
|
126
|
+
completeRevision: page.exhausted ? session.sourceRevision : undefined });
|
|
111
127
|
processed++;
|
|
112
128
|
acceptedCount += written;
|
|
113
129
|
}
|
package/dist/src/manager.d.ts
CHANGED
|
@@ -12,7 +12,7 @@ import { reviewIndexedClaim } from "./evidence-review.js";
|
|
|
12
12
|
import { reviewClusterIngestion } from "./cluster-review.js";
|
|
13
13
|
import type { QueryPair } from "./query-contract.js";
|
|
14
14
|
export { expandSessionSearchHit } from "./memory-passage.js";
|
|
15
|
-
export type ManagerStore = Pick<QMDStore, "update" | "embed" | "getStatus" | "listCollections" | "
|
|
15
|
+
export type ManagerStore = Pick<QMDStore, "update" | "embed" | "getStatus" | "listCollections" | "vsearch" | "get" | "getDocumentBody" | "close">;
|
|
16
16
|
export type ManagerSessionConfig = {
|
|
17
17
|
agentId: string;
|
|
18
18
|
agentName: string;
|
package/dist/src/manager.js
CHANGED
|
@@ -74,16 +74,6 @@ function frontmatterValue(body, key) {
|
|
|
74
74
|
.find((value) => value !== undefined);
|
|
75
75
|
return raw?.replace(/^(?:"(.*)"|'(.*)')$/u, "$1$2").trim();
|
|
76
76
|
}
|
|
77
|
-
function embeddingText(name, description, model) {
|
|
78
|
-
return model.toLowerCase().includes("qwen3-embedding")
|
|
79
|
-
? `${name}\n${description}`
|
|
80
|
-
: `title: ${name} | text: ${description}`;
|
|
81
|
-
}
|
|
82
|
-
function queryText(query, model) {
|
|
83
|
-
return model.toLowerCase().includes("qwen3-embedding")
|
|
84
|
-
? `Instruct: Retrieve relevant documents for the given query\nQuery: ${query}`
|
|
85
|
-
: `task: search result | query: ${query}`;
|
|
86
|
-
}
|
|
87
77
|
function cosineSimilarity(left, right) {
|
|
88
78
|
if (left.length !== right.length || left.length === 0)
|
|
89
79
|
return 0;
|
|
@@ -192,22 +182,6 @@ function lineSpan(body, position, text) {
|
|
|
192
182
|
const endLine = startLine + Math.max(0, text.split("\n").length - 1);
|
|
193
183
|
return { startLine, endLine };
|
|
194
184
|
}
|
|
195
|
-
function lexicalResult(hit, corpus, session) {
|
|
196
|
-
const body = hit.body ?? hit.title;
|
|
197
|
-
const endLine = Math.max(1, body.split("\n").length);
|
|
198
|
-
return {
|
|
199
|
-
path: hit.filepath,
|
|
200
|
-
startLine: 1,
|
|
201
|
-
endLine,
|
|
202
|
-
score: hit.score,
|
|
203
|
-
textScore: hit.score,
|
|
204
|
-
snippet: body,
|
|
205
|
-
source: "memory",
|
|
206
|
-
corpus,
|
|
207
|
-
...(session ? { session } : {}),
|
|
208
|
-
citation: `${hit.displayPath}#L1-L${endLine}`,
|
|
209
|
-
};
|
|
210
|
-
}
|
|
211
185
|
function sessionAllowedPaths(metadataByPath, collection, filter) {
|
|
212
186
|
const startedFrom = filter.startedFrom === undefined ? undefined : Date.parse(filter.startedFrom);
|
|
213
187
|
const startedTo = filter.startedTo === undefined ? undefined : Date.parse(filter.startedTo);
|
|
@@ -251,6 +225,7 @@ export class QmdMemoryManager {
|
|
|
251
225
|
#watcher;
|
|
252
226
|
#watchReady;
|
|
253
227
|
#watchTimer;
|
|
228
|
+
#watchCollections = new Set();
|
|
254
229
|
#watchError;
|
|
255
230
|
#closed = false;
|
|
256
231
|
#files = 0;
|
|
@@ -360,12 +335,17 @@ export class QmdMemoryManager {
|
|
|
360
335
|
this.#skillIndex = undefined;
|
|
361
336
|
if (!matchingSources.some((source) => source.kind !== "skills"))
|
|
362
337
|
return;
|
|
338
|
+
for (const source of matchingSources)
|
|
339
|
+
if (source.kind !== "skills")
|
|
340
|
+
this.#watchCollections.add(source.collection);
|
|
363
341
|
this.#dirty = true;
|
|
364
342
|
if (this.#watchTimer)
|
|
365
343
|
clearTimeout(this.#watchTimer);
|
|
366
344
|
this.#watchTimer = setTimeout(() => {
|
|
367
345
|
this.#watchTimer = undefined;
|
|
368
|
-
|
|
346
|
+
const collections = [...this.#watchCollections];
|
|
347
|
+
this.#watchCollections.clear();
|
|
348
|
+
void this.#sync({ reason: "watch" }, collections).catch(() => undefined);
|
|
369
349
|
}, WATCH_DEBOUNCE_MS);
|
|
370
350
|
});
|
|
371
351
|
}
|
|
@@ -489,23 +469,36 @@ export class QmdMemoryManager {
|
|
|
489
469
|
const allowed = new Set(sessionAllowedPaths(new Map(records.map(r => [extractedPath(r), r.metadata])), EXTRACTED_COLLECTION, filter)[EXTRACTED_COLLECTION]);
|
|
490
470
|
return records.filter(r => allowed.has(extractedPath(r)));
|
|
491
471
|
}
|
|
492
|
-
async #updateAndEmbed(store,
|
|
472
|
+
async #updateAndEmbed(store, collections, force) {
|
|
493
473
|
// Extracted facts are DB projections, not files: never run filesystem sync on them.
|
|
494
|
-
const update = await store.update({ collections: collection ? [collection] : this.#qmdSources().map(source => source.collection) });
|
|
495
|
-
this.#cleanupRemovedDocuments?.(update.updated + update.removed);
|
|
496
474
|
const analysisStore = store;
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
}
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
475
|
+
let changedDocuments = 0;
|
|
476
|
+
try {
|
|
477
|
+
const update = await store.update({ collections: collections ? [...collections] : this.#qmdSources().map(source => source.collection) });
|
|
478
|
+
changedDocuments = update.updated + update.removed;
|
|
479
|
+
if ((update.indexed + changedDocuments > 0 || update.needsEmbedding > 0 || force) && analysisStore.internal) {
|
|
480
|
+
markMemoryAnalysisStale(analysisStore.internal.db);
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
catch (error) {
|
|
484
|
+
// QMD may commit earlier collections before a later collection fails.
|
|
485
|
+
if (analysisStore.internal)
|
|
486
|
+
markMemoryAnalysisStale(analysisStore.internal.db);
|
|
487
|
+
changedDocuments = Math.max(1, changedDocuments);
|
|
488
|
+
throw error;
|
|
489
|
+
}
|
|
490
|
+
finally {
|
|
491
|
+
// A later collection failing must not leave earlier removed plaintext behind.
|
|
492
|
+
this.#cleanupRemovedDocuments?.(changedDocuments);
|
|
493
|
+
}
|
|
494
|
+
let chunksEmbedded = 0;
|
|
495
|
+
for (const collection of collections?.length ? collections : [undefined]) {
|
|
496
|
+
const embed = await store.embed({ ...(collection ? { collection } : {}), force, chunkStrategy: "semantic" });
|
|
497
|
+
this.#recordEmbedding(embed);
|
|
498
|
+
const count = completedEmbeddingCount(embed);
|
|
499
|
+
chunksEmbedded += count;
|
|
500
|
+
if (count > 0 && analysisStore.internal)
|
|
501
|
+
markMemoryAnalysisStale(analysisStore.internal.db);
|
|
509
502
|
}
|
|
510
503
|
return chunksEmbedded;
|
|
511
504
|
}
|
|
@@ -516,16 +509,14 @@ export class QmdMemoryManager {
|
|
|
516
509
|
this.#dirty = status.needsEmbedding > 0;
|
|
517
510
|
}
|
|
518
511
|
sync(params) {
|
|
512
|
+
return this.#sync(params);
|
|
513
|
+
}
|
|
514
|
+
#sync(params, affectedCollections) {
|
|
519
515
|
const run = async () => {
|
|
520
516
|
const store = await this.#getStore();
|
|
521
517
|
this.#dirty = true;
|
|
522
|
-
const collections = this.#qmdSources().filter((source) => source.kind !== "sessions");
|
|
523
|
-
|
|
524
|
-
await this.#updateAndEmbed(store, undefined, params?.force);
|
|
525
|
-
}
|
|
526
|
-
for (const source of collections) {
|
|
527
|
-
await this.#updateAndEmbed(store, source.collection, params?.force);
|
|
528
|
-
}
|
|
518
|
+
const collections = affectedCollections ?? this.#qmdSources().filter((source) => source.kind !== "sessions").map(source => source.collection);
|
|
519
|
+
await this.#updateAndEmbed(store, collections.length ? collections : undefined, params?.force);
|
|
529
520
|
await this.#refreshIndexStatus(store);
|
|
530
521
|
};
|
|
531
522
|
return this.#enqueue(run);
|
|
@@ -544,7 +535,7 @@ export class QmdMemoryManager {
|
|
|
544
535
|
index: async () => {
|
|
545
536
|
onPhase?.("indexing");
|
|
546
537
|
const store = await this.#getStore();
|
|
547
|
-
return this.#updateAndEmbed(store, sessions.collection);
|
|
538
|
+
return this.#updateAndEmbed(store, [sessions.collection]);
|
|
548
539
|
},
|
|
549
540
|
});
|
|
550
541
|
this.#sessionMetadata = sessionMetadataByPath(synced.manifest);
|
|
@@ -699,18 +690,28 @@ export class QmdMemoryManager {
|
|
|
699
690
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
700
691
|
`);
|
|
701
692
|
const curation = this.#getCuration();
|
|
693
|
+
const fingerprints = new Map();
|
|
702
694
|
for (const annotation of curation.annotations()) {
|
|
703
695
|
if (!annotation.contentFingerprint) {
|
|
704
696
|
insert.run(annotation.collection, annotation.path, null, null, annotation.eventTime, annotation.basis, 1);
|
|
705
697
|
continue;
|
|
706
698
|
}
|
|
707
|
-
const
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
699
|
+
const key = JSON.stringify([annotation.collection, annotation.path]);
|
|
700
|
+
let chunks = fingerprints.get(key);
|
|
701
|
+
if (!chunks) {
|
|
702
|
+
chunks = new Map();
|
|
703
|
+
const rows = findChunks.all(annotation.collection, annotation.path);
|
|
704
|
+
for (const row of rows) {
|
|
705
|
+
const fingerprint = chunkFingerprint(row.doc.slice(row.pos, row.pos + row.chunk_len));
|
|
706
|
+
if (!chunks.has(fingerprint))
|
|
707
|
+
chunks.set(fingerprint, row);
|
|
708
|
+
}
|
|
709
|
+
fingerprints.set(key, chunks);
|
|
710
|
+
}
|
|
711
|
+
const matched = chunks.get(annotation.contentFingerprint);
|
|
712
|
+
if (annotation.qmdHash !== (matched?.hash ?? null) || annotation.qmdSeq !== (matched?.seq ?? null)) {
|
|
713
|
+
curation.updateAnnotationLocation({ annotation, qmdHash: matched?.hash ?? null, qmdSeq: matched?.seq ?? null });
|
|
714
|
+
}
|
|
714
715
|
if (matched) {
|
|
715
716
|
insert.run(annotation.collection, annotation.path, matched.hash, matched.seq, annotation.eventTime, annotation.basis, 0);
|
|
716
717
|
}
|
|
@@ -779,7 +780,7 @@ export class QmdMemoryManager {
|
|
|
779
780
|
}
|
|
780
781
|
async search(query, opts) {
|
|
781
782
|
const started = performance.now();
|
|
782
|
-
const operation =
|
|
783
|
+
const operation = "vector";
|
|
783
784
|
let results;
|
|
784
785
|
try {
|
|
785
786
|
results = await this.#search(query, opts);
|
|
@@ -820,25 +821,6 @@ export class QmdMemoryManager {
|
|
|
820
821
|
if (collections.includes(EXTRACTED_COLLECTION)) {
|
|
821
822
|
allowedPaths = { ...allowedPaths, [EXTRACTED_COLLECTION]: currentRecords().map(extractedPath) };
|
|
822
823
|
}
|
|
823
|
-
if (opts?.lexicalOnly) {
|
|
824
|
-
const hits = await store.searchLex(query, {
|
|
825
|
-
limit: opts.maxResults ?? 5,
|
|
826
|
-
collection: collections,
|
|
827
|
-
});
|
|
828
|
-
const current = new Map(currentRecords(hits.map(hit => hit.filepath)).map(r => [`qmd://${EXTRACTED_COLLECTION}/${extractedPath(r)}`, r]));
|
|
829
|
-
return hits.flatMap((hit) => {
|
|
830
|
-
if (hit.collectionName === EXTRACTED_COLLECTION) {
|
|
831
|
-
const record = current.get(hit.filepath);
|
|
832
|
-
return record && hit.score >= (opts.minScore ?? 0) ? [extractedHit(record, hit.score)] : [];
|
|
833
|
-
}
|
|
834
|
-
const corpus = this.#sources.get(hit.collectionName)?.corpus;
|
|
835
|
-
const prefix = `qmd://${hit.collectionName}/`;
|
|
836
|
-
const session = corpus === "sessions" && hit.filepath.startsWith(prefix)
|
|
837
|
-
? this.#sessionMetadata.get(hit.filepath.slice(prefix.length))
|
|
838
|
-
: undefined;
|
|
839
|
-
return hit.score >= (opts.minScore ?? 0) && corpus ? [lexicalResult(hit, corpus, session)] : [];
|
|
840
|
-
});
|
|
841
|
-
}
|
|
842
824
|
const hits = await store.vsearch(query, {
|
|
843
825
|
collection: collections,
|
|
844
826
|
limit: opts?.maxResults ?? 5,
|
|
@@ -944,6 +926,7 @@ export class QmdMemoryManager {
|
|
|
944
926
|
if (!store.internal?.llm)
|
|
945
927
|
throw new Error("Skill Whisperer requires the QMD embedding model");
|
|
946
928
|
const llm = store.internal.llm;
|
|
929
|
+
const { formatDocForEmbedding, formatQueryForEmbedding } = await qmdModule;
|
|
947
930
|
let skillIndex = this.#skillIndex;
|
|
948
931
|
if (!skillIndex) {
|
|
949
932
|
const pending = (async () => {
|
|
@@ -968,7 +951,7 @@ export class QmdMemoryManager {
|
|
|
968
951
|
}
|
|
969
952
|
}
|
|
970
953
|
const skills = [...metadata.values()];
|
|
971
|
-
const embeddings = await llm.embedBatch(skills.map(({ candidate, description }) =>
|
|
954
|
+
const embeddings = await llm.embedBatch(skills.map(({ candidate, description }) => formatDocForEmbedding(description, candidate.name, llm.embedModelName)));
|
|
972
955
|
return skills.flatMap(({ candidate }, index) => {
|
|
973
956
|
const embedding = embeddings[index]?.embedding;
|
|
974
957
|
return embedding ? [{ ...candidate, score: 0, embedding }] : [];
|
|
@@ -981,7 +964,7 @@ export class QmdMemoryManager {
|
|
|
981
964
|
});
|
|
982
965
|
this.#skillIndex = skillIndex;
|
|
983
966
|
}
|
|
984
|
-
const queryEmbedding = await llm.embed(
|
|
967
|
+
const queryEmbedding = await llm.embed(formatQueryForEmbedding(query, llm.embedModelName), { isQuery: true });
|
|
985
968
|
if (!queryEmbedding)
|
|
986
969
|
return [];
|
|
987
970
|
const candidates = await skillIndex;
|
|
@@ -1064,6 +1047,7 @@ export class QmdMemoryManager {
|
|
|
1064
1047
|
if (this.#watchTimer)
|
|
1065
1048
|
clearTimeout(this.#watchTimer);
|
|
1066
1049
|
this.#watchTimer = undefined;
|
|
1050
|
+
this.#watchCollections.clear();
|
|
1067
1051
|
await this.#watcher?.close();
|
|
1068
1052
|
this.#watcher = undefined;
|
|
1069
1053
|
this.#watchReady = undefined;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
|
-
import {
|
|
2
|
+
import { judgeMemoryPassage, memoryUsefulnessRequest } from "./typesafe.js";
|
|
3
3
|
import { resolveTypeSafeApiKey, TypeSafeRequestError } from "./typesafe-client.js";
|
|
4
4
|
import { complementaryIndices, reviewMemoryRedundancy } from "./typesafe-review.js";
|
|
5
5
|
import { ApiQueryGenerator, QueryApiError, queryConversation } from "./query-generator.js";
|
|
@@ -164,15 +164,11 @@ export function registerMemoryWhisperer(api, runtime, config, typesafe, diagnost
|
|
|
164
164
|
timeoutMs: typesafe.timeoutMs };
|
|
165
165
|
try {
|
|
166
166
|
const { hit, excerpt } = candidate;
|
|
167
|
-
const
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
dates: [...new Set(hit.sessionMessages?.flatMap(message => message.timestamp ? [message.timestamp] : [])
|
|
173
|
-
?? (hit.messageTimestamp ? [hit.messageTimestamp] : []))],
|
|
174
|
-
}],
|
|
175
|
-
});
|
|
167
|
+
const { probability } = await judgeMemoryPassage(memoryUsefulnessRequest(conversation, {
|
|
168
|
+
excerpt, corpus: hit.corpus, sourcePath: hit.path,
|
|
169
|
+
dates: [...new Set(hit.sessionMessages?.flatMap(message => message.timestamp ? [message.timestamp] : [])
|
|
170
|
+
?? (hit.messageTimestamp ? [hit.messageTimestamp] : []))],
|
|
171
|
+
}, asOf), { apiKey, timeoutMs: typesafe.timeoutMs, signal });
|
|
176
172
|
if (signal.aborted)
|
|
177
173
|
return [];
|
|
178
174
|
requestsSucceeded++;
|