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.
@@ -0,0 +1,39 @@
1
+ import { logEvent } from "../db.js";
2
+ import { openLcmSource } from "./lcm.js";
3
+ import { openTranscriptSource } from "./transcripts.js";
4
+ export function selectSource(config, db) {
5
+ const tryLcm = () => {
6
+ if (!config.lcmDbPath)
7
+ return null;
8
+ const lcm = openLcmSource(config.lcmDbPath);
9
+ const check = lcm.validate();
10
+ if (check.ok)
11
+ return lcm;
12
+ logEvent(db, {
13
+ eventType: "source_fallback",
14
+ memoryKey: "source.lcm",
15
+ newValue: check.reason,
16
+ source: "selectSource",
17
+ });
18
+ return null;
19
+ };
20
+ if (config.source === "lcm") {
21
+ const lcm = tryLcm();
22
+ if (!lcm)
23
+ throw new Error("Clawmark: CLAWMARK_SOURCE=lcm but the LCM database failed validation");
24
+ return lcm;
25
+ }
26
+ if (config.source === "transcripts") {
27
+ if (!config.transcriptsDir)
28
+ throw new Error("Clawmark: transcripts source requires CLAWMARK_TRANSCRIPTS_DIR");
29
+ return openTranscriptSource(config.transcriptsDir);
30
+ }
31
+ // auto
32
+ const lcm = tryLcm();
33
+ if (lcm)
34
+ return lcm;
35
+ if (!config.transcriptsDir) {
36
+ throw new Error("Clawmark: no usable message source (LCM invalid/missing and no CLAWMARK_TRANSCRIPTS_DIR)");
37
+ }
38
+ return openTranscriptSource(config.transcriptsDir);
39
+ }
@@ -0,0 +1,2 @@
1
+ import type { MessageSource } from "./source.js";
2
+ export declare function openTranscriptSource(dir: string): MessageSource;
@@ -0,0 +1,171 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ function parseCursor(cursor) {
4
+ if (cursor === null)
5
+ return null;
6
+ const idx = cursor.lastIndexOf(":");
7
+ if (idx <= 0)
8
+ return null;
9
+ return { file: cursor.slice(0, idx), offset: Number(cursor.slice(idx + 1)) };
10
+ }
11
+ function extractText(content) {
12
+ if (typeof content === "string")
13
+ return content;
14
+ if (Array.isArray(content)) {
15
+ return content
16
+ .map((part) => typeof part === "string"
17
+ ? part
18
+ : part && typeof part === "object" && typeof part.text === "string"
19
+ ? part.text
20
+ : "")
21
+ .filter(Boolean)
22
+ .join("\n");
23
+ }
24
+ return "";
25
+ }
26
+ function parseLine(raw, file, lineOffset, sessionId) {
27
+ let entry;
28
+ try {
29
+ entry = JSON.parse(raw);
30
+ }
31
+ catch {
32
+ return { message: null };
33
+ }
34
+ const type = entry.type;
35
+ if (type !== "message" && type !== "custom_message")
36
+ return { message: null };
37
+ // Message payloads either carry role/content at the top level or under `message`.
38
+ const payload = entry.message && typeof entry.message === "object"
39
+ ? entry.message
40
+ : entry;
41
+ const role = typeof payload.role === "string" ? payload.role : null;
42
+ if (role !== "user" && role !== "assistant")
43
+ return { message: null };
44
+ const text = extractText(payload.content ?? payload.text);
45
+ if (!text.trim())
46
+ return { message: null };
47
+ const ts = typeof entry.timestamp === "string"
48
+ ? entry.timestamp
49
+ : typeof entry.timestamp === "number"
50
+ ? new Date(entry.timestamp).toISOString()
51
+ : new Date(0).toISOString();
52
+ return {
53
+ message: {
54
+ ref: `${file}:${lineOffset}`,
55
+ conversationId: sessionId,
56
+ role,
57
+ text,
58
+ ts,
59
+ },
60
+ };
61
+ }
62
+ function sessionIdOf(file) {
63
+ return path.basename(file).replace(/\.jsonl$/, "");
64
+ }
65
+ export function openTranscriptSource(dir) {
66
+ const listFiles = () => {
67
+ try {
68
+ return fs
69
+ .readdirSync(dir)
70
+ .filter((f) => f.endsWith(".jsonl"))
71
+ .sort();
72
+ }
73
+ catch {
74
+ return [];
75
+ }
76
+ };
77
+ const readFrom = (file, startOffset, limit, out) => {
78
+ const full = path.join(dir, file);
79
+ let buf;
80
+ try {
81
+ buf = fs.readFileSync(full);
82
+ }
83
+ catch {
84
+ return startOffset;
85
+ }
86
+ let offset = startOffset;
87
+ const sessionId = sessionIdOf(file);
88
+ while (offset < buf.length && out.length < limit) {
89
+ let end = buf.indexOf(0x0a, offset);
90
+ if (end === -1) {
91
+ // No trailing newline yet — the writer may still be mid-line; wait for the flush.
92
+ break;
93
+ }
94
+ const raw = buf.subarray(offset, end).toString("utf8").trim();
95
+ if (raw) {
96
+ const { message } = parseLine(raw, file, offset, sessionId);
97
+ if (message)
98
+ out.push(message);
99
+ }
100
+ offset = end + 1;
101
+ }
102
+ return offset;
103
+ };
104
+ return {
105
+ name: "transcripts",
106
+ validate() {
107
+ if (!fs.existsSync(dir))
108
+ return { ok: false, reason: `transcripts dir not found: ${dir}` };
109
+ return { ok: true };
110
+ },
111
+ messagesAfter(cursor, limit) {
112
+ const files = listFiles();
113
+ if (files.length === 0)
114
+ return { messages: [], nextCursor: null };
115
+ const parsed = parseCursor(cursor);
116
+ const out = [];
117
+ let curFile = parsed?.file ?? files[0];
118
+ let curOffset = parsed?.offset ?? 0;
119
+ // If the cursor's file vanished (shouldn't happen — append-only), restart after it by name.
120
+ if (!files.includes(curFile)) {
121
+ const next = files.find((f) => f > curFile);
122
+ if (!next)
123
+ return { messages: [], nextCursor: null };
124
+ curFile = next;
125
+ curOffset = 0;
126
+ }
127
+ let advanced = false;
128
+ for (let i = files.indexOf(curFile); i < files.length && out.length < limit; i++) {
129
+ const file = files[i];
130
+ const start = file === curFile ? curOffset : 0;
131
+ const newOffset = readFrom(file, start, limit, out);
132
+ if (newOffset > start || file !== curFile)
133
+ advanced = true;
134
+ curFile = file;
135
+ curOffset = newOffset;
136
+ if (out.length >= limit)
137
+ break;
138
+ }
139
+ if (out.length === 0 && !advanced)
140
+ return { messages: [], nextCursor: null };
141
+ return { messages: out, nextCursor: `${curFile}:${curOffset}` };
142
+ },
143
+ getMessages(refs) {
144
+ const out = [];
145
+ for (const ref of refs) {
146
+ const parsed = parseCursor(ref);
147
+ if (!parsed)
148
+ continue;
149
+ const full = path.join(dir, parsed.file);
150
+ let buf;
151
+ try {
152
+ buf = fs.readFileSync(full);
153
+ }
154
+ catch {
155
+ continue;
156
+ }
157
+ const end = buf.indexOf(0x0a, parsed.offset);
158
+ const raw = buf
159
+ .subarray(parsed.offset, end === -1 ? buf.length : end)
160
+ .toString("utf8")
161
+ .trim();
162
+ if (!raw)
163
+ continue;
164
+ const { message } = parseLine(raw, parsed.file, parsed.offset, sessionIdOf(parsed.file));
165
+ if (message)
166
+ out.push(message);
167
+ }
168
+ return out;
169
+ },
170
+ };
171
+ }
@@ -0,0 +1,58 @@
1
+ import type { ClawmarkConfig } from "./config.js";
2
+ import type { Db } from "./db.js";
3
+ import type { EmbeddingClient } from "./embeddings.js";
4
+ import type { MessageSource } from "./sources/source.js";
5
+ interface ToolResult {
6
+ content: {
7
+ type: "text";
8
+ text: string;
9
+ }[];
10
+ details: unknown;
11
+ }
12
+ export interface ClawmarkRuntime {
13
+ db: Db;
14
+ source: MessageSource;
15
+ embedder: EmbeddingClient;
16
+ config: ClawmarkConfig;
17
+ }
18
+ /**
19
+ * Tool definitions in the OpenClaw AgentTool shape. `clawmark_` prefix avoids
20
+ * collisions with native memory tools and other memory plugins. Runtime is resolved
21
+ * lazily so tools registered before gateway_start still work.
22
+ */
23
+ export declare function buildTools(getRuntime: () => ClawmarkRuntime | null): ({
24
+ name: string;
25
+ label: string;
26
+ description: string;
27
+ parameters: import("@sinclair/typebox").TObject<{
28
+ key: import("@sinclair/typebox").TString;
29
+ value: import("@sinclair/typebox").TString;
30
+ }>;
31
+ execute(_id: string, params: {
32
+ key: string;
33
+ value: string;
34
+ }): Promise<ToolResult>;
35
+ } | {
36
+ name: string;
37
+ label: string;
38
+ description: string;
39
+ parameters: import("@sinclair/typebox").TObject<{
40
+ query: import("@sinclair/typebox").TString;
41
+ limit: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TNumber>;
42
+ }>;
43
+ execute(_id: string, params: {
44
+ query: string;
45
+ limit?: number;
46
+ }): Promise<ToolResult>;
47
+ } | {
48
+ name: string;
49
+ label: string;
50
+ description: string;
51
+ parameters: import("@sinclair/typebox").TObject<{
52
+ key: import("@sinclair/typebox").TString;
53
+ }>;
54
+ execute(_id: string, params: {
55
+ key: string;
56
+ }): Promise<ToolResult>;
57
+ })[];
58
+ export {};
package/dist/tools.js ADDED
@@ -0,0 +1,79 @@
1
+ import { Type } from "@sinclair/typebox";
2
+ import { deleteFact, listFacts, setFact } from "./facts.js";
3
+ import { recall } from "./recall.js";
4
+ function text(value) {
5
+ return {
6
+ content: [{ type: "text", text: typeof value === "string" ? value : JSON.stringify(value, null, 2) }],
7
+ details: {},
8
+ };
9
+ }
10
+ /**
11
+ * Tool definitions in the OpenClaw AgentTool shape. `clawmark_` prefix avoids
12
+ * collisions with native memory tools and other memory plugins. Runtime is resolved
13
+ * lazily so tools registered before gateway_start still work.
14
+ */
15
+ export function buildTools(getRuntime) {
16
+ const requireRuntime = () => {
17
+ const rt = getRuntime();
18
+ if (!rt)
19
+ throw new Error("Clawmark is not initialized (gateway not started or config invalid)");
20
+ return rt;
21
+ };
22
+ return [
23
+ {
24
+ name: "clawmark_set",
25
+ label: "Clawmark: remember fact",
26
+ description: "Store a durable fact about the user (preference, decision, project, correction). " +
27
+ "Key must be lowercase dot-separated starting with pref./project./user./lesson., " +
28
+ 'e.g. "pref.editor". User-set facts always win over extracted ones.',
29
+ parameters: Type.Object({
30
+ key: Type.String({ description: "Fact key, e.g. pref.editor" }),
31
+ value: Type.String({ description: "The fact itself, one or two sentences" }),
32
+ }),
33
+ async execute(_id, params) {
34
+ const { db, config } = requireRuntime();
35
+ const result = setFact(db, params.key, params.value, 1.0, "user", config.confidenceThreshold);
36
+ if (!result.ok)
37
+ throw new Error(`clawmark_set rejected (${result.code}): ${result.reason}`);
38
+ return text(`Remembered ${params.key} (${result.action}).`);
39
+ },
40
+ },
41
+ {
42
+ name: "clawmark_search",
43
+ label: "Clawmark: search memory",
44
+ description: "Search stored facts and past conversation history. Returns matching facts and " +
45
+ "relevant excerpts from prior sessions.",
46
+ parameters: Type.Object({
47
+ query: Type.String({ description: "What to look for" }),
48
+ limit: Type.Optional(Type.Number({ description: "Max recall excerpts (default 6)" })),
49
+ }),
50
+ async execute(_id, params) {
51
+ const { db, source, embedder, config } = requireRuntime();
52
+ const q = params.query.toLowerCase();
53
+ const tokens = q.match(/[a-z0-9_]{2,}/g) ?? [];
54
+ const facts = listFacts(db).filter((f) => {
55
+ const hay = `${f.key} ${f.value}`.toLowerCase();
56
+ return tokens.some((t) => hay.includes(t));
57
+ });
58
+ const excerpts = await recall(db, source, embedder, params.query, params.limit ?? config.recallLimit);
59
+ return text({
60
+ facts: facts.map((f) => ({ key: f.key, value: f.value, confidence: f.confidence })),
61
+ recalled: excerpts.map((r) => ({ date: r.ts.slice(0, 10), role: r.role, excerpt: r.excerpt })),
62
+ });
63
+ },
64
+ },
65
+ {
66
+ name: "clawmark_forget",
67
+ label: "Clawmark: forget fact",
68
+ description: "Delete a stored fact by key. Use clawmark_search first to find the key.",
69
+ parameters: Type.Object({
70
+ key: Type.String({ description: "Fact key to delete" }),
71
+ }),
72
+ async execute(_id, params) {
73
+ const { db } = requireRuntime();
74
+ const deleted = deleteFact(db, params.key, "user");
75
+ return text(deleted ? `Forgot ${params.key}.` : `No fact stored under ${params.key}.`);
76
+ },
77
+ },
78
+ ];
79
+ }
@@ -0,0 +1,9 @@
1
+ {
2
+ "id": "clawmark",
3
+ "name": "Clawmark",
4
+ "description": "Durable facts and semantic recall for OpenClaw, derived from the message history you already keep. No duplicate message store; crash-safe by design.",
5
+ "version": "0.1.0",
6
+ "contracts": {
7
+ "tools": ["clawmark_set", "clawmark_search", "clawmark_forget"]
8
+ }
9
+ }
package/package.json ADDED
@@ -0,0 +1,51 @@
1
+ {
2
+ "name": "openclaw-clawmark",
3
+ "version": "0.1.0",
4
+ "description": "Clawmark — durable facts and semantic recall for OpenClaw, derived from the message history you already keep. No duplicate message store; crash-safe by design.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "repository": "github:namitsingal/clawmark",
8
+ "keywords": [
9
+ "openclaw",
10
+ "openclaw-plugin",
11
+ "memory",
12
+ "recall",
13
+ "embeddings"
14
+ ],
15
+ "main": "dist/index.js",
16
+ "files": [
17
+ "dist",
18
+ "openclaw.plugin.json",
19
+ "README.md",
20
+ "LICENSE"
21
+ ],
22
+ "scripts": {
23
+ "build": "tsc",
24
+ "check": "tsc --noEmit",
25
+ "test": "vitest run"
26
+ },
27
+ "openclaw": {
28
+ "extensions": [
29
+ "./dist/index.js"
30
+ ]
31
+ },
32
+ "peerDependencies": {
33
+ "openclaw": ">=2026.5.28"
34
+ },
35
+ "peerDependenciesMeta": {
36
+ "openclaw": {
37
+ "optional": false
38
+ }
39
+ },
40
+ "dependencies": {
41
+ "@sinclair/typebox": "^0.34.49",
42
+ "better-sqlite3": "^12.2.0"
43
+ },
44
+ "devDependencies": {
45
+ "@types/better-sqlite3": "^7.6.13",
46
+ "@types/node": "^25.0.0",
47
+ "openclaw": "2026.6.11",
48
+ "typescript": "^5.9.0",
49
+ "vitest": "^3.2.0"
50
+ }
51
+ }