@clawmem-ai/clawmem 0.1.3
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 +271 -0
- package/index.ts +6 -0
- package/openclaw.plugin.json +159 -0
- package/package.json +25 -0
- package/src/config.ts +69 -0
- package/src/conversation.ts +162 -0
- package/src/github-client.ts +64 -0
- package/src/keyed-async-queue.ts +26 -0
- package/src/memory.ts +157 -0
- package/src/service.ts +199 -0
- package/src/state.ts +85 -0
- package/src/transcript.ts +186 -0
- package/src/types.ts +23 -0
- package/src/utils.ts +22 -0
- package/src/yaml.ts +88 -0
- package/tsconfig.json +14 -0
package/src/state.ts
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import type { PluginState } from "./types.js";
|
|
4
|
+
|
|
5
|
+
const EMPTY_STATE: PluginState = {
|
|
6
|
+
version: 1,
|
|
7
|
+
sessions: {},
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export function resolveStatePath(stateDir: string): string {
|
|
11
|
+
return path.join(stateDir, "clawmem", "state.json");
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export async function loadState(filePath: string): Promise<PluginState> {
|
|
15
|
+
try {
|
|
16
|
+
const raw = await fs.promises.readFile(filePath, "utf8");
|
|
17
|
+
return sanitizeState(JSON.parse(raw));
|
|
18
|
+
} catch (error) {
|
|
19
|
+
const code = (error as { code?: string }).code;
|
|
20
|
+
if (code === "ENOENT") {
|
|
21
|
+
return structuredClone(EMPTY_STATE);
|
|
22
|
+
}
|
|
23
|
+
return structuredClone(EMPTY_STATE);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function saveState(filePath: string, state: PluginState): Promise<void> {
|
|
28
|
+
await fs.promises.mkdir(path.dirname(filePath), { recursive: true, mode: 0o700 });
|
|
29
|
+
const next = JSON.stringify(state, null, 2) + "\n";
|
|
30
|
+
const tmpPath = `${filePath}.tmp-${process.pid}-${Date.now()}`;
|
|
31
|
+
await fs.promises.writeFile(tmpPath, next, { encoding: "utf8", mode: 0o600 });
|
|
32
|
+
await fs.promises.rename(tmpPath, filePath);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function sanitizeState(value: unknown): PluginState {
|
|
36
|
+
if (!value || typeof value !== "object") {
|
|
37
|
+
return structuredClone(EMPTY_STATE);
|
|
38
|
+
}
|
|
39
|
+
const raw = value as Record<string, unknown>;
|
|
40
|
+
const sessions =
|
|
41
|
+
raw.sessions && typeof raw.sessions === "object"
|
|
42
|
+
? (raw.sessions as Record<string, unknown>)
|
|
43
|
+
: {};
|
|
44
|
+
const out: PluginState = {
|
|
45
|
+
version: 1,
|
|
46
|
+
sessions: {},
|
|
47
|
+
};
|
|
48
|
+
for (const [sessionId, sessionValue] of Object.entries(sessions)) {
|
|
49
|
+
if (!sessionValue || typeof sessionValue !== "object" || !sessionId.trim()) {
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
const rawSession = sessionValue as Record<string, unknown>;
|
|
53
|
+
out.sessions[sessionId] = {
|
|
54
|
+
sessionId,
|
|
55
|
+
sessionKey: readString(rawSession.sessionKey),
|
|
56
|
+
sessionFile: readString(rawSession.sessionFile),
|
|
57
|
+
agentId: readString(rawSession.agentId),
|
|
58
|
+
issueNumber: readNumber(rawSession.issueNumber),
|
|
59
|
+
issueTitle: readString(rawSession.issueTitle),
|
|
60
|
+
lastMirroredCount: readNumber(rawSession.lastMirroredCount) ?? 0,
|
|
61
|
+
turnCount: readNumber(rawSession.turnCount) ?? 0,
|
|
62
|
+
finalizedAt: readString(rawSession.finalizedAt),
|
|
63
|
+
lastSummaryHash: readString(rawSession.lastSummaryHash),
|
|
64
|
+
lastTurnHash: readString(rawSession.lastTurnHash),
|
|
65
|
+
createdAt: readString(rawSession.createdAt),
|
|
66
|
+
updatedAt: readString(rawSession.updatedAt),
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
return out;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function readString(value: unknown): string | undefined {
|
|
73
|
+
if (typeof value !== "string") {
|
|
74
|
+
return undefined;
|
|
75
|
+
}
|
|
76
|
+
const trimmed = value.trim();
|
|
77
|
+
return trimmed ? trimmed : undefined;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function readNumber(value: unknown): number | undefined {
|
|
81
|
+
if (typeof value !== "number" || !Number.isFinite(value)) {
|
|
82
|
+
return undefined;
|
|
83
|
+
}
|
|
84
|
+
return Math.max(0, Math.floor(value));
|
|
85
|
+
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import type { NormalizedMessage, TranscriptSnapshot } from "./types.js";
|
|
3
|
+
|
|
4
|
+
export async function readTranscriptSnapshot(filePath: string): Promise<TranscriptSnapshot> {
|
|
5
|
+
const raw = await fs.promises.readFile(filePath, "utf8");
|
|
6
|
+
const lines = raw
|
|
7
|
+
.split(/\r?\n/)
|
|
8
|
+
.map((line) => line.trim())
|
|
9
|
+
.filter((line) => line.length > 0);
|
|
10
|
+
|
|
11
|
+
let sessionId: string | undefined;
|
|
12
|
+
const messages: NormalizedMessage[] = [];
|
|
13
|
+
|
|
14
|
+
for (const line of lines) {
|
|
15
|
+
let parsed: unknown;
|
|
16
|
+
try {
|
|
17
|
+
parsed = JSON.parse(line);
|
|
18
|
+
} catch {
|
|
19
|
+
continue;
|
|
20
|
+
}
|
|
21
|
+
const record = asRecord(parsed);
|
|
22
|
+
if (!record) {
|
|
23
|
+
continue;
|
|
24
|
+
}
|
|
25
|
+
if (!sessionId && record.type === "session" && typeof record.id === "string") {
|
|
26
|
+
sessionId = record.id.trim() || undefined;
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
29
|
+
const message = normalizeMessage(record.message ?? record);
|
|
30
|
+
if (message) {
|
|
31
|
+
messages.push(message);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return { sessionId, messages };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function normalizeMessages(items: unknown[]): NormalizedMessage[] {
|
|
39
|
+
const out: NormalizedMessage[] = [];
|
|
40
|
+
for (const item of items) {
|
|
41
|
+
const record = asRecord(item);
|
|
42
|
+
if (!record) {
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
const message = normalizeMessage(record.message ?? record);
|
|
46
|
+
if (message) {
|
|
47
|
+
out.push(message);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return out;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function normalizeMessage(value: unknown): NormalizedMessage | null {
|
|
54
|
+
const record = asRecord(value);
|
|
55
|
+
if (!record) {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
const role = typeof record.role === "string" ? record.role : undefined;
|
|
59
|
+
if (role !== "assistant" && role !== "user") {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
if (record.tool_call_id || record.toolCallId) {
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const directText = typeof record.text === "string" ? normalizeChatText(record.text) : null;
|
|
67
|
+
const text = directText ?? extractChatText(record.content);
|
|
68
|
+
if (!text) {
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const timestamp = normalizeTimestamp(record.timestamp);
|
|
73
|
+
const stopReason = typeof record.stopReason === "string" ? record.stopReason : undefined;
|
|
74
|
+
return {
|
|
75
|
+
role,
|
|
76
|
+
text,
|
|
77
|
+
...(timestamp ? { timestamp } : {}),
|
|
78
|
+
...(stopReason ? { stopReason } : {}),
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function extractChatText(content: unknown): string {
|
|
83
|
+
if (typeof content === "string") {
|
|
84
|
+
return normalizeChatText(content) ?? "";
|
|
85
|
+
}
|
|
86
|
+
if (!Array.isArray(content)) {
|
|
87
|
+
return "";
|
|
88
|
+
}
|
|
89
|
+
const parts: string[] = [];
|
|
90
|
+
for (const block of content) {
|
|
91
|
+
if (typeof block === "string") {
|
|
92
|
+
const normalized = normalizeChatText(block);
|
|
93
|
+
if (normalized) {
|
|
94
|
+
parts.push(normalized);
|
|
95
|
+
}
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
const record = asRecord(block);
|
|
99
|
+
if (!record) {
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
const type = typeof record.type === "string" ? record.type.toLowerCase() : "";
|
|
103
|
+
if (type.includes("tool")) {
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
const textCandidates = [
|
|
107
|
+
record.text,
|
|
108
|
+
record.value,
|
|
109
|
+
record.content,
|
|
110
|
+
record.outputText,
|
|
111
|
+
record.inputText,
|
|
112
|
+
];
|
|
113
|
+
for (const candidate of textCandidates) {
|
|
114
|
+
if (typeof candidate === "string") {
|
|
115
|
+
const normalized = normalizeChatText(candidate);
|
|
116
|
+
if (normalized) {
|
|
117
|
+
parts.push(normalized);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return compactText(parts);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function normalizeChatText(value: string): string | null {
|
|
126
|
+
let trimmed = stripUntrustedMetadataPrefixes(squashWhitespace(value));
|
|
127
|
+
if (!trimmed) {
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
trimmed = trimmed.replace(/^\[\[\s*reply_to[^\]]*\]\]\s*/i, "").trim();
|
|
131
|
+
if (!trimmed) {
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
const upper = trimmed.toUpperCase();
|
|
135
|
+
if (upper === "NO_REPLY" || upper === "HEARTBEAT_OK" || upper === "IDLE-CHAT") {
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
return trimmed;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function normalizeTimestamp(value: unknown): string | undefined {
|
|
142
|
+
if (typeof value === "number" && Number.isFinite(value)) {
|
|
143
|
+
return new Date(value).toISOString();
|
|
144
|
+
}
|
|
145
|
+
if (typeof value === "string") {
|
|
146
|
+
const trimmed = value.trim();
|
|
147
|
+
return trimmed || undefined;
|
|
148
|
+
}
|
|
149
|
+
return undefined;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function compactText(parts: Array<string | undefined>): string {
|
|
153
|
+
return parts
|
|
154
|
+
.map((part) => (typeof part === "string" ? part.trim() : ""))
|
|
155
|
+
.filter((part) => part.length > 0)
|
|
156
|
+
.join("\n\n");
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function squashWhitespace(value: string): string {
|
|
160
|
+
return value.replace(/\r/g, "").replace(/\n{3,}/g, "\n\n").trim();
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function stripUntrustedMetadataPrefixes(value: string): string {
|
|
164
|
+
let current = value.trim();
|
|
165
|
+
|
|
166
|
+
for (;;) {
|
|
167
|
+
const next = current
|
|
168
|
+
.replace(
|
|
169
|
+
/^Conversation info \(untrusted metadata\):\s*```(?:json)?\s*[\s\S]*?```\s*/i,
|
|
170
|
+
"",
|
|
171
|
+
)
|
|
172
|
+
.replace(
|
|
173
|
+
/^Sender \(untrusted metadata\):\s*```(?:json)?\s*[\s\S]*?```\s*/i,
|
|
174
|
+
"",
|
|
175
|
+
)
|
|
176
|
+
.trim();
|
|
177
|
+
if (next === current) {
|
|
178
|
+
return current;
|
|
179
|
+
}
|
|
180
|
+
current = next;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function asRecord(value: unknown): Record<string, unknown> | null {
|
|
185
|
+
return value && typeof value === "object" ? (value as Record<string, unknown>) : null;
|
|
186
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// Shared types for the clawmem plugin.
|
|
2
|
+
export type ClawMemPluginConfig = {
|
|
3
|
+
baseUrl?: string; repo?: string; token?: string;
|
|
4
|
+
authScheme: "token" | "bearer";
|
|
5
|
+
memoryRecallLimit: number; turnCommentDelayMs: number;
|
|
6
|
+
summaryWaitTimeoutMs: number;
|
|
7
|
+
};
|
|
8
|
+
export type AnonymousSessionResponse = { token: string; owner_login: string; repo_name: string; repo_full_name: string };
|
|
9
|
+
export type SessionMirrorState = {
|
|
10
|
+
sessionId: string; sessionKey?: string; sessionFile?: string; agentId?: string;
|
|
11
|
+
issueNumber?: number; issueTitle?: string;
|
|
12
|
+
lastMirroredCount: number; turnCount: number; lastAssistantText?: string;
|
|
13
|
+
finalizedAt?: string; lastSummaryHash?: string; lastTurnHash?: string;
|
|
14
|
+
createdAt?: string; updatedAt?: string;
|
|
15
|
+
};
|
|
16
|
+
export type PluginState = { version: 1; sessions: Record<string, SessionMirrorState> };
|
|
17
|
+
export type NormalizedMessage = { role: string; text: string; toolName?: string; timestamp?: string; stopReason?: string };
|
|
18
|
+
export type TranscriptSnapshot = { sessionId?: string; messages: NormalizedMessage[] };
|
|
19
|
+
export type ParsedMemoryIssue = {
|
|
20
|
+
issueNumber: number; title: string; memoryId: string; memoryHash?: string;
|
|
21
|
+
sessionId: string; date: string; detail: string;
|
|
22
|
+
topics?: string[]; status: "active" | "stale";
|
|
23
|
+
};
|
package/src/utils.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// Shared utility helpers used by memory.ts and conversation.ts.
|
|
2
|
+
import crypto from "node:crypto";
|
|
3
|
+
import type { NormalizedMessage } from "./types.js";
|
|
4
|
+
|
|
5
|
+
export function sha256(v: string): string { return crypto.createHash("sha256").update(v).digest("hex"); }
|
|
6
|
+
|
|
7
|
+
export function subKey(s: { sessionId: string; agentId?: string }, suffix: string): string {
|
|
8
|
+
const san = (v: string) => v.replace(/[^a-zA-Z0-9_-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 48) || "main";
|
|
9
|
+
return `agent:${san(s.agentId || "main")}:subagent:clawmem-${suffix}-${san(s.sessionId)}`;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function fmtTranscript(msgs: NormalizedMessage[]): string {
|
|
13
|
+
return msgs.map((m, i) => `${i + 1}. ${m.role === "assistant" ? "assistant" : "user"}: ${m.text}`).join("\n\n");
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function localDate(d: Date = new Date()): string {
|
|
17
|
+
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function localDateTime(d: Date): string {
|
|
21
|
+
return `${localDate(d)}T${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}:${String(d.getSeconds()).padStart(2, "0")}`;
|
|
22
|
+
}
|
package/src/yaml.ts
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
export function stringifyFlatYaml(
|
|
2
|
+
entries: Array<[key: string, value: string | undefined]>,
|
|
3
|
+
): string {
|
|
4
|
+
const out: string[] = [];
|
|
5
|
+
for (const [key, rawValue] of entries) {
|
|
6
|
+
const value = rawValue ?? "";
|
|
7
|
+
if (value.includes("\n")) {
|
|
8
|
+
out.push(`${key}: |-`);
|
|
9
|
+
for (const line of value.split("\n")) {
|
|
10
|
+
out.push(` ${line}`);
|
|
11
|
+
}
|
|
12
|
+
continue;
|
|
13
|
+
}
|
|
14
|
+
out.push(`${key}: ${formatScalar(value)}`);
|
|
15
|
+
}
|
|
16
|
+
return out.join("\n");
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function parseFlatYaml(input: string): Record<string, string> {
|
|
20
|
+
const result: Record<string, string> = {};
|
|
21
|
+
const lines = input.replace(/\r/g, "").split("\n");
|
|
22
|
+
|
|
23
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
24
|
+
const line = lines[index] ?? "";
|
|
25
|
+
if (!line.trim()) {
|
|
26
|
+
continue;
|
|
27
|
+
}
|
|
28
|
+
const match = /^([A-Za-z0-9_]+):(?:\s(.*))?$/.exec(line);
|
|
29
|
+
if (!match) {
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
const key = match[1];
|
|
33
|
+
const rawValue = match[2] ?? "";
|
|
34
|
+
if (rawValue === "|-" || rawValue === "|") {
|
|
35
|
+
const block: string[] = [];
|
|
36
|
+
let cursor = index + 1;
|
|
37
|
+
while (cursor < lines.length) {
|
|
38
|
+
const blockLine = lines[cursor] ?? "";
|
|
39
|
+
if (blockLine.startsWith(" ")) {
|
|
40
|
+
block.push(blockLine.slice(2));
|
|
41
|
+
cursor += 1;
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
if (!blockLine.trim()) {
|
|
45
|
+
block.push("");
|
|
46
|
+
cursor += 1;
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
break;
|
|
50
|
+
}
|
|
51
|
+
result[key] = block.join("\n");
|
|
52
|
+
index = cursor - 1;
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
result[key] = parseScalar(rawValue.trim());
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return result;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function formatScalar(value: string): string {
|
|
62
|
+
if (value.length === 0) {
|
|
63
|
+
return '""';
|
|
64
|
+
}
|
|
65
|
+
if (/^[A-Za-z0-9_./:@ -]+$/.test(value) && !looksLikeYamlKeyword(value)) {
|
|
66
|
+
return value;
|
|
67
|
+
}
|
|
68
|
+
return JSON.stringify(value);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function parseScalar(value: string): string {
|
|
72
|
+
if (!value) {
|
|
73
|
+
return "";
|
|
74
|
+
}
|
|
75
|
+
if (value.startsWith('"') && value.endsWith('"')) {
|
|
76
|
+
try {
|
|
77
|
+
return JSON.parse(value) as string;
|
|
78
|
+
} catch {
|
|
79
|
+
return value.slice(1, -1);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return value;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function looksLikeYamlKeyword(value: string): boolean {
|
|
86
|
+
const lowered = value.trim().toLowerCase();
|
|
87
|
+
return lowered === "null" || lowered === "true" || lowered === "false" || lowered === "~";
|
|
88
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"extends": "../openclaw/tsconfig.json",
|
|
3
|
+
"compilerOptions": {
|
|
4
|
+
"baseUrl": ".",
|
|
5
|
+
"paths": {
|
|
6
|
+
"openclaw/plugin-sdk": ["../openclaw/src/plugin-sdk/index.ts"],
|
|
7
|
+
"openclaw/plugin-sdk/*": ["../openclaw/src/plugin-sdk/*.ts"],
|
|
8
|
+
"openclaw/plugin-sdk/account-id": ["../openclaw/src/plugin-sdk/account-id.ts"]
|
|
9
|
+
},
|
|
10
|
+
"typeRoots": ["../openclaw/node_modules/@types"],
|
|
11
|
+
"types": ["node"]
|
|
12
|
+
},
|
|
13
|
+
"include": ["./index.ts", "./src/**/*.ts"]
|
|
14
|
+
}
|