@clawmem-ai/clawmem 0.1.5 → 0.1.7
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 +23 -5
- package/openclaw.plugin.json +34 -4
- package/package.json +1 -1
- package/src/config.ts +33 -4
- package/src/conversation.ts +40 -2
- package/src/github-client.ts +2 -2
- package/src/service.ts +143 -67
- package/src/state.ts +12 -6
- package/src/types.ts +21 -3
- package/src/utils.ts +20 -0
package/README.md
CHANGED
|
@@ -36,7 +36,7 @@ openclaw plugins install @clawmem-ai/clawmem
|
|
|
36
36
|
openclaw gateway restart
|
|
37
37
|
```
|
|
38
38
|
|
|
39
|
-
After restart, clawmem
|
|
39
|
+
After restart, clawmem provisions per-agent memory repos on `git.clawmem.ai` as each agent is first used, then writes that agent's `token` + `repo` back into your config under `plugins.entries.clawmem.config.agents.<agentId>`. Memories start accumulating from that agent's next session.
|
|
40
40
|
|
|
41
41
|
---
|
|
42
42
|
|
|
@@ -53,7 +53,7 @@ Before the workflow can publish successfully, configure the package on npmjs.com
|
|
|
53
53
|
Release flow:
|
|
54
54
|
|
|
55
55
|
1. Bump `package.json` to the version you want to ship.
|
|
56
|
-
2. Create and push a matching tag such as `0.1.
|
|
56
|
+
2. Create and push a matching tag such as `0.1.6`.
|
|
57
57
|
3. GitHub Actions runs `.github/workflows/release.yml` and publishes with OIDC. No long-lived `NPM_TOKEN` secret is required.
|
|
58
58
|
|
|
59
59
|
The workflow intentionally publishes from a tag push instead of `workflow_dispatch`, because npm validates the workflow filename exactly when using trusted publishing.
|
|
@@ -239,9 +239,15 @@ Minimal config (after auto-provisioning):
|
|
|
239
239
|
enabled: true,
|
|
240
240
|
config: {
|
|
241
241
|
baseUrl: "https://git.clawmem.ai/api/v3",
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
242
|
+
authScheme: "token",
|
|
243
|
+
agents: {
|
|
244
|
+
main: {
|
|
245
|
+
baseUrl: "https://git.clawmem.ai/api/v3",
|
|
246
|
+
repo: "owner/main-memory",
|
|
247
|
+
token: "<token>",
|
|
248
|
+
authScheme: "token"
|
|
249
|
+
}
|
|
250
|
+
}
|
|
245
251
|
}
|
|
246
252
|
}
|
|
247
253
|
}
|
|
@@ -260,6 +266,18 @@ Full config with all options:
|
|
|
260
266
|
config: {
|
|
261
267
|
baseUrl: "https://git.clawmem.ai/api/v3",
|
|
262
268
|
authScheme: "token",
|
|
269
|
+
agents: {
|
|
270
|
+
main: {
|
|
271
|
+
baseUrl: "https://git.clawmem.ai/api/v3",
|
|
272
|
+
repo: "owner/main-memory",
|
|
273
|
+
token: "<token>",
|
|
274
|
+
authScheme: "token"
|
|
275
|
+
},
|
|
276
|
+
coder: {
|
|
277
|
+
repo: "owner/coder-memory",
|
|
278
|
+
token: "<token>"
|
|
279
|
+
}
|
|
280
|
+
},
|
|
263
281
|
issueTitlePrefix: "Session: ",
|
|
264
282
|
memoryTitlePrefix: "Memory: ",
|
|
265
283
|
defaultLabels: ["source:openclaw"],
|
package/openclaw.plugin.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"id": "clawmem",
|
|
3
3
|
"name": "ClawMem",
|
|
4
|
-
"version": "0.1.
|
|
4
|
+
"version": "0.1.7",
|
|
5
5
|
"description": "Mirror OpenClaw sessions into GitHub-compatible issues and comments.",
|
|
6
6
|
"kind": "memory",
|
|
7
7
|
"configSchema": {
|
|
@@ -25,6 +25,32 @@
|
|
|
25
25
|
"type": "string",
|
|
26
26
|
"enum": ["token", "bearer"]
|
|
27
27
|
},
|
|
28
|
+
"agents": {
|
|
29
|
+
"type": "object",
|
|
30
|
+
"default": {},
|
|
31
|
+
"additionalProperties": {
|
|
32
|
+
"type": "object",
|
|
33
|
+
"additionalProperties": false,
|
|
34
|
+
"properties": {
|
|
35
|
+
"baseUrl": {
|
|
36
|
+
"type": "string",
|
|
37
|
+
"minLength": 1
|
|
38
|
+
},
|
|
39
|
+
"repo": {
|
|
40
|
+
"type": "string",
|
|
41
|
+
"pattern": "^[^/]+/[^/]+$"
|
|
42
|
+
},
|
|
43
|
+
"token": {
|
|
44
|
+
"type": "string",
|
|
45
|
+
"minLength": 1
|
|
46
|
+
},
|
|
47
|
+
"authScheme": {
|
|
48
|
+
"type": "string",
|
|
49
|
+
"enum": ["token", "bearer"]
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
},
|
|
28
54
|
"issueTitlePrefix": {
|
|
29
55
|
"type": "string"
|
|
30
56
|
},
|
|
@@ -94,16 +120,20 @@
|
|
|
94
120
|
"repo": {
|
|
95
121
|
"label": "Repository",
|
|
96
122
|
"placeholder": "owner/repo",
|
|
97
|
-
"help": "
|
|
123
|
+
"help": "Legacy single-route setting. New installs should use per-agent routes under agents.<agentId>."
|
|
98
124
|
},
|
|
99
125
|
"token": {
|
|
100
126
|
"label": "API Token",
|
|
101
127
|
"sensitive": true,
|
|
102
|
-
"help": "
|
|
128
|
+
"help": "Legacy single-route setting. New installs should use per-agent routes under agents.<agentId>."
|
|
103
129
|
},
|
|
104
130
|
"authScheme": {
|
|
105
131
|
"label": "Auth Scheme",
|
|
106
|
-
"help": "
|
|
132
|
+
"help": "Default auth scheme for per-agent routes. Automatically provisioned credentials use 'token'."
|
|
133
|
+
},
|
|
134
|
+
"agents": {
|
|
135
|
+
"label": "Agent Routes",
|
|
136
|
+
"help": "Per-agent ClawMem credentials keyed by agent id. Missing repo/token are provisioned automatically on first use."
|
|
107
137
|
},
|
|
108
138
|
"defaultLabels": {
|
|
109
139
|
"label": "Default Labels",
|
package/package.json
CHANGED
package/src/config.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// Hardcoded label/prefix constants and plugin config resolution.
|
|
2
2
|
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core";
|
|
3
|
-
import type { ClawMemPluginConfig } from "./types.js";
|
|
3
|
+
import type { ClawMemAgentConfig, ClawMemPluginConfig, ClawMemResolvedRoute } from "./types.js";
|
|
4
|
+
import { normalizeAgentId } from "./utils.js";
|
|
4
5
|
|
|
5
6
|
export const SESSION_TITLE_PREFIX = "Session: ";
|
|
6
7
|
export const MEMORY_TITLE_PREFIX = "Memory: ";
|
|
@@ -20,18 +21,46 @@ export function resolvePluginConfig(api: OpenClawPluginApi): ClawMemPluginConfig
|
|
|
20
21
|
const num = (v: unknown, d: number) => typeof v === "number" && Number.isFinite(v) ? Math.floor(v) : d;
|
|
21
22
|
const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v));
|
|
22
23
|
const baseUrl = (str(raw.baseUrl) ?? "https://git.clawmem.ai").replace(/\/+$/, "");
|
|
24
|
+
const rawAgents = raw.agents && typeof raw.agents === "object" && !Array.isArray(raw.agents)
|
|
25
|
+
? (raw.agents as Record<string, unknown>)
|
|
26
|
+
: {};
|
|
27
|
+
const agents: Record<string, ClawMemAgentConfig> = {};
|
|
28
|
+
for (const [rawAgentId, rawAgentConfig] of Object.entries(rawAgents)) {
|
|
29
|
+
if (!rawAgentConfig || typeof rawAgentConfig !== "object" || Array.isArray(rawAgentConfig)) continue;
|
|
30
|
+
const agentId = normalizeAgentId(rawAgentId);
|
|
31
|
+
const agent = rawAgentConfig as Record<string, unknown>;
|
|
32
|
+
agents[agentId] = {
|
|
33
|
+
baseUrl: str(agent.baseUrl)?.replace(/\/+$/, ""),
|
|
34
|
+
repo: str(agent.repo),
|
|
35
|
+
token: str(agent.token),
|
|
36
|
+
authScheme: agent.authScheme === "bearer" ? "bearer" : agent.authScheme === "token" ? "token" : undefined,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
23
39
|
return {
|
|
24
40
|
baseUrl: baseUrl.endsWith("/api/v3") ? baseUrl : `${baseUrl}/api/v3`,
|
|
25
|
-
repo: str(raw.repo), token: str(raw.token),
|
|
26
41
|
authScheme: raw.authScheme === "bearer" ? "bearer" : "token",
|
|
42
|
+
agents,
|
|
27
43
|
memoryRecallLimit: clamp(num(raw.memoryRecallLimit, 5), 1, 20),
|
|
28
44
|
turnCommentDelayMs: num(raw.turnCommentDelayMs, 1000),
|
|
29
45
|
summaryWaitTimeoutMs: clamp(num(raw.summaryWaitTimeoutMs, 120000), 1000, 600000),
|
|
30
46
|
};
|
|
31
47
|
}
|
|
32
48
|
|
|
33
|
-
export function
|
|
34
|
-
|
|
49
|
+
export function resolveAgentRoute(config: ClawMemPluginConfig, agentId?: string): ClawMemResolvedRoute {
|
|
50
|
+
const id = normalizeAgentId(agentId);
|
|
51
|
+
const agent = config.agents[id] ?? {};
|
|
52
|
+
const baseUrl = (agent.baseUrl ?? config.baseUrl).replace(/\/+$/, "");
|
|
53
|
+
return {
|
|
54
|
+
agentId: id,
|
|
55
|
+
baseUrl: baseUrl.endsWith("/api/v3") ? baseUrl : `${baseUrl}/api/v3`,
|
|
56
|
+
repo: agent.repo?.trim() || undefined,
|
|
57
|
+
token: agent.token?.trim() || undefined,
|
|
58
|
+
authScheme: agent.authScheme === "bearer" ? "bearer" : config.authScheme,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function isAgentConfigured(route: ClawMemResolvedRoute): boolean {
|
|
63
|
+
return Boolean(route.baseUrl && route.repo && route.token);
|
|
35
64
|
}
|
|
36
65
|
|
|
37
66
|
export function resolveLabelColor(label: string): string {
|
package/src/conversation.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import fs from "node:fs";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core";
|
|
5
|
-
import { AGENT_LABEL_PREFIX, DEFAULT_LABELS, LABEL_ACTIVE, LABEL_CLOSED, SESSION_TITLE_PREFIX } from "./config.js";
|
|
5
|
+
import { AGENT_LABEL_PREFIX, DEFAULT_LABELS, LABEL_ACTIVE, LABEL_CLOSED, SESSION_TITLE_PREFIX, extractLabelNames } from "./config.js";
|
|
6
6
|
import type { GitHubIssueClient } from "./github-client.js";
|
|
7
7
|
import { normalizeMessages, readTranscriptSnapshot } from "./transcript.js";
|
|
8
8
|
import type { ClawMemPluginConfig, NormalizedMessage, SessionMirrorState, TranscriptSnapshot } from "./types.js";
|
|
@@ -36,7 +36,17 @@ export class ConversationMirror {
|
|
|
36
36
|
}
|
|
37
37
|
|
|
38
38
|
async ensureIssue(session: SessionMirrorState, snapshot: TranscriptSnapshot): Promise<void> {
|
|
39
|
-
if (session.issueNumber)
|
|
39
|
+
if (session.issueNumber) {
|
|
40
|
+
const existing = await this.lookupBoundIssue(session);
|
|
41
|
+
if (existing && this.isBoundIssue(session, existing)) {
|
|
42
|
+
session.issueTitle = existing.title?.trim() || session.issueTitle;
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
this.api.logger.warn(
|
|
46
|
+
`clawmem: issue binding for ${session.sessionId} is stale or mismatched (${session.issueNumber}); recreating`,
|
|
47
|
+
);
|
|
48
|
+
this.resetIssueBinding(session);
|
|
49
|
+
}
|
|
40
50
|
const title = `${SESSION_TITLE_PREFIX}${session.sessionId}`;
|
|
41
51
|
const labels = this.buildLabels(session, snapshot, false);
|
|
42
52
|
const body = this.renderBody(session, snapshot, "pending", false);
|
|
@@ -143,9 +153,37 @@ export class ConversationMirror {
|
|
|
143
153
|
} catch { /* directory unreadable */ }
|
|
144
154
|
return null;
|
|
145
155
|
}
|
|
156
|
+
|
|
157
|
+
private async lookupBoundIssue(session: SessionMirrorState): Promise<{ number: number; title?: string; labels?: Array<{ name?: string } | string> } | null> {
|
|
158
|
+
if (!session.issueNumber) return null;
|
|
159
|
+
try {
|
|
160
|
+
return await this.client.getIssue(session.issueNumber);
|
|
161
|
+
} catch (error) {
|
|
162
|
+
if (isNotFoundError(error)) return null;
|
|
163
|
+
throw error;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
private isBoundIssue(session: SessionMirrorState, issue: { title?: string; labels?: Array<{ name?: string } | string> }): boolean {
|
|
168
|
+
const labels = extractLabelNames(issue.labels);
|
|
169
|
+
return labels.includes("type:conversation") && labels.includes(`session:${session.sessionId}`);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
private resetIssueBinding(session: SessionMirrorState): void {
|
|
173
|
+
session.issueNumber = undefined;
|
|
174
|
+
session.issueTitle = undefined;
|
|
175
|
+
session.lastSummaryHash = undefined;
|
|
176
|
+
session.lastMirroredCount = 0;
|
|
177
|
+
session.turnCount = 0;
|
|
178
|
+
session.finalizedAt = undefined;
|
|
179
|
+
}
|
|
146
180
|
}
|
|
147
181
|
|
|
148
182
|
async function fexists(p: string): Promise<boolean> { try { return (await fs.promises.stat(p)).isFile(); } catch { return false; } }
|
|
183
|
+
function isNotFoundError(error: unknown): boolean {
|
|
184
|
+
const text = String(error);
|
|
185
|
+
return text.includes("HTTP 404");
|
|
186
|
+
}
|
|
149
187
|
function parseSummary(raw: string): string {
|
|
150
188
|
const tryParse = (s: string): string | null => {
|
|
151
189
|
try { const p = JSON.parse(s) as { summary?: unknown }; return typeof p?.summary === "string" && p.summary.trim() ? p.summary.trim() : null; }
|
package/src/github-client.ts
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
// GitHub Issues API client for clawmem. No label caching — idempotent create-if-absent.
|
|
2
2
|
import { resolveLabelColor, labelDescription, extractLabelNames, isManagedLabel } from "./config.js";
|
|
3
|
-
import type { AnonymousSessionResponse,
|
|
3
|
+
import type { AnonymousSessionResponse, ClawMemResolvedRoute } from "./types.js";
|
|
4
4
|
|
|
5
5
|
type IssueResponse = { number: number; title?: string; body?: string; state?: string; labels?: Array<{ name?: string } | string> };
|
|
6
6
|
type ReqOpts = { allowNotFound?: boolean; allowValidationError?: boolean; omitAuth?: boolean };
|
|
7
7
|
|
|
8
8
|
export class GitHubIssueClient {
|
|
9
|
-
constructor(private readonly config:
|
|
9
|
+
constructor(private readonly config: ClawMemResolvedRoute, private readonly log: { warn?: (msg: string) => void }) {}
|
|
10
10
|
|
|
11
11
|
async createIssue(params: { title: string; body: string; labels: string[] }): Promise<IssueResponse> {
|
|
12
12
|
return this.req<IssueResponse>(this.repoPath("issues"), { method: "POST", body: JSON.stringify(params) });
|
package/src/service.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// Thin orchestrator: wires conversation mirroring, memory store, and plugin lifecycle.
|
|
2
2
|
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core";
|
|
3
|
-
import {
|
|
3
|
+
import { isAgentConfigured, resolveAgentRoute, resolvePluginConfig } from "./config.js";
|
|
4
4
|
import { ConversationMirror } from "./conversation.js";
|
|
5
5
|
import { GitHubIssueClient } from "./github-client.js";
|
|
6
6
|
import { KeyedAsyncQueue } from "./keyed-async-queue.js";
|
|
@@ -8,34 +8,29 @@ import { MemoryStore } from "./memory.js";
|
|
|
8
8
|
import { loadState, resolveStatePath, saveState } from "./state.js";
|
|
9
9
|
import { readTranscriptSnapshot } from "./transcript.js";
|
|
10
10
|
import type { ClawMemPluginConfig, PluginState, SessionMirrorState, TranscriptSnapshot } from "./types.js";
|
|
11
|
+
import { inferAgentIdFromTranscriptPath, normalizeAgentId, sessionScopeKey } from "./utils.js";
|
|
11
12
|
|
|
12
13
|
type TurnPayload = { sessionId?: string; sessionKey?: string; agentId?: string; messages: unknown[] };
|
|
13
14
|
type FinalizePayload = { sessionId?: string; sessionKey?: string; sessionFile?: string; agentId?: string; reason?: string; messages?: unknown[] };
|
|
14
15
|
|
|
15
16
|
class ClawMemService {
|
|
16
17
|
private readonly config: ClawMemPluginConfig;
|
|
17
|
-
private readonly client: GitHubIssueClient;
|
|
18
|
-
private readonly conv: ConversationMirror;
|
|
19
|
-
private readonly mem: MemoryStore;
|
|
20
18
|
private readonly queue = new KeyedAsyncQueue();
|
|
21
19
|
private readonly stateQueue = new KeyedAsyncQueue();
|
|
22
20
|
private readonly pending = new Set<Promise<unknown>>();
|
|
23
21
|
private readonly syncTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
|
24
22
|
private statePath = "";
|
|
25
|
-
private state: PluginState = { version:
|
|
23
|
+
private state: PluginState = { version: 2, sessions: {} };
|
|
26
24
|
private unsubTranscript?: () => void;
|
|
27
25
|
private loadPromise: Promise<void> | null = null;
|
|
28
|
-
private
|
|
26
|
+
private readonly configPromises = new Map<string, Promise<boolean>>();
|
|
29
27
|
|
|
30
28
|
constructor(private readonly api: OpenClawPluginApi) {
|
|
31
29
|
this.config = resolvePluginConfig(api);
|
|
32
|
-
this.client = new GitHubIssueClient(this.config, api.logger);
|
|
33
|
-
this.conv = new ConversationMirror(this.client, api, this.config);
|
|
34
|
-
this.mem = new MemoryStore(this.client, api, this.config);
|
|
35
30
|
}
|
|
36
31
|
|
|
37
32
|
register(): void {
|
|
38
|
-
this.api.on("before_agent_start", async (ev) => this.handleRecall(ev.prompt));
|
|
33
|
+
this.api.on("before_agent_start", async (ev, ctx) => this.handleRecall(ev.prompt, ctx.agentId));
|
|
39
34
|
this.api.on("agent_end", (ev, ctx) => this.scheduleTurn({ sessionId: ctx.sessionId, sessionKey: ctx.sessionKey, agentId: ctx.agentId, messages: ev.messages }));
|
|
40
35
|
this.api.on("before_reset", (ev, ctx) => this.enqueueFinalize({ sessionId: ctx.sessionId, sessionKey: ctx.sessionKey, sessionFile: ev.sessionFile, agentId: ctx.agentId, reason: ev.reason, messages: ev.messages }));
|
|
41
36
|
this.api.on("session_end", (ev, ctx) => this.enqueueFinalize({ sessionId: ev.sessionId ?? ctx.sessionId, sessionKey: ev.sessionKey ?? ctx.sessionKey, agentId: ctx.agentId, reason: "session_end" }));
|
|
@@ -45,12 +40,17 @@ class ClawMemService {
|
|
|
45
40
|
start: async (ctx) => {
|
|
46
41
|
this.statePath = resolveStatePath(ctx.stateDir);
|
|
47
42
|
await this.ensureLoaded();
|
|
48
|
-
const ok = await this.ensureConfigured();
|
|
49
43
|
this.unsubTranscript = this.api.runtime.events.onSessionTranscriptUpdate((u) => {
|
|
50
44
|
void this.track(this.handleTranscript(u.sessionFile)).catch((e) => this.warn("transcript update", e));
|
|
51
45
|
});
|
|
52
|
-
|
|
53
|
-
|
|
46
|
+
const configuredCount = Object.keys(this.config.agents).filter((agentId) => {
|
|
47
|
+
return isAgentConfigured(resolveAgentRoute(this.config, agentId));
|
|
48
|
+
}).length;
|
|
49
|
+
this.api.logger.info?.(
|
|
50
|
+
configuredCount > 0
|
|
51
|
+
? `clawmem: ready with ${configuredCount} configured agent route(s); missing routes will provision on first use via ${this.config.baseUrl}`
|
|
52
|
+
: `clawmem: ready; agent routes will provision on first use via ${this.config.baseUrl}`,
|
|
53
|
+
);
|
|
54
54
|
},
|
|
55
55
|
stop: async () => {
|
|
56
56
|
this.unsubTranscript?.();
|
|
@@ -61,11 +61,13 @@ class ClawMemService {
|
|
|
61
61
|
});
|
|
62
62
|
}
|
|
63
63
|
|
|
64
|
-
private async handleRecall(prompt: unknown): Promise<{ prependContext: string } | void> {
|
|
64
|
+
private async handleRecall(prompt: unknown, agentId?: string): Promise<{ prependContext: string } | void> {
|
|
65
65
|
if (typeof prompt !== "string" || prompt.trim().length < 5) return;
|
|
66
|
-
|
|
66
|
+
const routeAgentId = normalizeAgentId(agentId);
|
|
67
|
+
if (!(await this.ensureConfigured(routeAgentId))) return;
|
|
67
68
|
try {
|
|
68
|
-
const
|
|
69
|
+
const { mem } = this.getServices(routeAgentId);
|
|
70
|
+
const memories = await mem.search(prompt, this.config.memoryRecallLimit);
|
|
69
71
|
if (memories.length === 0) return;
|
|
70
72
|
const text = memories.map((m) => `- ${m.detail}`).join("\n");
|
|
71
73
|
return { prependContext: `<relevant-memories>\nThe following active memories may be relevant to this conversation:\n${text}\n</relevant-memories>` };
|
|
@@ -75,67 +77,84 @@ class ClawMemService {
|
|
|
75
77
|
private async handleTranscript(sessionFile: string): Promise<void> {
|
|
76
78
|
let snap: TranscriptSnapshot;
|
|
77
79
|
try { snap = await readTranscriptSnapshot(sessionFile); } catch (e) { this.warn("transcript read", e); return; }
|
|
78
|
-
if (!snap.sessionId
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
80
|
+
if (!snap.sessionId) return;
|
|
81
|
+
const agentId = this.resolveTranscriptAgentId(snap.sessionId, sessionFile);
|
|
82
|
+
if (!agentId) {
|
|
83
|
+
this.api.logger.info?.(
|
|
84
|
+
`clawmem: skipping transcript sync for ${snap.sessionId} because agent ownership could not be inferred from ${sessionFile}`,
|
|
85
|
+
);
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
const { conv } = this.getServices(agentId);
|
|
89
|
+
if (!conv.shouldMirror(snap.sessionId, snap.messages)) return;
|
|
90
|
+
if (!(await this.ensureConfigured(agentId))) return;
|
|
91
|
+
await this.enqueueSession(sessionScopeKey(snap.sessionId, agentId), async () => {
|
|
92
|
+
const s = this.getOrCreate(snap.sessionId!, agentId);
|
|
82
93
|
s.sessionFile = sessionFile;
|
|
83
94
|
s.updatedAt = new Date().toISOString();
|
|
84
|
-
await
|
|
95
|
+
await conv.ensureIssue(s, snap);
|
|
85
96
|
await this.persistState();
|
|
86
97
|
});
|
|
87
98
|
}
|
|
88
99
|
|
|
89
100
|
private scheduleTurn(p: TurnPayload): void {
|
|
90
101
|
if (!p.sessionId) return;
|
|
91
|
-
const
|
|
102
|
+
const scopeKey = sessionScopeKey(p.sessionId, p.agentId);
|
|
103
|
+
const prev = this.syncTimers.get(scopeKey);
|
|
92
104
|
if (prev) clearTimeout(prev);
|
|
93
105
|
const timer = setTimeout(() => {
|
|
94
|
-
this.syncTimers.delete(
|
|
95
|
-
void this.track(this.enqueueSession(
|
|
106
|
+
this.syncTimers.delete(scopeKey);
|
|
107
|
+
void this.track(this.enqueueSession(scopeKey, () => this.syncTurn(p))).catch((e) => this.warn("turn sync", e));
|
|
96
108
|
}, this.config.turnCommentDelayMs);
|
|
97
109
|
timer.unref?.();
|
|
98
|
-
this.syncTimers.set(
|
|
110
|
+
this.syncTimers.set(scopeKey, timer);
|
|
99
111
|
}
|
|
100
112
|
|
|
101
113
|
private async syncTurn(p: TurnPayload): Promise<void> {
|
|
102
|
-
if (!p.sessionId
|
|
103
|
-
const
|
|
104
|
-
|
|
105
|
-
const
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
await
|
|
114
|
+
if (!p.sessionId) return;
|
|
115
|
+
const agentId = normalizeAgentId(p.agentId);
|
|
116
|
+
if (!(await this.ensureConfigured(agentId))) return;
|
|
117
|
+
const { conv } = this.getServices(agentId);
|
|
118
|
+
const s = this.getOrCreate(p.sessionId, agentId);
|
|
119
|
+
s.sessionKey = p.sessionKey ?? s.sessionKey; s.agentId = agentId; s.updatedAt = new Date().toISOString();
|
|
120
|
+
const snap = await conv.loadSnapshot(s, p.messages);
|
|
121
|
+
if (!conv.shouldMirror(s.sessionId, snap.messages) || snap.messages.length === 0) { await this.persistState(); return; }
|
|
122
|
+
await conv.ensureIssue(s, snap);
|
|
123
|
+
await conv.syncLabels(s, snap, false);
|
|
109
124
|
const next = snap.messages.slice(s.lastMirroredCount);
|
|
110
|
-
if (next.length > 0) { const n = await
|
|
125
|
+
if (next.length > 0) { const n = await conv.appendComments(s.issueNumber!, next); s.lastMirroredCount += n; s.turnCount += n; }
|
|
111
126
|
await this.persistState();
|
|
112
127
|
}
|
|
113
128
|
|
|
114
129
|
private enqueueFinalize(p: FinalizePayload): void {
|
|
115
130
|
if (!p.sessionId) return;
|
|
116
|
-
const
|
|
117
|
-
|
|
118
|
-
|
|
131
|
+
const scopeKey = sessionScopeKey(p.sessionId, p.agentId);
|
|
132
|
+
const prev = this.syncTimers.get(scopeKey);
|
|
133
|
+
if (prev) { clearTimeout(prev); this.syncTimers.delete(scopeKey); }
|
|
134
|
+
void this.track(this.enqueueSession(scopeKey, () => this.finalize(p))).catch((e) => this.warn("finalize", e));
|
|
119
135
|
}
|
|
120
136
|
|
|
121
137
|
private async finalize(p: FinalizePayload): Promise<void> {
|
|
122
|
-
if (!p.sessionId
|
|
123
|
-
const
|
|
138
|
+
if (!p.sessionId) return;
|
|
139
|
+
const agentId = normalizeAgentId(p.agentId);
|
|
140
|
+
if (!(await this.ensureConfigured(agentId))) return;
|
|
141
|
+
const { conv, mem } = this.getServices(agentId);
|
|
142
|
+
const s = this.getOrCreate(p.sessionId, agentId);
|
|
124
143
|
if (s.finalizedAt) return;
|
|
125
144
|
s.sessionKey = p.sessionKey ?? s.sessionKey; s.sessionFile = p.sessionFile ?? s.sessionFile;
|
|
126
|
-
s.agentId =
|
|
127
|
-
const snap = await
|
|
128
|
-
if (!
|
|
145
|
+
s.agentId = agentId; s.updatedAt = new Date().toISOString();
|
|
146
|
+
const snap = await conv.loadSnapshot(s, p.messages ?? []);
|
|
147
|
+
if (!conv.shouldMirror(s.sessionId, snap.messages)) { await this.persistState(); return; }
|
|
129
148
|
if (snap.messages.length === 0 && !s.issueNumber) { await this.persistState(); return; }
|
|
130
|
-
await
|
|
149
|
+
await conv.ensureIssue(s, snap);
|
|
131
150
|
const next = snap.messages.slice(s.lastMirroredCount);
|
|
132
151
|
let allOk = true;
|
|
133
|
-
if (next.length > 0) { const n = await
|
|
152
|
+
if (next.length > 0) { const n = await conv.appendComments(s.issueNumber!, next); s.lastMirroredCount += n; s.turnCount += n; allOk = n === next.length; }
|
|
134
153
|
let summary = "pending";
|
|
135
|
-
try { summary = await
|
|
136
|
-
await
|
|
137
|
-
await
|
|
138
|
-
await
|
|
154
|
+
try { summary = await conv.generateSummary(s, snap); } catch (e) { summary = `failed: ${String(e)}`; }
|
|
155
|
+
await conv.syncLabels(s, snap, true);
|
|
156
|
+
await conv.syncBody(s, snap, summary, true);
|
|
157
|
+
await mem.syncFromConversation(s, snap);
|
|
139
158
|
if (allOk) s.finalizedAt = new Date().toISOString();
|
|
140
159
|
await this.persistState();
|
|
141
160
|
}
|
|
@@ -146,15 +165,41 @@ class ClawMemService {
|
|
|
146
165
|
return this.queue.enqueue(sessionId, async () => { await this.ensureLoaded(); return task(); });
|
|
147
166
|
}
|
|
148
167
|
private track<T>(promise: Promise<T>): Promise<T> {
|
|
149
|
-
this.pending.add(promise);
|
|
168
|
+
this.pending.add(promise);
|
|
169
|
+
// Avoid creating a second rejecting promise via finally(); OpenClaw treats
|
|
170
|
+
// unhandled rejections as fatal and exits the gateway process.
|
|
171
|
+
void promise.then(
|
|
172
|
+
() => this.pending.delete(promise),
|
|
173
|
+
() => this.pending.delete(promise),
|
|
174
|
+
);
|
|
175
|
+
return promise;
|
|
150
176
|
}
|
|
151
|
-
private getOrCreate(sessionId: string): SessionMirrorState {
|
|
152
|
-
|
|
177
|
+
private getOrCreate(sessionId: string, agentId?: string): SessionMirrorState {
|
|
178
|
+
const scopeKey = sessionScopeKey(sessionId, agentId);
|
|
179
|
+
if (this.state.sessions[scopeKey]) return this.state.sessions[scopeKey];
|
|
153
180
|
const now = new Date().toISOString();
|
|
154
|
-
const s: SessionMirrorState = {
|
|
155
|
-
|
|
181
|
+
const s: SessionMirrorState = {
|
|
182
|
+
sessionId,
|
|
183
|
+
agentId: normalizeAgentId(agentId),
|
|
184
|
+
lastMirroredCount: 0,
|
|
185
|
+
turnCount: 0,
|
|
186
|
+
createdAt: now,
|
|
187
|
+
updatedAt: now,
|
|
188
|
+
};
|
|
189
|
+
this.state.sessions[scopeKey] = s;
|
|
156
190
|
return s;
|
|
157
191
|
}
|
|
192
|
+
private resolveTranscriptAgentId(sessionId: string, sessionFile: string): string | null {
|
|
193
|
+
const fromPath = inferAgentIdFromTranscriptPath(sessionFile);
|
|
194
|
+
if (fromPath) return fromPath;
|
|
195
|
+
const knownAgents = new Set(
|
|
196
|
+
Object.values(this.state.sessions)
|
|
197
|
+
.filter((session) => session.sessionId === sessionId)
|
|
198
|
+
.map((session) => normalizeAgentId(session.agentId)),
|
|
199
|
+
);
|
|
200
|
+
if (knownAgents.size === 1) return [...knownAgents][0] ?? null;
|
|
201
|
+
return null;
|
|
202
|
+
}
|
|
158
203
|
private async persistState(): Promise<void> {
|
|
159
204
|
if (!this.statePath) this.statePath = resolveStatePath(this.api.runtime.state.resolveStateDir());
|
|
160
205
|
await this.stateQueue.enqueue("state", () => saveState(this.statePath, this.state));
|
|
@@ -167,29 +212,60 @@ class ClawMemService {
|
|
|
167
212
|
})();
|
|
168
213
|
return this.loadPromise;
|
|
169
214
|
}
|
|
170
|
-
private async ensureConfigured(): Promise<boolean> {
|
|
171
|
-
|
|
172
|
-
if (this.
|
|
173
|
-
const
|
|
174
|
-
|
|
175
|
-
|
|
215
|
+
private async ensureConfigured(agentId?: string): Promise<boolean> {
|
|
216
|
+
const id = normalizeAgentId(agentId);
|
|
217
|
+
if (isAgentConfigured(resolveAgentRoute(this.config, id))) return true;
|
|
218
|
+
const pending = this.configPromises.get(id);
|
|
219
|
+
if (pending) return pending;
|
|
220
|
+
const p = this.bootstrap(id);
|
|
221
|
+
this.configPromises.set(id, p);
|
|
222
|
+
try { return await p; } finally { if (this.configPromises.get(id) === p) this.configPromises.delete(id); }
|
|
176
223
|
}
|
|
177
|
-
private async bootstrap(): Promise<boolean> {
|
|
178
|
-
|
|
224
|
+
private async bootstrap(agentId: string): Promise<boolean> {
|
|
225
|
+
const route = resolveAgentRoute(this.config, agentId);
|
|
226
|
+
if (!route.baseUrl) { this.api.logger.warn(`clawmem: cannot provision Git credentials for ${agentId} without a baseUrl`); return false; }
|
|
179
227
|
try {
|
|
180
|
-
const
|
|
181
|
-
|
|
182
|
-
this.
|
|
183
|
-
this.
|
|
228
|
+
const client = new GitHubIssueClient(route, this.api.logger);
|
|
229
|
+
const sess = await client.createAnonymousSession();
|
|
230
|
+
await this.persistAgentConfig(agentId, { baseUrl: route.baseUrl, authScheme: "token", token: sess.token, repo: sess.repo_full_name });
|
|
231
|
+
this.config.agents[agentId] = { ...(this.config.agents[agentId] ?? {}), baseUrl: route.baseUrl, authScheme: "token", token: sess.token, repo: sess.repo_full_name };
|
|
232
|
+
this.api.logger.info?.(`clawmem: provisioned Git credentials for agent ${agentId} -> ${sess.repo_full_name} via ${route.baseUrl}`);
|
|
184
233
|
return true;
|
|
185
|
-
} catch (error) { this.api.logger.warn(`clawmem: failed to provision Git credentials via ${
|
|
234
|
+
} catch (error) { this.api.logger.warn(`clawmem: failed to provision Git credentials for agent ${agentId} via ${route.baseUrl}: ${String(error)}`); return false; }
|
|
186
235
|
}
|
|
187
|
-
private async
|
|
236
|
+
private async persistAgentConfig(agentId: string, values: { baseUrl: string; authScheme: "token" | "bearer"; token: string; repo: string }): Promise<void> {
|
|
188
237
|
const root = this.api.runtime.config.loadConfig();
|
|
189
238
|
const plugins = root.plugins;
|
|
190
239
|
const entries = plugins?.entries && typeof plugins.entries === "object" && !Array.isArray(plugins.entries) ? (plugins.entries as Record<string, unknown>) : {};
|
|
191
240
|
const ex = asRecord(entries[this.api.id]), exCfg = asRecord(ex.config);
|
|
192
|
-
|
|
241
|
+
const agents = exCfg.agents && typeof exCfg.agents === "object" && !Array.isArray(exCfg.agents) ? (exCfg.agents as Record<string, unknown>) : {};
|
|
242
|
+
const existingAgent = asRecord(agents[agentId]);
|
|
243
|
+
await this.api.runtime.config.writeConfigFile({
|
|
244
|
+
...root,
|
|
245
|
+
plugins: {
|
|
246
|
+
...(plugins ?? {}),
|
|
247
|
+
entries: {
|
|
248
|
+
...entries,
|
|
249
|
+
[this.api.id]: {
|
|
250
|
+
...ex,
|
|
251
|
+
config: {
|
|
252
|
+
...exCfg,
|
|
253
|
+
agents: {
|
|
254
|
+
...agents,
|
|
255
|
+
[agentId]: { ...existingAgent, ...values },
|
|
256
|
+
},
|
|
257
|
+
},
|
|
258
|
+
},
|
|
259
|
+
},
|
|
260
|
+
},
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
private getServices(agentId?: string): { conv: ConversationMirror; mem: MemoryStore } {
|
|
264
|
+
const client = new GitHubIssueClient(resolveAgentRoute(this.config, agentId), this.api.logger);
|
|
265
|
+
return {
|
|
266
|
+
conv: new ConversationMirror(client, this.api, this.config),
|
|
267
|
+
mem: new MemoryStore(client, this.api, this.config),
|
|
268
|
+
};
|
|
193
269
|
}
|
|
194
270
|
private warn(scope: string, error: unknown): void { this.api.logger.warn(`clawmem: ${scope} failed: ${String(error)}`); }
|
|
195
271
|
}
|
package/src/state.ts
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import type { PluginState } from "./types.js";
|
|
4
|
+
import { normalizeAgentId, sessionScopeKey } from "./utils.js";
|
|
4
5
|
|
|
5
6
|
const EMPTY_STATE: PluginState = {
|
|
6
|
-
version:
|
|
7
|
+
version: 2,
|
|
7
8
|
sessions: {},
|
|
8
9
|
};
|
|
9
10
|
|
|
@@ -42,19 +43,24 @@ function sanitizeState(value: unknown): PluginState {
|
|
|
42
43
|
? (raw.sessions as Record<string, unknown>)
|
|
43
44
|
: {};
|
|
44
45
|
const out: PluginState = {
|
|
45
|
-
version:
|
|
46
|
+
version: 2,
|
|
46
47
|
sessions: {},
|
|
47
48
|
};
|
|
48
|
-
for (const [
|
|
49
|
-
if (!sessionValue || typeof sessionValue !== "object" || !
|
|
49
|
+
for (const [storedKey, sessionValue] of Object.entries(sessions)) {
|
|
50
|
+
if (!sessionValue || typeof sessionValue !== "object" || !storedKey.trim()) {
|
|
50
51
|
continue;
|
|
51
52
|
}
|
|
52
53
|
const rawSession = sessionValue as Record<string, unknown>;
|
|
53
|
-
|
|
54
|
+
const sessionId = readString(rawSession.sessionId) ?? storedKey.trim();
|
|
55
|
+
if (!sessionId) {
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
const agentId = normalizeAgentId(readString(rawSession.agentId));
|
|
59
|
+
out.sessions[sessionScopeKey(sessionId, agentId)] = {
|
|
54
60
|
sessionId,
|
|
55
61
|
sessionKey: readString(rawSession.sessionKey),
|
|
56
62
|
sessionFile: readString(rawSession.sessionFile),
|
|
57
|
-
agentId
|
|
63
|
+
agentId,
|
|
58
64
|
issueNumber: readNumber(rawSession.issueNumber),
|
|
59
65
|
issueTitle: readString(rawSession.issueTitle),
|
|
60
66
|
lastMirroredCount: readNumber(rawSession.lastMirroredCount) ?? 0,
|
package/src/types.ts
CHANGED
|
@@ -1,10 +1,28 @@
|
|
|
1
1
|
// Shared types for the clawmem plugin.
|
|
2
|
+
export type ClawMemAgentConfig = {
|
|
3
|
+
baseUrl?: string;
|
|
4
|
+
repo?: string;
|
|
5
|
+
token?: string;
|
|
6
|
+
authScheme?: "token" | "bearer";
|
|
7
|
+
};
|
|
8
|
+
|
|
2
9
|
export type ClawMemPluginConfig = {
|
|
3
|
-
baseUrl
|
|
10
|
+
baseUrl: string;
|
|
4
11
|
authScheme: "token" | "bearer";
|
|
5
|
-
|
|
12
|
+
agents: Record<string, ClawMemAgentConfig>;
|
|
13
|
+
memoryRecallLimit: number;
|
|
14
|
+
turnCommentDelayMs: number;
|
|
6
15
|
summaryWaitTimeoutMs: number;
|
|
7
16
|
};
|
|
17
|
+
|
|
18
|
+
export type ClawMemResolvedRoute = {
|
|
19
|
+
agentId: string;
|
|
20
|
+
baseUrl: string;
|
|
21
|
+
repo?: string;
|
|
22
|
+
token?: string;
|
|
23
|
+
authScheme: "token" | "bearer";
|
|
24
|
+
};
|
|
25
|
+
|
|
8
26
|
export type AnonymousSessionResponse = { token: string; owner_login: string; repo_name: string; repo_full_name: string };
|
|
9
27
|
export type SessionMirrorState = {
|
|
10
28
|
sessionId: string; sessionKey?: string; sessionFile?: string; agentId?: string;
|
|
@@ -13,7 +31,7 @@ export type SessionMirrorState = {
|
|
|
13
31
|
finalizedAt?: string; lastSummaryHash?: string; lastTurnHash?: string;
|
|
14
32
|
createdAt?: string; updatedAt?: string;
|
|
15
33
|
};
|
|
16
|
-
export type PluginState = { version:
|
|
34
|
+
export type PluginState = { version: 2; sessions: Record<string, SessionMirrorState> };
|
|
17
35
|
export type NormalizedMessage = { role: string; text: string; toolName?: string; timestamp?: string; stopReason?: string };
|
|
18
36
|
export type TranscriptSnapshot = { sessionId?: string; messages: NormalizedMessage[] };
|
|
19
37
|
export type ParsedMemoryIssue = {
|
package/src/utils.ts
CHANGED
|
@@ -1,9 +1,29 @@
|
|
|
1
1
|
// Shared utility helpers used by memory.ts and conversation.ts.
|
|
2
2
|
import crypto from "node:crypto";
|
|
3
|
+
import path from "node:path";
|
|
3
4
|
import type { NormalizedMessage } from "./types.js";
|
|
4
5
|
|
|
6
|
+
export const DEFAULT_AGENT_ID = "main";
|
|
7
|
+
|
|
5
8
|
export function sha256(v: string): string { return crypto.createHash("sha256").update(v).digest("hex"); }
|
|
6
9
|
|
|
10
|
+
export function normalizeAgentId(value: string | undefined | null): string {
|
|
11
|
+
const trimmed = (value ?? "").trim().toLowerCase();
|
|
12
|
+
if (!trimmed) return DEFAULT_AGENT_ID;
|
|
13
|
+
return trimmed.replace(/[^a-z0-9_-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 64) || DEFAULT_AGENT_ID;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function sessionScopeKey(sessionId: string, agentId?: string): string {
|
|
17
|
+
return `${normalizeAgentId(agentId)}:${sessionId.trim()}`;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function inferAgentIdFromTranscriptPath(filePath: string): string | undefined {
|
|
21
|
+
const parts = path.resolve(filePath).split(path.sep);
|
|
22
|
+
const idx = parts.lastIndexOf("agents");
|
|
23
|
+
if (idx < 0 || !parts[idx + 1] || parts[idx + 2] !== "sessions") return undefined;
|
|
24
|
+
return normalizeAgentId(parts[idx + 1]);
|
|
25
|
+
}
|
|
26
|
+
|
|
7
27
|
export function subKey(s: { sessionId: string; agentId?: string }, suffix: string): string {
|
|
8
28
|
const san = (v: string) => v.replace(/[^a-zA-Z0-9_-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 48) || "main";
|
|
9
29
|
return `agent:${san(s.agentId || "main")}:subagent:clawmem-${suffix}-${san(s.sessionId)}`;
|