openclaw-clawmark 0.1.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/LICENSE +21 -0
- package/README.md +133 -0
- package/dist/config.d.ts +16 -0
- package/dist/config.js +45 -0
- package/dist/constants.d.ts +22 -0
- package/dist/constants.js +37 -0
- package/dist/context.d.ts +9 -0
- package/dist/context.js +47 -0
- package/dist/db.d.ts +15 -0
- package/dist/db.js +79 -0
- package/dist/embeddings.d.ts +13 -0
- package/dist/embeddings.js +94 -0
- package/dist/extractor.d.ts +16 -0
- package/dist/extractor.js +138 -0
- package/dist/facts.d.ts +21 -0
- package/dist/facts.js +71 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +149 -0
- package/dist/indexer.d.ts +12 -0
- package/dist/indexer.js +47 -0
- package/dist/recall.d.ts +15 -0
- package/dist/recall.js +62 -0
- package/dist/sources/lcm.d.ts +2 -0
- package/dist/sources/lcm.js +83 -0
- package/dist/sources/source.d.ts +29 -0
- package/dist/sources/source.js +39 -0
- package/dist/sources/transcripts.d.ts +2 -0
- package/dist/sources/transcripts.js +171 -0
- package/dist/tools.d.ts +58 -0
- package/dist/tools.js +79 -0
- package/openclaw.plugin.json +9 -0
- package/package.json +51 -0
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { EXTRACT_BATCH, MAX_FACTS_PER_RUN, MIN_EXTRACT_MESSAGES } from "./constants.js";
|
|
2
|
+
import { getCursor, logEvent, setCursor } from "./db.js";
|
|
3
|
+
import { setFact } from "./facts.js";
|
|
4
|
+
const EXTRACT_TIMEOUT_MS = 120_000;
|
|
5
|
+
const MSG_TRUNCATE_CHARS = 500;
|
|
6
|
+
/** Message-count triggers process at most this many batches; idle/boot may drain fully. */
|
|
7
|
+
const MAX_BATCHES_INCREMENTAL = 5;
|
|
8
|
+
export function extractCursorName(source) {
|
|
9
|
+
return `extract:${source.name}`;
|
|
10
|
+
}
|
|
11
|
+
const EXTRACTION_PROMPT = `You extract durable long-term facts from a conversation transcript. Return ONLY a JSON
|
|
12
|
+
object:
|
|
13
|
+
|
|
14
|
+
{"facts": [{"key": "pref.<topic>", "value": "<fact>", "confidence": 0.9}]}
|
|
15
|
+
|
|
16
|
+
Rules:
|
|
17
|
+
- Keys: lowercase dot-separated, starting with one of: pref. project. user. lesson.
|
|
18
|
+
- Only DURABLE facts: stated preferences, decisions, ongoing projects, corrections,
|
|
19
|
+
stable facts about the user. Exclude small talk, transient state, secrets, and anything
|
|
20
|
+
the user asked to keep private.
|
|
21
|
+
- confidence: 0.9+ only when the user stated it directly; 0.8 for strong inference;
|
|
22
|
+
anything below 0.8 will be discarded, so omit weak guesses.
|
|
23
|
+
- Maximum ${MAX_FACTS_PER_RUN} facts. An empty array is fine and common.
|
|
24
|
+
|
|
25
|
+
Transcript:
|
|
26
|
+
`;
|
|
27
|
+
function renderTranscript(messages) {
|
|
28
|
+
return messages
|
|
29
|
+
.map((m) => `${m.role}: ${m.text.slice(0, MSG_TRUNCATE_CHARS)}`)
|
|
30
|
+
.join("\n");
|
|
31
|
+
}
|
|
32
|
+
function parseFacts(raw) {
|
|
33
|
+
const start = raw.indexOf("{");
|
|
34
|
+
if (start === -1)
|
|
35
|
+
return null;
|
|
36
|
+
// Take the largest {...} span, then narrow until it parses.
|
|
37
|
+
for (let end = raw.lastIndexOf("}"); end > start; end = raw.lastIndexOf("}", end - 1)) {
|
|
38
|
+
try {
|
|
39
|
+
const parsed = JSON.parse(raw.slice(start, end + 1));
|
|
40
|
+
if (!Array.isArray(parsed.facts))
|
|
41
|
+
return null;
|
|
42
|
+
return parsed.facts
|
|
43
|
+
.filter((f) => !!f &&
|
|
44
|
+
typeof f === "object" &&
|
|
45
|
+
typeof f.key === "string" &&
|
|
46
|
+
typeof f.value === "string" &&
|
|
47
|
+
typeof f.confidence === "number")
|
|
48
|
+
.slice(0, MAX_FACTS_PER_RUN);
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
// keep narrowing
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
async function callExtractModel(config, transcript) {
|
|
57
|
+
const controller = new AbortController();
|
|
58
|
+
const timer = setTimeout(() => controller.abort(), EXTRACT_TIMEOUT_MS);
|
|
59
|
+
try {
|
|
60
|
+
const res = await fetch(`${config.extractUrl}/v1/chat/completions`, {
|
|
61
|
+
method: "POST",
|
|
62
|
+
headers: { "Content-Type": "application/json" },
|
|
63
|
+
body: JSON.stringify({
|
|
64
|
+
model: config.extractModel,
|
|
65
|
+
temperature: 0,
|
|
66
|
+
max_tokens: 1500,
|
|
67
|
+
// Qwen3-family servers otherwise spend the whole budget on reasoning before any
|
|
68
|
+
// JSON (verified 2026-07-07). Harmlessly ignored by non-thinking models.
|
|
69
|
+
chat_template_kwargs: { enable_thinking: false },
|
|
70
|
+
messages: [{ role: "user", content: EXTRACTION_PROMPT + transcript }],
|
|
71
|
+
}),
|
|
72
|
+
signal: controller.signal,
|
|
73
|
+
});
|
|
74
|
+
if (!res.ok)
|
|
75
|
+
return null;
|
|
76
|
+
const payload = (await res.json());
|
|
77
|
+
return payload.choices?.[0]?.message?.content ?? null;
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
finally {
|
|
83
|
+
clearTimeout(timer);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Read un-extracted messages behind a cursor, run one small-model call per batch, and
|
|
88
|
+
* upsert surviving facts. The cursor advances in the same transaction as the fact
|
|
89
|
+
* writes; a parse failure advances the cursor too (logged) so one bad batch can never
|
|
90
|
+
* wedge the pipeline. Crash-safe by construction: re-running a batch is absorbed by the
|
|
91
|
+
* upsert + precedence ladder.
|
|
92
|
+
*/
|
|
93
|
+
export async function runExtractor(db, source, config, opts = {}) {
|
|
94
|
+
const cursorName = extractCursorName(source);
|
|
95
|
+
let written = 0;
|
|
96
|
+
const maxBatches = opts.drain ? Number.POSITIVE_INFINITY : MAX_BATCHES_INCREMENTAL;
|
|
97
|
+
for (let batchNo = 0; batchNo < maxBatches; batchNo++) {
|
|
98
|
+
const batch = source.messagesAfter(getCursor(db, cursorName), EXTRACT_BATCH);
|
|
99
|
+
if (batch.messages.length === 0) {
|
|
100
|
+
if (batch.nextCursor !== null)
|
|
101
|
+
setCursor(db, cursorName, batch.nextCursor);
|
|
102
|
+
return { written };
|
|
103
|
+
}
|
|
104
|
+
const isFinalPartialBatch = batch.messages.length < EXTRACT_BATCH;
|
|
105
|
+
if (isFinalPartialBatch && batch.messages.length < MIN_EXTRACT_MESSAGES) {
|
|
106
|
+
// Not enough new material yet — wait for more; do NOT advance the cursor.
|
|
107
|
+
return { written };
|
|
108
|
+
}
|
|
109
|
+
const raw = await callExtractModel(config, renderTranscript(batch.messages));
|
|
110
|
+
if (raw === null) {
|
|
111
|
+
// Model server down — retry this batch on the next trigger.
|
|
112
|
+
return { written };
|
|
113
|
+
}
|
|
114
|
+
const facts = parseFacts(raw);
|
|
115
|
+
const commit = db.transaction(() => {
|
|
116
|
+
if (facts === null) {
|
|
117
|
+
logEvent(db, {
|
|
118
|
+
eventType: "extract_error",
|
|
119
|
+
memoryKey: "extractor.parse_error",
|
|
120
|
+
newValue: raw.slice(0, 300),
|
|
121
|
+
source: "extractor",
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
else {
|
|
125
|
+
for (const fact of facts) {
|
|
126
|
+
const result = setFact(db, fact.key, fact.value, fact.confidence, "extracted", config.confidenceThreshold);
|
|
127
|
+
if (result.ok)
|
|
128
|
+
written++;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
setCursor(db, cursorName, batch.nextCursor);
|
|
132
|
+
});
|
|
133
|
+
commit();
|
|
134
|
+
if (isFinalPartialBatch)
|
|
135
|
+
return { written };
|
|
136
|
+
}
|
|
137
|
+
return { written };
|
|
138
|
+
}
|
package/dist/facts.d.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { type Db } from "./db.js";
|
|
2
|
+
export type FactSource = "user" | "extracted";
|
|
3
|
+
export type SetFactResult = {
|
|
4
|
+
ok: true;
|
|
5
|
+
action: "create" | "update";
|
|
6
|
+
} | {
|
|
7
|
+
ok: false;
|
|
8
|
+
code: "KEY_FORMAT" | "VALUE_SIZE" | "CONFIDENCE" | "INJECTION" | "CONFLICT_SKIP";
|
|
9
|
+
reason: string;
|
|
10
|
+
};
|
|
11
|
+
export interface FactRow {
|
|
12
|
+
key: string;
|
|
13
|
+
value: string;
|
|
14
|
+
confidence: number;
|
|
15
|
+
source: string;
|
|
16
|
+
created_at: string;
|
|
17
|
+
updated_at: string;
|
|
18
|
+
}
|
|
19
|
+
export declare function setFact(db: Db, key: string, value: string, confidence: number, source: FactSource, confidenceThreshold: number): SetFactResult;
|
|
20
|
+
export declare function deleteFact(db: Db, key: string, source: string): boolean;
|
|
21
|
+
export declare function listFacts(db: Db): FactRow[];
|
package/dist/facts.js
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { CONFIDENCE_TIE_BAND, FACT_KEY_PATTERN, INJECTION_PATTERNS, MAX_FACT_KEY_LEN, MAX_FACT_VALUE_CHARS, } from "./constants.js";
|
|
2
|
+
import { logEvent } from "./db.js";
|
|
3
|
+
export function setFact(db, key, value, confidence, source, confidenceThreshold) {
|
|
4
|
+
if (key.length > MAX_FACT_KEY_LEN || key.includes("..") || !FACT_KEY_PATTERN.test(key)) {
|
|
5
|
+
logEvent(db, { eventType: "reject", memoryKey: key, newValue: "KEY_FORMAT", source });
|
|
6
|
+
return { ok: false, code: "KEY_FORMAT", reason: `invalid key "${key}"` };
|
|
7
|
+
}
|
|
8
|
+
if (typeof value !== "string" || value.length === 0 || value.length > MAX_FACT_VALUE_CHARS) {
|
|
9
|
+
logEvent(db, { eventType: "reject", memoryKey: key, newValue: "VALUE_SIZE", source });
|
|
10
|
+
return { ok: false, code: "VALUE_SIZE", reason: `value must be 1..${MAX_FACT_VALUE_CHARS} chars` };
|
|
11
|
+
}
|
|
12
|
+
if (source === "extracted" && confidence < confidenceThreshold) {
|
|
13
|
+
logEvent(db, { eventType: "reject", memoryKey: key, newValue: "CONFIDENCE", source });
|
|
14
|
+
return { ok: false, code: "CONFIDENCE", reason: `confidence ${confidence} below ${confidenceThreshold}` };
|
|
15
|
+
}
|
|
16
|
+
if (INJECTION_PATTERNS.some((p) => p.test(value))) {
|
|
17
|
+
logEvent(db, { eventType: "injection_blocked", memoryKey: key, newValue: value.slice(0, 200), source });
|
|
18
|
+
return { ok: false, code: "INJECTION", reason: "value matched a prompt-injection pattern" };
|
|
19
|
+
}
|
|
20
|
+
const clamped = Math.max(0, Math.min(1, confidence));
|
|
21
|
+
const existing = db
|
|
22
|
+
.prepare("SELECT key, value, confidence, source FROM facts WHERE key = ? AND is_deleted = 0")
|
|
23
|
+
.get(key);
|
|
24
|
+
if (existing) {
|
|
25
|
+
const userWins = source === "user";
|
|
26
|
+
const existingIsUser = existing.source === "user";
|
|
27
|
+
const higherConfidence = clamped > existing.confidence;
|
|
28
|
+
const tieBand = Math.abs(clamped - existing.confidence) < CONFIDENCE_TIE_BAND;
|
|
29
|
+
const shouldWrite = userWins || (!existingIsUser && (higherConfidence || tieBand));
|
|
30
|
+
if (!shouldWrite) {
|
|
31
|
+
logEvent(db, {
|
|
32
|
+
eventType: "conflict_skip",
|
|
33
|
+
memoryKey: key,
|
|
34
|
+
oldValue: existing.value,
|
|
35
|
+
newValue: value,
|
|
36
|
+
source,
|
|
37
|
+
});
|
|
38
|
+
return { ok: false, code: "CONFLICT_SKIP", reason: "existing fact wins by precedence/confidence" };
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
const now = new Date().toISOString();
|
|
42
|
+
db.prepare(`INSERT INTO facts (key, value, confidence, source, created_at, updated_at, is_deleted)
|
|
43
|
+
VALUES (?, ?, ?, ?, ?, ?, 0)
|
|
44
|
+
ON CONFLICT(key) DO UPDATE SET
|
|
45
|
+
value = excluded.value, confidence = excluded.confidence, source = excluded.source,
|
|
46
|
+
updated_at = excluded.updated_at, is_deleted = 0`).run(key, value, clamped, source, now, now);
|
|
47
|
+
logEvent(db, {
|
|
48
|
+
eventType: existing ? "update" : "create",
|
|
49
|
+
memoryKey: key,
|
|
50
|
+
oldValue: existing?.value ?? null,
|
|
51
|
+
newValue: value,
|
|
52
|
+
source,
|
|
53
|
+
});
|
|
54
|
+
return { ok: true, action: existing ? "update" : "create" };
|
|
55
|
+
}
|
|
56
|
+
export function deleteFact(db, key, source) {
|
|
57
|
+
const result = db
|
|
58
|
+
.prepare("UPDATE facts SET is_deleted = 1, updated_at = ? WHERE key = ? AND is_deleted = 0")
|
|
59
|
+
.run(new Date().toISOString(), key);
|
|
60
|
+
if (result.changes > 0) {
|
|
61
|
+
logEvent(db, { eventType: "delete", memoryKey: key, source });
|
|
62
|
+
return true;
|
|
63
|
+
}
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
export function listFacts(db) {
|
|
67
|
+
return db
|
|
68
|
+
.prepare(`SELECT key, value, confidence, source, created_at, updated_at
|
|
69
|
+
FROM facts WHERE is_deleted = 0 ORDER BY updated_at DESC`)
|
|
70
|
+
.all();
|
|
71
|
+
}
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
|
|
2
|
+
import { getConfig } from "./config.js";
|
|
3
|
+
import { JOB_EVERY_N_MESSAGES } from "./constants.js";
|
|
4
|
+
import { buildContext } from "./context.js";
|
|
5
|
+
import { openDb, rotateEvents } from "./db.js";
|
|
6
|
+
import { createEmbeddingClient } from "./embeddings.js";
|
|
7
|
+
import { runExtractor } from "./extractor.js";
|
|
8
|
+
import { runIndexer } from "./indexer.js";
|
|
9
|
+
import { selectSource } from "./sources/source.js";
|
|
10
|
+
import { buildTools } from "./tools.js";
|
|
11
|
+
const IDLE_TICK_MS = 10 * 60 * 1000;
|
|
12
|
+
const entry = definePluginEntry({
|
|
13
|
+
id: "clawmark",
|
|
14
|
+
name: "Clawmark",
|
|
15
|
+
description: "Durable facts and semantic recall derived from the message history OpenClaw already keeps.",
|
|
16
|
+
register(api) {
|
|
17
|
+
const log = {
|
|
18
|
+
info: (msg) => api.logger?.info?.(`clawmark: ${msg}`),
|
|
19
|
+
warn: (msg) => api.logger?.warn?.(`clawmark: ${msg}`),
|
|
20
|
+
error: (msg) => api.logger?.error?.(`clawmark: ${msg}`),
|
|
21
|
+
};
|
|
22
|
+
let runtime = null;
|
|
23
|
+
let idleTimer = null;
|
|
24
|
+
let messageCounter = 0;
|
|
25
|
+
let lastActivity = Date.now();
|
|
26
|
+
let jobsRunning = false;
|
|
27
|
+
const runJobs = async (opts = {}) => {
|
|
28
|
+
if (!runtime || jobsRunning)
|
|
29
|
+
return;
|
|
30
|
+
jobsRunning = true;
|
|
31
|
+
try {
|
|
32
|
+
const { db, source, embedder, config } = runtime;
|
|
33
|
+
const indexed = await runIndexer(db, source, embedder);
|
|
34
|
+
const extracted = await runExtractor(db, source, config, opts);
|
|
35
|
+
rotateEvents(db);
|
|
36
|
+
if (indexed.indexed > 0 || extracted.written > 0) {
|
|
37
|
+
log.info(`jobs: indexed ${indexed.indexed} messages, wrote ${extracted.written} facts`);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
catch (err) {
|
|
41
|
+
log.error(`background jobs failed: ${String(err)}`);
|
|
42
|
+
}
|
|
43
|
+
finally {
|
|
44
|
+
jobsRunning = false;
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
const noteActivity = () => {
|
|
48
|
+
lastActivity = Date.now();
|
|
49
|
+
messageCounter++;
|
|
50
|
+
if (messageCounter >= JOB_EVERY_N_MESSAGES) {
|
|
51
|
+
messageCounter = 0;
|
|
52
|
+
void runJobs();
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
api.on("gateway_start", () => {
|
|
56
|
+
try {
|
|
57
|
+
const config = getConfig();
|
|
58
|
+
const db = openDb(config.dbPath);
|
|
59
|
+
const source = selectSource(config, db);
|
|
60
|
+
const embedder = createEmbeddingClient(config, log.warn);
|
|
61
|
+
runtime = { db, source, embedder, config };
|
|
62
|
+
log.info(`started — source=${source.name}, db=${config.dbPath}`);
|
|
63
|
+
// Catch up on everything missed while the gateway was down. Cursor-based reads
|
|
64
|
+
// of a durable log make this crash-safe: kill -9 at any point loses nothing.
|
|
65
|
+
void runJobs({ drain: true });
|
|
66
|
+
idleTimer = setInterval(() => {
|
|
67
|
+
if (!runtime)
|
|
68
|
+
return;
|
|
69
|
+
if (Date.now() - lastActivity >= runtime.config.idleHours * 3_600_000) {
|
|
70
|
+
void runJobs({ drain: true });
|
|
71
|
+
}
|
|
72
|
+
}, IDLE_TICK_MS);
|
|
73
|
+
idleTimer.unref?.();
|
|
74
|
+
}
|
|
75
|
+
catch (err) {
|
|
76
|
+
// Memory failures must never break the agent — stay inert and say why.
|
|
77
|
+
log.error(`disabled (init failed): ${String(err)}`);
|
|
78
|
+
runtime = null;
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
api.on("gateway_stop", () => {
|
|
82
|
+
try {
|
|
83
|
+
if (idleTimer)
|
|
84
|
+
clearInterval(idleTimer);
|
|
85
|
+
idleTimer = null;
|
|
86
|
+
if (runtime) {
|
|
87
|
+
runtime.db.pragma("wal_checkpoint(TRUNCATE)");
|
|
88
|
+
runtime.db.close();
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
catch (err) {
|
|
92
|
+
log.error(`shutdown error: ${String(err)}`);
|
|
93
|
+
}
|
|
94
|
+
finally {
|
|
95
|
+
runtime = null;
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
api.on("before_prompt_build", async (event) => {
|
|
99
|
+
try {
|
|
100
|
+
if (!runtime)
|
|
101
|
+
return;
|
|
102
|
+
const { db, source, embedder, config } = runtime;
|
|
103
|
+
const block = await buildContext(db, source, embedder, event.prompt ?? "", config.recallLimit);
|
|
104
|
+
if (!block)
|
|
105
|
+
return;
|
|
106
|
+
return { prependContext: block };
|
|
107
|
+
}
|
|
108
|
+
catch (err) {
|
|
109
|
+
log.error(`before_prompt_build failed: ${String(err)}`);
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
api.on("message_received", () => {
|
|
114
|
+
try {
|
|
115
|
+
noteActivity();
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
/* never break the agent */
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
api.on("agent_end", () => {
|
|
122
|
+
try {
|
|
123
|
+
noteActivity();
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
/* never break the agent */
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
api.on("session_end", () => {
|
|
130
|
+
try {
|
|
131
|
+
// Best-effort flush; the boot-time drain is the real guarantee.
|
|
132
|
+
void runJobs();
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
/* never break the agent */
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
for (const tool of buildTools(() => runtime)) {
|
|
139
|
+
try {
|
|
140
|
+
// Cast: our minimal ToolResult structurally matches AgentToolResult.
|
|
141
|
+
api.registerTool(tool, { optional: true });
|
|
142
|
+
}
|
|
143
|
+
catch (err) {
|
|
144
|
+
log.error(`tool registration failed for ${tool.name}: ${String(err)}`);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
},
|
|
148
|
+
});
|
|
149
|
+
export default entry;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { type Db } from "./db.js";
|
|
2
|
+
import { type EmbeddingClient } from "./embeddings.js";
|
|
3
|
+
import type { MessageSource } from "./sources/source.js";
|
|
4
|
+
export declare function indexCursorName(source: MessageSource): string;
|
|
5
|
+
/**
|
|
6
|
+
* Incrementally embed new source messages into message_vectors.
|
|
7
|
+
* Cursor advances in the same transaction as the inserts; an embedder outage
|
|
8
|
+
* leaves the cursor untouched so the next trigger retries the same batch.
|
|
9
|
+
*/
|
|
10
|
+
export declare function runIndexer(db: Db, source: MessageSource, embedder: EmbeddingClient): Promise<{
|
|
11
|
+
indexed: number;
|
|
12
|
+
}>;
|
package/dist/indexer.js
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { MAX_INDEX_CHARS, MIN_INDEX_CHARS } from "./constants.js";
|
|
2
|
+
import { getCursor, setCursor } from "./db.js";
|
|
3
|
+
import { serializeVector } from "./embeddings.js";
|
|
4
|
+
const BATCH_SIZE = 100;
|
|
5
|
+
export function indexCursorName(source) {
|
|
6
|
+
return `index:${source.name}`;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Incrementally embed new source messages into message_vectors.
|
|
10
|
+
* Cursor advances in the same transaction as the inserts; an embedder outage
|
|
11
|
+
* leaves the cursor untouched so the next trigger retries the same batch.
|
|
12
|
+
*/
|
|
13
|
+
export async function runIndexer(db, source, embedder) {
|
|
14
|
+
const cursorName = indexCursorName(source);
|
|
15
|
+
let indexed = 0;
|
|
16
|
+
for (;;) {
|
|
17
|
+
const batch = source.messagesAfter(getCursor(db, cursorName), BATCH_SIZE);
|
|
18
|
+
if (batch.messages.length === 0) {
|
|
19
|
+
// Skipped-only progress (e.g. a stretch of tool lines) still advances the cursor.
|
|
20
|
+
if (batch.nextCursor !== null)
|
|
21
|
+
setCursor(db, cursorName, batch.nextCursor);
|
|
22
|
+
return { indexed };
|
|
23
|
+
}
|
|
24
|
+
const eligible = batch.messages.filter((m) => m.text.trim().length >= MIN_INDEX_CHARS);
|
|
25
|
+
const texts = eligible.map((m) => m.text.slice(0, MAX_INDEX_CHARS));
|
|
26
|
+
const vectors = await embedder.embedTexts(texts);
|
|
27
|
+
if (eligible.length > 0 && vectors.every((v) => v === null)) {
|
|
28
|
+
// Embedding server down — retry this batch on the next trigger.
|
|
29
|
+
return { indexed };
|
|
30
|
+
}
|
|
31
|
+
const insert = db.prepare(`INSERT OR IGNORE INTO message_vectors (source_ref, conversation_id, ts, embedding)
|
|
32
|
+
VALUES (?, ?, ?, ?)`);
|
|
33
|
+
const commit = db.transaction(() => {
|
|
34
|
+
for (let i = 0; i < eligible.length; i++) {
|
|
35
|
+
const vec = vectors[i];
|
|
36
|
+
if (vec === null)
|
|
37
|
+
continue;
|
|
38
|
+
insert.run(eligible[i].ref, eligible[i].conversationId, eligible[i].ts, serializeVector(vec));
|
|
39
|
+
indexed++;
|
|
40
|
+
}
|
|
41
|
+
setCursor(db, cursorName, batch.nextCursor);
|
|
42
|
+
});
|
|
43
|
+
commit();
|
|
44
|
+
if (batch.messages.length < BATCH_SIZE)
|
|
45
|
+
return { indexed };
|
|
46
|
+
}
|
|
47
|
+
}
|
package/dist/recall.d.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { Db } from "./db.js";
|
|
2
|
+
import { type EmbeddingClient } from "./embeddings.js";
|
|
3
|
+
import type { MessageSource } from "./sources/source.js";
|
|
4
|
+
export interface RecallResult {
|
|
5
|
+
ref: string;
|
|
6
|
+
ts: string;
|
|
7
|
+
role: string;
|
|
8
|
+
excerpt: string;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Query-relevant retrieval: cosine over the full vector index with a recency decay,
|
|
12
|
+
* then a cheap token-Jaccard diversity pass. Returns [] when the embedder is down —
|
|
13
|
+
* facts still inject without recall.
|
|
14
|
+
*/
|
|
15
|
+
export declare function recall(db: Db, source: MessageSource, embedder: EmbeddingClient, query: string, limit: number): Promise<RecallResult[]>;
|
package/dist/recall.js
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { DECAY_RATE, JACCARD_DUP_THRESHOLD, RECALL_MIN_COSINE } from "./constants.js";
|
|
2
|
+
import { cosine, deserializeVector } from "./embeddings.js";
|
|
3
|
+
const EXCERPT_CHARS = 400;
|
|
4
|
+
function tokenSet(text) {
|
|
5
|
+
return new Set(text.toLowerCase().match(/[a-z0-9_]{2,}/g) ?? []);
|
|
6
|
+
}
|
|
7
|
+
function jaccard(a, b) {
|
|
8
|
+
if (a.size === 0 || b.size === 0)
|
|
9
|
+
return 0;
|
|
10
|
+
let inter = 0;
|
|
11
|
+
for (const t of a)
|
|
12
|
+
if (b.has(t))
|
|
13
|
+
inter++;
|
|
14
|
+
return inter / (a.size + b.size - inter);
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Query-relevant retrieval: cosine over the full vector index with a recency decay,
|
|
18
|
+
* then a cheap token-Jaccard diversity pass. Returns [] when the embedder is down —
|
|
19
|
+
* facts still inject without recall.
|
|
20
|
+
*/
|
|
21
|
+
export async function recall(db, source, embedder, query, limit) {
|
|
22
|
+
if (!query.trim() || limit <= 0)
|
|
23
|
+
return [];
|
|
24
|
+
const queryVec = await embedder.embedQuery(query);
|
|
25
|
+
if (queryVec === null)
|
|
26
|
+
return [];
|
|
27
|
+
const now = Date.now();
|
|
28
|
+
const scored = [];
|
|
29
|
+
const rows = db
|
|
30
|
+
.prepare("SELECT source_ref, ts, embedding FROM message_vectors")
|
|
31
|
+
.iterate();
|
|
32
|
+
for (const row of rows) {
|
|
33
|
+
const sim = cosine(queryVec, deserializeVector(row.embedding));
|
|
34
|
+
if (sim < RECALL_MIN_COSINE)
|
|
35
|
+
continue;
|
|
36
|
+
const ageDays = Math.max(0, (now - Date.parse(row.ts)) / 86_400_000) || 0;
|
|
37
|
+
scored.push({ ref: row.source_ref, ts: row.ts, score: sim * Math.exp(-DECAY_RATE * ageDays) });
|
|
38
|
+
}
|
|
39
|
+
scored.sort((a, b) => b.score - a.score);
|
|
40
|
+
const candidates = scored.slice(0, limit * 3);
|
|
41
|
+
const messages = new Map(source.getMessages(candidates.map((c) => c.ref)).map((m) => [m.ref, m]));
|
|
42
|
+
const selected = [];
|
|
43
|
+
const selectedTokens = [];
|
|
44
|
+
for (const cand of candidates) {
|
|
45
|
+
if (selected.length >= limit)
|
|
46
|
+
break;
|
|
47
|
+
const msg = messages.get(cand.ref);
|
|
48
|
+
if (!msg)
|
|
49
|
+
continue;
|
|
50
|
+
const tokens = tokenSet(msg.text);
|
|
51
|
+
if (selectedTokens.some((prev) => jaccard(prev, tokens) > JACCARD_DUP_THRESHOLD))
|
|
52
|
+
continue;
|
|
53
|
+
selectedTokens.push(tokens);
|
|
54
|
+
selected.push({
|
|
55
|
+
ref: msg.ref,
|
|
56
|
+
ts: msg.ts,
|
|
57
|
+
role: msg.role,
|
|
58
|
+
excerpt: msg.text.slice(0, EXCERPT_CHARS),
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
return selected;
|
|
62
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import Database from "better-sqlite3";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
/**
|
|
4
|
+
* Read-only adapter over Lossless Claw's SQLite database (`lcm.db`).
|
|
5
|
+
*
|
|
6
|
+
* Schema (from lossless-claw src/db/migration.ts):
|
|
7
|
+
* messages(message_id INTEGER PK AUTOINCREMENT, conversation_id INTEGER, seq,
|
|
8
|
+
* role TEXT, content TEXT, token_count, identity_hash,
|
|
9
|
+
* transcript_entry_id, created_at TEXT)
|
|
10
|
+
*
|
|
11
|
+
* Cursor/ref = stringified message_id (monotonic autoincrement).
|
|
12
|
+
* validate() checks the expected columns exist so a schema change in a future LCM
|
|
13
|
+
* release degrades to the transcript source instead of crashing.
|
|
14
|
+
*/
|
|
15
|
+
const REQUIRED_COLUMNS = ["message_id", "conversation_id", "role", "content", "created_at"];
|
|
16
|
+
function toMessage(row) {
|
|
17
|
+
return {
|
|
18
|
+
ref: String(row.message_id),
|
|
19
|
+
conversationId: String(row.conversation_id),
|
|
20
|
+
role: row.role,
|
|
21
|
+
text: row.content,
|
|
22
|
+
ts: row.created_at,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
export function openLcmSource(dbPath) {
|
|
26
|
+
let db = null;
|
|
27
|
+
const open = () => {
|
|
28
|
+
if (!db)
|
|
29
|
+
db = new Database(dbPath, { readonly: true, fileMustExist: true });
|
|
30
|
+
return db;
|
|
31
|
+
};
|
|
32
|
+
return {
|
|
33
|
+
name: "lcm",
|
|
34
|
+
validate() {
|
|
35
|
+
if (!fs.existsSync(dbPath)) {
|
|
36
|
+
return { ok: false, reason: `LCM database not found at ${dbPath}` };
|
|
37
|
+
}
|
|
38
|
+
try {
|
|
39
|
+
const cols = open()
|
|
40
|
+
.prepare("SELECT name FROM pragma_table_info('messages')")
|
|
41
|
+
.all();
|
|
42
|
+
const names = new Set(cols.map((c) => c.name));
|
|
43
|
+
const missing = REQUIRED_COLUMNS.filter((c) => !names.has(c));
|
|
44
|
+
if (cols.length === 0)
|
|
45
|
+
return { ok: false, reason: "LCM 'messages' table not found" };
|
|
46
|
+
if (missing.length > 0) {
|
|
47
|
+
return { ok: false, reason: `LCM schema drift: missing columns ${missing.join(", ")}` };
|
|
48
|
+
}
|
|
49
|
+
return { ok: true };
|
|
50
|
+
}
|
|
51
|
+
catch (err) {
|
|
52
|
+
return { ok: false, reason: `LCM database unreadable: ${String(err)}` };
|
|
53
|
+
}
|
|
54
|
+
},
|
|
55
|
+
messagesAfter(cursor, limit) {
|
|
56
|
+
const after = cursor === null ? 0 : Number(cursor);
|
|
57
|
+
const rows = open()
|
|
58
|
+
.prepare(`SELECT message_id, conversation_id, role, content, created_at
|
|
59
|
+
FROM messages WHERE message_id > ? ORDER BY message_id ASC LIMIT ?`)
|
|
60
|
+
.all(after, limit);
|
|
61
|
+
if (rows.length === 0)
|
|
62
|
+
return { messages: [], nextCursor: null };
|
|
63
|
+
return {
|
|
64
|
+
messages: rows.map(toMessage),
|
|
65
|
+
nextCursor: String(rows[rows.length - 1].message_id),
|
|
66
|
+
};
|
|
67
|
+
},
|
|
68
|
+
getMessages(refs) {
|
|
69
|
+
if (refs.length === 0)
|
|
70
|
+
return [];
|
|
71
|
+
const placeholders = refs.map(() => "?").join(",");
|
|
72
|
+
const rows = open()
|
|
73
|
+
.prepare(`SELECT message_id, conversation_id, role, content, created_at
|
|
74
|
+
FROM messages WHERE message_id IN (${placeholders})`)
|
|
75
|
+
.all(...refs.map(Number));
|
|
76
|
+
const byRef = new Map(rows.map((r) => [String(r.message_id), toMessage(r)]));
|
|
77
|
+
return refs.flatMap((ref) => {
|
|
78
|
+
const msg = byRef.get(ref);
|
|
79
|
+
return msg ? [msg] : [];
|
|
80
|
+
});
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { ClawmarkConfig } from "../config.js";
|
|
2
|
+
import { type Db } from "../db.js";
|
|
3
|
+
export interface SourceMessage {
|
|
4
|
+
/** Opaque, stable, unique per message within a source. */
|
|
5
|
+
ref: string;
|
|
6
|
+
conversationId: string;
|
|
7
|
+
role: string;
|
|
8
|
+
text: string;
|
|
9
|
+
/** ISO-8601 timestamp. */
|
|
10
|
+
ts: string;
|
|
11
|
+
}
|
|
12
|
+
export interface MessageBatch {
|
|
13
|
+
messages: SourceMessage[];
|
|
14
|
+
/** Cursor covering everything returned (and skipped) so far; null if nothing new. */
|
|
15
|
+
nextCursor: string | null;
|
|
16
|
+
}
|
|
17
|
+
export interface MessageSource {
|
|
18
|
+
name: "lcm" | "transcripts";
|
|
19
|
+
validate(): {
|
|
20
|
+
ok: true;
|
|
21
|
+
} | {
|
|
22
|
+
ok: false;
|
|
23
|
+
reason: string;
|
|
24
|
+
};
|
|
25
|
+
/** Ordered, cursor-exclusive read. `cursor === null` means from the beginning. */
|
|
26
|
+
messagesAfter(cursor: string | null, limit: number): MessageBatch;
|
|
27
|
+
getMessages(refs: string[]): SourceMessage[];
|
|
28
|
+
}
|
|
29
|
+
export declare function selectSource(config: ClawmarkConfig, db: Db): MessageSource;
|