@clawmem-ai/clawmem 0.1.18 → 0.1.19
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 +28 -9
- package/dist/index.d.ts +2 -0
- package/dist/index.js +4 -0
- package/dist/src/collaboration.d.ts +49 -0
- package/dist/src/collaboration.js +69 -0
- package/dist/src/config.d.ts +21 -0
- package/dist/src/config.js +119 -0
- package/dist/src/conversation.d.ts +30 -0
- package/dist/src/conversation.js +323 -0
- package/dist/src/github-client.d.ts +269 -0
- package/dist/src/github-client.js +350 -0
- package/dist/src/keyed-async-queue.d.ts +12 -0
- package/dist/src/keyed-async-queue.js +23 -0
- package/dist/src/memory.d.ts +29 -0
- package/dist/src/memory.js +451 -0
- package/dist/src/recall-sanitize.d.ts +1 -0
- package/dist/src/recall-sanitize.js +149 -0
- package/dist/src/runtime-env.d.ts +2 -0
- package/dist/src/runtime-env.js +12 -0
- package/dist/src/service.d.ts +18 -0
- package/dist/src/service.js +3645 -0
- package/dist/src/state.d.ts +4 -0
- package/dist/src/state.js +182 -0
- package/dist/src/transcript.d.ts +3 -0
- package/dist/src/transcript.js +164 -0
- package/dist/src/types.d.ts +130 -0
- package/dist/src/types.js +1 -0
- package/dist/src/utils.d.ts +26 -0
- package/dist/src/utils.js +62 -0
- package/dist/src/yaml.d.ts +2 -0
- package/dist/src/yaml.js +81 -0
- package/openclaw.plugin.json +14 -1
- package/package.json +21 -7
- package/skills/clawmem/SKILL.md +26 -5
- package/skills/clawmem/references/collaboration.md +13 -5
- package/skills/clawmem/references/review.md +77 -0
- package/skills/clawmem/references/schema.md +44 -1
- package/index.ts +0 -6
- package/src/collaboration.test.ts +0 -71
- package/src/collaboration.ts +0 -109
- package/src/config.test.ts +0 -83
- package/src/config.ts +0 -117
- package/src/conversation.test.ts +0 -120
- package/src/conversation.ts +0 -304
- package/src/github-client.test.ts +0 -101
- package/src/github-client.ts +0 -363
- package/src/keyed-async-queue.ts +0 -26
- package/src/memory.test.ts +0 -588
- package/src/memory.ts +0 -444
- package/src/recall-sanitize.ts +0 -143
- package/src/runtime-env.ts +0 -12
- package/src/service.test.ts +0 -337
- package/src/service.ts +0 -2786
- package/src/state.test.ts +0 -119
- package/src/state.ts +0 -206
- package/src/transcript.ts +0 -186
- package/src/types.ts +0 -86
- package/src/utils.ts +0 -74
- package/src/yaml.ts +0 -88
- package/tsconfig.json +0 -15
package/src/config.ts
DELETED
|
@@ -1,117 +0,0 @@
|
|
|
1
|
-
// Hardcoded label/prefix constants and plugin config resolution.
|
|
2
|
-
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core";
|
|
3
|
-
import type { ClawMemAgentConfig, ClawMemPluginConfig, ClawMemResolvedRoute } from "./types.js";
|
|
4
|
-
import { normalizeAgentId } from "./utils.js";
|
|
5
|
-
|
|
6
|
-
export const SESSION_TITLE_PREFIX = "Session: ";
|
|
7
|
-
export const MEMORY_TITLE_PREFIX = "Memory: ";
|
|
8
|
-
export const DEFAULT_LABELS: readonly string[] = [];
|
|
9
|
-
export const AGENT_LABEL_PREFIX = "agent:";
|
|
10
|
-
export const LABEL_ACTIVE = "status:active";
|
|
11
|
-
export const LABEL_CLOSED = "status:closed";
|
|
12
|
-
export const LABEL_MEMORY_ACTIVE = "memory-status:active";
|
|
13
|
-
export const LABEL_MEMORY_STALE = "memory-status:stale";
|
|
14
|
-
|
|
15
|
-
const MANAGED_PREFIXES = ["type:", "kind:", "session:", "date:", "topic:", "agent:"];
|
|
16
|
-
const MANAGED_EXACT = new Set([LABEL_ACTIVE, LABEL_CLOSED, LABEL_MEMORY_ACTIVE, LABEL_MEMORY_STALE]);
|
|
17
|
-
|
|
18
|
-
export function resolvePluginConfig(api: OpenClawPluginApi): ClawMemPluginConfig {
|
|
19
|
-
const raw = (api.pluginConfig ?? {}) as Record<string, unknown>;
|
|
20
|
-
const str = (v: unknown) => typeof v === "string" && v.trim() ? v.trim() : undefined;
|
|
21
|
-
const num = (v: unknown, d: number) => typeof v === "number" && Number.isFinite(v) ? Math.floor(v) : d;
|
|
22
|
-
const float = (v: unknown, d: number) => typeof v === "number" && Number.isFinite(v) ? v : d;
|
|
23
|
-
const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v));
|
|
24
|
-
const baseUrl = (str(raw.baseUrl) ?? "https://git.clawmem.ai").replace(/\/+$/, "");
|
|
25
|
-
const rawAgents = raw.agents && typeof raw.agents === "object" && !Array.isArray(raw.agents)
|
|
26
|
-
? (raw.agents as Record<string, unknown>)
|
|
27
|
-
: {};
|
|
28
|
-
const agents: Record<string, ClawMemAgentConfig> = {};
|
|
29
|
-
for (const [rawAgentId, rawAgentConfig] of Object.entries(rawAgents)) {
|
|
30
|
-
if (!rawAgentConfig || typeof rawAgentConfig !== "object" || Array.isArray(rawAgentConfig)) continue;
|
|
31
|
-
const agentId = normalizeAgentId(rawAgentId);
|
|
32
|
-
const agent = rawAgentConfig as Record<string, unknown>;
|
|
33
|
-
agents[agentId] = {
|
|
34
|
-
baseUrl: str(agent.baseUrl)?.replace(/\/+$/, ""),
|
|
35
|
-
defaultRepo: normalizeRepoName(str(agent.defaultRepo) ?? str(agent.repo)),
|
|
36
|
-
repo: str(agent.repo),
|
|
37
|
-
token: str(agent.token),
|
|
38
|
-
authScheme: agent.authScheme === "bearer" ? "bearer" : agent.authScheme === "token" ? "token" : undefined,
|
|
39
|
-
};
|
|
40
|
-
}
|
|
41
|
-
return {
|
|
42
|
-
baseUrl: baseUrl.endsWith("/api/v3") ? baseUrl : `${baseUrl}/api/v3`,
|
|
43
|
-
defaultRepo: normalizeRepoName(str(raw.defaultRepo) ?? str(raw.repo)),
|
|
44
|
-
repo: normalizeRepoName(str(raw.repo)),
|
|
45
|
-
token: str(raw.token),
|
|
46
|
-
authScheme: raw.authScheme === "bearer" ? "bearer" : "token",
|
|
47
|
-
agents,
|
|
48
|
-
memoryRecallLimit: clamp(num(raw.memoryRecallLimit, 5), 1, 20),
|
|
49
|
-
memoryAutoRecallLimit: clamp(num(raw.memoryAutoRecallLimit, 3), 1, 20),
|
|
50
|
-
summaryWaitTimeoutMs: clamp(num(raw.summaryWaitTimeoutMs, 120000), 1000, 600000),
|
|
51
|
-
memoryExtractWaitTimeoutMs: clamp(num(raw.memoryExtractWaitTimeoutMs, 45000), 1000, 600000),
|
|
52
|
-
};
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
export function resolveAgentRoute(config: ClawMemPluginConfig, agentId?: string, repoOverride?: string): ClawMemResolvedRoute {
|
|
56
|
-
const id = normalizeAgentId(agentId);
|
|
57
|
-
const agent = config.agents[id] ?? {};
|
|
58
|
-
const baseUrl = (agent.baseUrl ?? config.baseUrl).replace(/\/+$/, "");
|
|
59
|
-
const defaultRepo = normalizeRepoName(agent.defaultRepo ?? agent.repo) ?? config.defaultRepo ?? normalizeRepoName(config.repo);
|
|
60
|
-
const repo = normalizeRepoName(repoOverride) ?? defaultRepo;
|
|
61
|
-
return {
|
|
62
|
-
agentId: id,
|
|
63
|
-
baseUrl: baseUrl.endsWith("/api/v3") ? baseUrl : `${baseUrl}/api/v3`,
|
|
64
|
-
...(defaultRepo ? { defaultRepo } : {}),
|
|
65
|
-
...(repo ? { repo } : {}),
|
|
66
|
-
token: agent.token?.trim() || config.token?.trim() || undefined,
|
|
67
|
-
authScheme: agent.authScheme === "bearer" ? "bearer" : agent.authScheme === "token" ? "token" : config.authScheme,
|
|
68
|
-
};
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
export function isAgentConfigured(route: ClawMemResolvedRoute): boolean {
|
|
72
|
-
return Boolean(route.baseUrl && route.token);
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
export function hasDefaultRepo(route: ClawMemResolvedRoute): boolean {
|
|
76
|
-
return Boolean(route.defaultRepo);
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
export function resolveLabelColor(label: string): string {
|
|
80
|
-
if (label.startsWith("status:")) return "b60205";
|
|
81
|
-
if (label.startsWith("memory-status:")) return label.endsWith(":stale") ? "d93f0b" : "0e8a16";
|
|
82
|
-
if (label.startsWith("type:")) return label === "type:memory" ? "5319e7" : "1d76db";
|
|
83
|
-
if (label.startsWith("kind:")) return "5319e7";
|
|
84
|
-
if (label.startsWith("date:")) return "c5def5";
|
|
85
|
-
if (label.startsWith("topic:")) return "fbca04";
|
|
86
|
-
if (label.startsWith("session:")) return "bfdadc";
|
|
87
|
-
if (label.startsWith("agent:")) return "1d76db";
|
|
88
|
-
return "0e8a16";
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
export function labelDescription(label: string): string {
|
|
92
|
-
for (const [pfx, d] of [["type:", "Issue type"], ["kind:", "Memory kind"], ["memory-status:", "Memory lifecycle status"],
|
|
93
|
-
["status:", "Conversation lifecycle status"], ["session:", "Session association"],
|
|
94
|
-
["date:", "Date"], ["topic:", "Topic"], ["agent:", "Agent"]] as const)
|
|
95
|
-
if (label.startsWith(pfx)) return `${d} label managed by clawmem.`;
|
|
96
|
-
return "Label managed by clawmem.";
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
export function isManagedLabel(label: string): boolean {
|
|
100
|
-
return DEFAULT_LABELS.includes(label) || MANAGED_EXACT.has(label) || MANAGED_PREFIXES.some((p) => label.startsWith(p));
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
export function extractLabelNames(labels: Array<{ name?: string } | string> | undefined): string[] {
|
|
104
|
-
if (!Array.isArray(labels)) return [];
|
|
105
|
-
return labels.map((e) => (typeof e === "string" ? e : e?.name ?? "").trim()).filter(Boolean);
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
export function labelVal(labels: string[], prefix: string): string | undefined {
|
|
109
|
-
const m = labels.find((l) => l.startsWith(prefix));
|
|
110
|
-
return m ? m.slice(prefix.length).trim() || undefined : undefined;
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
function normalizeRepoName(value: string | undefined): string | undefined {
|
|
114
|
-
if (!value) return undefined;
|
|
115
|
-
const trimmed = value.trim().replace(/^\/+|\/+$/g, "");
|
|
116
|
-
return /^[^/\s]+\/[^/\s]+$/.test(trimmed) ? trimmed : undefined;
|
|
117
|
-
}
|
package/src/conversation.test.ts
DELETED
|
@@ -1,120 +0,0 @@
|
|
|
1
|
-
// Tests for conversation title derivation logic.
|
|
2
|
-
import { ConversationMirror, buildFinalizeArtifactsPrompt, deriveInitialTitle } from "./conversation.js";
|
|
3
|
-
import type { MemorySchema, NormalizedMessage, SessionMirrorState } from "./types.js";
|
|
4
|
-
|
|
5
|
-
function msg(role: string, text: string): NormalizedMessage {
|
|
6
|
-
return { role, text };
|
|
7
|
-
}
|
|
8
|
-
|
|
9
|
-
function assert(condition: unknown, message: string): void {
|
|
10
|
-
if (!condition) throw new Error(message);
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
const tests: Array<{ name: string; messages: NormalizedMessage[]; sessionId: string; expected: string }> = [
|
|
14
|
-
{
|
|
15
|
-
name: "returns placeholder regardless of user message content",
|
|
16
|
-
messages: [msg("user", "How do I configure Redis rate limiting?")],
|
|
17
|
-
sessionId: "abc123",
|
|
18
|
-
expected: "Session: abc123",
|
|
19
|
-
},
|
|
20
|
-
{
|
|
21
|
-
name: "returns placeholder for long messages",
|
|
22
|
-
messages: [msg("user", "I need help with configuring the distributed rate limiting system for our production Redis cluster")],
|
|
23
|
-
sessionId: "abc123",
|
|
24
|
-
expected: "Session: abc123",
|
|
25
|
-
},
|
|
26
|
-
{
|
|
27
|
-
name: "returns placeholder for messages with markdown",
|
|
28
|
-
messages: [msg("user", "## How do I **configure** `Redis` rate limiting?")],
|
|
29
|
-
sessionId: "abc123",
|
|
30
|
-
expected: "Session: abc123",
|
|
31
|
-
},
|
|
32
|
-
{
|
|
33
|
-
name: "returns placeholder for short messages",
|
|
34
|
-
messages: [msg("user", "hi")],
|
|
35
|
-
sessionId: "abc-def-123",
|
|
36
|
-
expected: "Session: abc-def-123",
|
|
37
|
-
},
|
|
38
|
-
{
|
|
39
|
-
name: "returns placeholder when no user messages",
|
|
40
|
-
messages: [msg("assistant", "Hello!")],
|
|
41
|
-
sessionId: "xyz-789",
|
|
42
|
-
expected: "Session: xyz-789",
|
|
43
|
-
},
|
|
44
|
-
{
|
|
45
|
-
name: "returns placeholder for empty messages",
|
|
46
|
-
messages: [],
|
|
47
|
-
sessionId: "empty-sess",
|
|
48
|
-
expected: "Session: empty-sess",
|
|
49
|
-
},
|
|
50
|
-
{
|
|
51
|
-
name: "returns placeholder with session ID for any input",
|
|
52
|
-
messages: [msg("assistant", "Welcome!"), msg("user", "Fix the login bug please")],
|
|
53
|
-
sessionId: "abc",
|
|
54
|
-
expected: "Session: abc",
|
|
55
|
-
},
|
|
56
|
-
];
|
|
57
|
-
|
|
58
|
-
let passed = 0;
|
|
59
|
-
let failed = 0;
|
|
60
|
-
|
|
61
|
-
async function testLoadSnapshotPrefersFallbackMessages(): Promise<void> {
|
|
62
|
-
const mirror = new ConversationMirror(
|
|
63
|
-
{} as never,
|
|
64
|
-
{ logger: { warn() {}, info() {} } } as never,
|
|
65
|
-
{} as never,
|
|
66
|
-
);
|
|
67
|
-
const session: SessionMirrorState = {
|
|
68
|
-
sessionId: "sync-session",
|
|
69
|
-
sessionFile: "/tmp/does-not-need-to-exist.jsonl",
|
|
70
|
-
lastMirroredCount: 0,
|
|
71
|
-
turnCount: 0,
|
|
72
|
-
};
|
|
73
|
-
const snapshot = await mirror.loadSnapshot(session, [{ role: "user", text: "Use the in-request transcript." }]);
|
|
74
|
-
assert(snapshot.messages.length === 1, "expected loadSnapshot to return fallback messages");
|
|
75
|
-
assert(snapshot.messages[0]?.text === "Use the in-request transcript.", "expected loadSnapshot to prefer in-request messages over transcript files");
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
function testBuildFinalizeArtifactsPromptIncludesSchemaReuseRules(): void {
|
|
79
|
-
const schema: MemorySchema = {
|
|
80
|
-
kinds: ["core-fact", "convention"],
|
|
81
|
-
topics: ["redis", "rate-limits"],
|
|
82
|
-
};
|
|
83
|
-
const prompt = buildFinalizeArtifactsPrompt({
|
|
84
|
-
sessionId: "finalize-session",
|
|
85
|
-
messages: [
|
|
86
|
-
msg("user", "请记住我们现在统一用 Redis 做限流。"),
|
|
87
|
-
msg("assistant", "好的,我会按这个约定处理。"),
|
|
88
|
-
],
|
|
89
|
-
}, schema);
|
|
90
|
-
|
|
91
|
-
assert(prompt.includes("Candidate titles and details must be in the same language as the majority of the conversation content."), "expected language guidance for candidate text");
|
|
92
|
-
assert(prompt.includes("Prefer a concise explicit title for each candidate"), "expected explicit title guidance for candidates");
|
|
93
|
-
assert(prompt.includes("Reuse existing schema labels when one already fits."), "expected schema reuse guidance");
|
|
94
|
-
assert(prompt.includes("Only create a new label when none of the current labels matches"), "expected controlled schema creation guidance");
|
|
95
|
-
assert(prompt.includes("- kind:core-fact"), "expected kinds to be embedded in finalize prompt");
|
|
96
|
-
assert(prompt.includes("- topic:redis"), "expected topics to be embedded in finalize prompt");
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
async function main(): Promise<void> {
|
|
100
|
-
for (const t of tests) {
|
|
101
|
-
const got = deriveInitialTitle(t.messages, t.sessionId);
|
|
102
|
-
const ok = got === t.expected;
|
|
103
|
-
if (!ok) {
|
|
104
|
-
console.error(`FAIL: ${t.name}\n got: ${JSON.stringify(got)}\n expected: ${JSON.stringify(t.expected)}`);
|
|
105
|
-
failed++;
|
|
106
|
-
} else {
|
|
107
|
-
console.log(`PASS: ${t.name}`);
|
|
108
|
-
passed++;
|
|
109
|
-
}
|
|
110
|
-
}
|
|
111
|
-
await testLoadSnapshotPrefersFallbackMessages();
|
|
112
|
-
console.log("PASS: loadSnapshot prefers fallback messages");
|
|
113
|
-
testBuildFinalizeArtifactsPromptIncludesSchemaReuseRules();
|
|
114
|
-
console.log("PASS: buildFinalizeArtifactsPrompt includes schema reuse rules");
|
|
115
|
-
|
|
116
|
-
console.log(`\n${passed + 2} passed, ${failed} failed`);
|
|
117
|
-
if (failed > 0) process.exit(1);
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
await main();
|
package/src/conversation.ts
DELETED
|
@@ -1,304 +0,0 @@
|
|
|
1
|
-
// Session mirroring: creates/updates GitHub issues and comments for each conversation.
|
|
2
|
-
import fs from "node:fs";
|
|
3
|
-
import path from "node:path";
|
|
4
|
-
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core";
|
|
5
|
-
import { AGENT_LABEL_PREFIX, DEFAULT_LABELS, SESSION_TITLE_PREFIX, extractLabelNames } from "./config.js";
|
|
6
|
-
import type { GitHubIssueClient } from "./github-client.js";
|
|
7
|
-
import { parseCandidates } from "./memory.js";
|
|
8
|
-
import { normalizeMessages, readTranscriptSnapshot } from "./transcript.js";
|
|
9
|
-
import type { ClawMemPluginConfig, MemoryCandidate, MemorySchema, NormalizedMessage, SessionMirrorState, TranscriptSnapshot } from "./types.js";
|
|
10
|
-
import { fmtTranscript, localDate, localDateTime, sha256, subKey } from "./utils.js";
|
|
11
|
-
import { parseFlatYaml, stringifyFlatYaml } from "./yaml.js";
|
|
12
|
-
|
|
13
|
-
const FINALIZE_SCHEMA_KIND_LIMIT = 24;
|
|
14
|
-
const FINALIZE_SCHEMA_TOPIC_LIMIT = 80;
|
|
15
|
-
|
|
16
|
-
export class ConversationMirror {
|
|
17
|
-
constructor(private readonly client: GitHubIssueClient, private readonly api: OpenClawPluginApi, private readonly config: ClawMemPluginConfig) {}
|
|
18
|
-
|
|
19
|
-
shouldMirror(sessionId: string, messages: NormalizedMessage[]): boolean {
|
|
20
|
-
if (sessionId.startsWith("slug-generator-")) return false;
|
|
21
|
-
const first = messages.find((m) => m.role === "user")?.text ?? "";
|
|
22
|
-
if (first.includes("generate a short 1-2 word filename slug") && first.includes("Reply with ONLY the slug")) return false;
|
|
23
|
-
if (first.includes("Write the final issue summary and extract durable memory candidates from the conversation below.")) return false;
|
|
24
|
-
return true;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
async loadSnapshot(session: SessionMirrorState, fallback: unknown[]): Promise<TranscriptSnapshot> {
|
|
28
|
-
const normalizedFallback = normalizeMessages(fallback);
|
|
29
|
-
if (normalizedFallback.length > 0) {
|
|
30
|
-
return { sessionId: session.sessionId, messages: normalizedFallback };
|
|
31
|
-
}
|
|
32
|
-
const filePath = await this.resolveTranscriptPath(session.sessionFile);
|
|
33
|
-
if (filePath) {
|
|
34
|
-
session.sessionFile = filePath;
|
|
35
|
-
try {
|
|
36
|
-
const t = await readTranscriptSnapshot(filePath);
|
|
37
|
-
return { sessionId: t.sessionId ?? session.sessionId, messages: t.messages };
|
|
38
|
-
} catch (error) {
|
|
39
|
-
this.api.logger.warn(`clawmem: transcript read failed for ${filePath}: ${String(error)}`);
|
|
40
|
-
}
|
|
41
|
-
}
|
|
42
|
-
return { sessionId: session.sessionId, messages: normalizedFallback };
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
async ensureIssue(session: SessionMirrorState, snapshot: TranscriptSnapshot): Promise<void> {
|
|
46
|
-
if (session.issueNumber) {
|
|
47
|
-
const existing = await this.lookupBoundIssue(session);
|
|
48
|
-
if (existing && this.isBoundIssue(session, existing)) {
|
|
49
|
-
session.issueTitle = existing.title?.trim() || session.issueTitle;
|
|
50
|
-
return;
|
|
51
|
-
}
|
|
52
|
-
this.api.logger.warn(
|
|
53
|
-
`clawmem: issue binding for ${session.sessionId} is stale or mismatched (${session.issueNumber}); recreating`,
|
|
54
|
-
);
|
|
55
|
-
this.resetIssueBinding(session);
|
|
56
|
-
}
|
|
57
|
-
// Use placeholder title; real title is generated by LLM once enough messages are available.
|
|
58
|
-
const title = deriveInitialTitle(snapshot.messages, session.sessionId);
|
|
59
|
-
const labels = this.buildLabels(session, snapshot, false);
|
|
60
|
-
const body = this.renderBody(session, snapshot, "pending", false);
|
|
61
|
-
await this.client.ensureLabels(labels);
|
|
62
|
-
const issue = await this.client.createIssue({ title, body, labels });
|
|
63
|
-
session.issueNumber = issue.number;
|
|
64
|
-
session.issueTitle = issue.title ?? title;
|
|
65
|
-
session.titleSource = "placeholder";
|
|
66
|
-
session.lastSummaryHash = sha256(`${title}\n${body}\nopen`);
|
|
67
|
-
session.createdAt = new Date().toISOString();
|
|
68
|
-
session.updatedAt = session.createdAt;
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
async syncBody(session: SessionMirrorState, snapshot: TranscriptSnapshot, summary: string, closed: boolean, titleOverride?: string): Promise<void> {
|
|
72
|
-
if (!session.issueNumber) return;
|
|
73
|
-
// Prefer explicit override (LLM-generated title), then keep existing title, then fall back to session ID.
|
|
74
|
-
const title = titleOverride?.trim() || session.issueTitle || `${SESSION_TITLE_PREFIX}${session.sessionId}`;
|
|
75
|
-
const body = this.renderBody(session, snapshot, summary, closed);
|
|
76
|
-
const hash = sha256(`${title}\n${body}\n${closed ? "closed" : "open"}`);
|
|
77
|
-
if (hash === session.lastSummaryHash) return;
|
|
78
|
-
await this.client.updateIssue(session.issueNumber, { title, body, ...(closed ? { state: "closed" as const } : {}) });
|
|
79
|
-
session.issueTitle = title;
|
|
80
|
-
if (titleOverride?.trim()) session.titleSource = "llm";
|
|
81
|
-
session.lastSummaryHash = hash;
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
async syncLabels(session: SessionMirrorState, snapshot: TranscriptSnapshot, closed: boolean): Promise<void> {
|
|
85
|
-
if (!session.issueNumber) return;
|
|
86
|
-
const labels = this.buildLabels(session, snapshot, closed);
|
|
87
|
-
await this.client.ensureLabels(labels);
|
|
88
|
-
await this.client.syncManagedLabels(session.issueNumber, labels);
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
async appendComments(issueNumber: number, messages: NormalizedMessage[]): Promise<number> {
|
|
92
|
-
let count = 0;
|
|
93
|
-
for (const msg of messages) {
|
|
94
|
-
try { await this.client.createComment(issueNumber, `role: ${msg.role}\n\n${msg.text.trim()}`); count++; }
|
|
95
|
-
catch (error) { this.api.logger.warn(`clawmem: conversation comment failed: ${String(error)}`); break; }
|
|
96
|
-
}
|
|
97
|
-
return count;
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
async generateFinalArtifacts(
|
|
101
|
-
session: SessionMirrorState,
|
|
102
|
-
snapshot: TranscriptSnapshot,
|
|
103
|
-
schema?: MemorySchema,
|
|
104
|
-
): Promise<{ summary: string; title?: string; candidates: MemoryCandidate[] }> {
|
|
105
|
-
if (snapshot.messages.length === 0) throw new Error("no conversation messages to finalize");
|
|
106
|
-
const subagent = this.api.runtime.subagent;
|
|
107
|
-
const sessionKey = subKey(session, "finalize");
|
|
108
|
-
const message = buildFinalizeArtifactsPrompt(snapshot, schema);
|
|
109
|
-
try {
|
|
110
|
-
const run = await subagent.run({
|
|
111
|
-
sessionKey,
|
|
112
|
-
message,
|
|
113
|
-
deliver: false,
|
|
114
|
-
lane: "clawmem-finalize",
|
|
115
|
-
idempotencyKey: sha256(`${session.sessionId}:${snapshot.messages.length}:finalize-v2`),
|
|
116
|
-
extraSystemPrompt: "You finalize ClawMem conversations. Output JSON only with summary, title, and durable memory candidates. Reuse existing schema when it fits and keep human-readable memory text in the conversation language.",
|
|
117
|
-
});
|
|
118
|
-
const wait = await subagent.waitForRun({
|
|
119
|
-
runId: run.runId,
|
|
120
|
-
timeoutMs: Math.max(this.config.summaryWaitTimeoutMs, this.config.memoryExtractWaitTimeoutMs),
|
|
121
|
-
});
|
|
122
|
-
if (wait.status === "timeout") throw new Error("finalize subagent timed out");
|
|
123
|
-
if (wait.status === "error") throw new Error(wait.error || "finalize subagent failed");
|
|
124
|
-
const msgs = normalizeMessages((await subagent.getSessionMessages({ sessionKey, limit: 50 })).messages);
|
|
125
|
-
const text = [...msgs].reverse().find((entry) => entry.role === "assistant" && entry.text.trim())?.text;
|
|
126
|
-
if (!text) throw new Error("finalize subagent returned no assistant text");
|
|
127
|
-
return parseFinalArtifacts(text);
|
|
128
|
-
} finally {
|
|
129
|
-
subagent.deleteSession({ sessionKey, deleteTranscript: true }).catch(() => {});
|
|
130
|
-
}
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
private buildLabels(session: SessionMirrorState, _snapshot: TranscriptSnapshot, _closed: boolean): string[] {
|
|
134
|
-
const labels = new Set([...DEFAULT_LABELS, "type:conversation", `session:${session.sessionId}`]);
|
|
135
|
-
if (session.agentId) labels.add(`${AGENT_LABEL_PREFIX}${session.agentId}`);
|
|
136
|
-
return [...labels].filter((l) => l.trim().length > 0);
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
private renderBody(session: SessionMirrorState, snapshot: TranscriptSnapshot, summary: string, _closed: boolean): string {
|
|
140
|
-
const dates = this.resolveDates(session, snapshot.messages);
|
|
141
|
-
return stringifyFlatYaml([
|
|
142
|
-
["type", "conversation"], ["session_id", session.sessionId], ["date", dates.date],
|
|
143
|
-
["start_at", dates.startAt], ["end_at", dates.endAt],
|
|
144
|
-
["summary", summary],
|
|
145
|
-
]);
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
private resolveDates(session: SessionMirrorState, messages: NormalizedMessage[]): { date: string; startAt: string; endAt: string } {
|
|
149
|
-
const ts = messages.map((m) => m.timestamp).filter((v): v is string => Boolean(v?.trim()))
|
|
150
|
-
.map((v) => new Date(v)).filter((d) => Number.isFinite(d.getTime()));
|
|
151
|
-
const fallbackStart = session.createdAt ? new Date(session.createdAt) : new Date();
|
|
152
|
-
const fallbackEnd = session.updatedAt ? new Date(session.updatedAt) : fallbackStart;
|
|
153
|
-
const start = ts[0] ?? fallbackStart, end = ts.at(-1) ?? fallbackEnd;
|
|
154
|
-
return { date: localDate(start), startAt: localDateTime(start), endAt: localDateTime(end) };
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
private async resolveTranscriptPath(filePath: string | undefined): Promise<string | null> {
|
|
158
|
-
if (!filePath) return null;
|
|
159
|
-
if (await fexists(filePath)) return filePath;
|
|
160
|
-
try {
|
|
161
|
-
const dir = path.dirname(filePath), prefix = `${path.basename(filePath)}.reset.`;
|
|
162
|
-
const latest = (await fs.promises.readdir(dir, { withFileTypes: true }))
|
|
163
|
-
.filter((e) => e.isFile() && e.name.startsWith(prefix)).map((e) => e.name).sort().at(-1);
|
|
164
|
-
if (latest) {
|
|
165
|
-
this.api.logger.info?.(`clawmem: using reset transcript ${path.join(dir, latest)} because ${filePath} is missing`);
|
|
166
|
-
return path.join(dir, latest);
|
|
167
|
-
}
|
|
168
|
-
} catch { /* directory unreadable */ }
|
|
169
|
-
return null;
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
private async lookupBoundIssue(session: SessionMirrorState): Promise<{ number: number; title?: string; labels?: Array<{ name?: string } | string> } | null> {
|
|
173
|
-
if (!session.issueNumber) return null;
|
|
174
|
-
try {
|
|
175
|
-
return await this.client.getIssue(session.issueNumber);
|
|
176
|
-
} catch (error) {
|
|
177
|
-
if (isNotFoundError(error)) return null;
|
|
178
|
-
throw error;
|
|
179
|
-
}
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
private isBoundIssue(session: SessionMirrorState, issue: { title?: string; labels?: Array<{ name?: string } | string> }): boolean {
|
|
183
|
-
const labels = extractLabelNames(issue.labels);
|
|
184
|
-
return labels.includes("type:conversation") && labels.includes(`session:${session.sessionId}`);
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
private resetIssueBinding(session: SessionMirrorState): void {
|
|
188
|
-
session.issueNumber = undefined;
|
|
189
|
-
session.issueTitle = undefined;
|
|
190
|
-
session.titleSource = undefined;
|
|
191
|
-
session.lastSummaryHash = undefined;
|
|
192
|
-
session.lastMirroredCount = 0;
|
|
193
|
-
session.turnCount = 0;
|
|
194
|
-
session.finalizedAt = undefined;
|
|
195
|
-
}
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
export function buildFinalizeArtifactsPrompt(snapshot: TranscriptSnapshot, schema?: MemorySchema): string {
|
|
199
|
-
return [
|
|
200
|
-
"Write the final issue summary and extract durable memory candidates from the conversation below.",
|
|
201
|
-
'Return valid JSON only in the form {"summary":"...","title":"...","candidates":[{"title":"...","detail":"...","kind":"...","topics":["..."],"evidence":"..."}]}.',
|
|
202
|
-
"The summary should be concise, factual, and written in 2-4 sentences.",
|
|
203
|
-
"Do not include markdown, bullet points, or analysis.",
|
|
204
|
-
"",
|
|
205
|
-
"Title rules:",
|
|
206
|
-
"- Under 50 characters, accurately describe the main topic or task.",
|
|
207
|
-
"- Should let someone immediately know what the conversation is about.",
|
|
208
|
-
"- Must be in the same language as the majority of the conversation content.",
|
|
209
|
-
"- Good: precise, descriptive, specific. Bad: vague, overly creative, generic.",
|
|
210
|
-
"",
|
|
211
|
-
"Candidate rules:",
|
|
212
|
-
"- Extract only durable facts, preferences, decisions, constraints, workflows, and ongoing context worth remembering later.",
|
|
213
|
-
"- Each candidate must represent one durable fact. Split independent facts into separate candidates.",
|
|
214
|
-
"- Prefer a concise explicit title for each candidate whenever the fact can be named clearly.",
|
|
215
|
-
"- Candidate titles and details must be in the same language as the majority of the conversation content.",
|
|
216
|
-
"- Do not extract temporary requests, tool chatter, startup boilerplate, or summaries about internal helper sessions.",
|
|
217
|
-
"- Reuse existing schema labels when one already fits.",
|
|
218
|
-
"- If no existing kind or topic fits, create one short stable machine-readable label instead of a translated or near-duplicate variant.",
|
|
219
|
-
"- Keep kind and topic labels short, reusable, low-cardinality, and machine-readable.",
|
|
220
|
-
"- Evidence is optional. If present, keep it short and quote-free.",
|
|
221
|
-
"- Prefer an empty candidates array when nothing durable was learned.",
|
|
222
|
-
"",
|
|
223
|
-
...buildFinalizeSchemaSection(schema),
|
|
224
|
-
"<conversation>",
|
|
225
|
-
fmtTranscript(snapshot.messages),
|
|
226
|
-
"</conversation>",
|
|
227
|
-
].join("\n");
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
function buildFinalizeSchemaSection(schema?: MemorySchema): string[] {
|
|
231
|
-
if (!schema) return [];
|
|
232
|
-
|
|
233
|
-
const kinds = schema.kinds.map((kind) => kind.trim()).filter(Boolean);
|
|
234
|
-
const topics = schema.topics.map((topic) => topic.trim()).filter(Boolean);
|
|
235
|
-
if (kinds.length === 0 && topics.length === 0) return [];
|
|
236
|
-
|
|
237
|
-
const kindLines = kinds.slice(0, FINALIZE_SCHEMA_KIND_LIMIT).map((kind) => `- kind:${kind}`);
|
|
238
|
-
const topicLines = topics.slice(0, FINALIZE_SCHEMA_TOPIC_LIMIT).map((topic) => `- topic:${topic}`);
|
|
239
|
-
const kindOverflow = kinds.length > kindLines.length ? [`- ...and ${kinds.length - kindLines.length} more kinds`] : [];
|
|
240
|
-
const topicOverflow = topics.length > topicLines.length ? [`- ...and ${topics.length - topicLines.length} more topics`] : [];
|
|
241
|
-
|
|
242
|
-
return [
|
|
243
|
-
"Current schema to reuse first:",
|
|
244
|
-
"<current-schema>",
|
|
245
|
-
"Kinds:",
|
|
246
|
-
...(kindLines.length > 0 ? kindLines : ["- None"]),
|
|
247
|
-
...kindOverflow,
|
|
248
|
-
"Topics:",
|
|
249
|
-
...(topicLines.length > 0 ? topicLines : ["- None"]),
|
|
250
|
-
...topicOverflow,
|
|
251
|
-
"</current-schema>",
|
|
252
|
-
"Prefer these existing labels whenever they fit. Only create a new label when none of the current labels matches the fact you are storing.",
|
|
253
|
-
"",
|
|
254
|
-
];
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
async function fexists(p: string): Promise<boolean> { try { return (await fs.promises.stat(p)).isFile(); } catch { return false; } }
|
|
258
|
-
function isNotFoundError(error: unknown): boolean {
|
|
259
|
-
const text = String(error);
|
|
260
|
-
return text.includes("HTTP 404");
|
|
261
|
-
}
|
|
262
|
-
/** Derive an initial placeholder title for a new conversation. The real title is generated by LLM once enough messages are available. */
|
|
263
|
-
export function deriveInitialTitle(_messages: NormalizedMessage[], sessionId: string): string {
|
|
264
|
-
return `${SESSION_TITLE_PREFIX}${sessionId}`;
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
function parseSummaryAndTitle(raw: string): { summary: string; title?: string } {
|
|
268
|
-
const tryParse = (s: string): { summary: string; title?: string } | null => {
|
|
269
|
-
try {
|
|
270
|
-
const p = JSON.parse(s) as { summary?: unknown; title?: unknown };
|
|
271
|
-
const summary = typeof p?.summary === "string" && p.summary.trim() ? p.summary.trim() : null;
|
|
272
|
-
if (!summary) return null;
|
|
273
|
-
const title = typeof p?.title === "string" && p.title.trim() ? p.title.trim() : undefined;
|
|
274
|
-
return { summary, title };
|
|
275
|
-
} catch {
|
|
276
|
-
const i = s.indexOf("{"), j = s.lastIndexOf("}");
|
|
277
|
-
if (i >= 0 && j > i) {
|
|
278
|
-
try {
|
|
279
|
-
const p = JSON.parse(s.slice(i, j + 1)) as { summary?: unknown; title?: unknown };
|
|
280
|
-
const summary = typeof p?.summary === "string" && p.summary.trim() ? p.summary.trim() : null;
|
|
281
|
-
if (!summary) return null;
|
|
282
|
-
const title = typeof p?.title === "string" && p.title.trim() ? p.title.trim() : undefined;
|
|
283
|
-
return { summary, title };
|
|
284
|
-
} catch { return null; }
|
|
285
|
-
}
|
|
286
|
-
return null;
|
|
287
|
-
}
|
|
288
|
-
};
|
|
289
|
-
const t = raw.trim();
|
|
290
|
-
const direct = tryParse(t); if (direct) return direct;
|
|
291
|
-
const f = /^```(?:json)?\s*([\s\S]*?)```$/i.exec(t);
|
|
292
|
-
if (f?.[1]) { const nested = tryParse(f[1].trim()); if (nested) return nested; }
|
|
293
|
-
return { summary: t };
|
|
294
|
-
}
|
|
295
|
-
|
|
296
|
-
function parseFinalArtifacts(raw: string): { summary: string; title?: string; candidates: MemoryCandidate[] } {
|
|
297
|
-
const parsedSummary = parseSummaryAndTitle(raw);
|
|
298
|
-
const candidates = parseCandidates(raw);
|
|
299
|
-
return {
|
|
300
|
-
summary: parsedSummary.summary,
|
|
301
|
-
...(parsedSummary.title ? { title: parsedSummary.title } : {}),
|
|
302
|
-
candidates,
|
|
303
|
-
};
|
|
304
|
-
}
|
|
@@ -1,101 +0,0 @@
|
|
|
1
|
-
import { GitHubIssueClient } from "./github-client.js";
|
|
2
|
-
import type { ClawMemResolvedRoute } from "./types.js";
|
|
3
|
-
|
|
4
|
-
function assert(condition: unknown, message: string): void {
|
|
5
|
-
if (!condition) throw new Error(message);
|
|
6
|
-
}
|
|
7
|
-
|
|
8
|
-
type FetchCall = { url: string; init: RequestInit };
|
|
9
|
-
|
|
10
|
-
function createClientRecorder(): {
|
|
11
|
-
client: GitHubIssueClient;
|
|
12
|
-
calls: FetchCall[];
|
|
13
|
-
restore(): void;
|
|
14
|
-
} {
|
|
15
|
-
const calls: FetchCall[] = [];
|
|
16
|
-
const originalFetch = globalThis.fetch;
|
|
17
|
-
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
18
|
-
calls.push({ url: String(input), init: init ?? {} });
|
|
19
|
-
const method = init?.method ?? "GET";
|
|
20
|
-
if (method === "DELETE" || method === "PATCH") return new Response(null, { status: 204 });
|
|
21
|
-
return new Response(JSON.stringify({ ok: true }), { status: 200, headers: { "Content-Type": "application/json" } });
|
|
22
|
-
}) as typeof fetch;
|
|
23
|
-
|
|
24
|
-
const route: ClawMemResolvedRoute = {
|
|
25
|
-
agentId: "main",
|
|
26
|
-
baseUrl: "https://git.clawmem.ai/api/v3",
|
|
27
|
-
defaultRepo: "alice/memory",
|
|
28
|
-
repo: "alice/memory",
|
|
29
|
-
token: "token-123",
|
|
30
|
-
authScheme: "token",
|
|
31
|
-
};
|
|
32
|
-
|
|
33
|
-
return {
|
|
34
|
-
client: new GitHubIssueClient(route, {}),
|
|
35
|
-
calls,
|
|
36
|
-
restore() {
|
|
37
|
-
globalThis.fetch = originalFetch;
|
|
38
|
-
},
|
|
39
|
-
};
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
async function testOrgGovernanceRoutes(): Promise<void> {
|
|
43
|
-
const { client, calls, restore } = createClientRecorder();
|
|
44
|
-
try {
|
|
45
|
-
await client.listOrgMembers("acme", "admin");
|
|
46
|
-
await client.getOrgMembership("acme", "alice");
|
|
47
|
-
await client.removeOrgMember("acme", "alice");
|
|
48
|
-
await client.removeOrgMembership("acme", "alice");
|
|
49
|
-
await client.revokeOrgInvitation("acme", 12);
|
|
50
|
-
|
|
51
|
-
assert(calls[0]?.url === "https://git.clawmem.ai/api/v3/orgs/acme/members?role=admin", "expected org member list route");
|
|
52
|
-
assert(calls[1]?.url === "https://git.clawmem.ai/api/v3/orgs/acme/memberships/alice", "expected org membership route");
|
|
53
|
-
assert(calls[2]?.url === "https://git.clawmem.ai/api/v3/orgs/acme/members/alice", "expected org member delete route");
|
|
54
|
-
assert(calls[2]?.init.method === "DELETE", "expected DELETE for org member removal");
|
|
55
|
-
assert(calls[3]?.url === "https://git.clawmem.ai/api/v3/orgs/acme/memberships/alice", "expected org membership delete route");
|
|
56
|
-
assert(calls[3]?.init.method === "DELETE", "expected DELETE for org membership removal");
|
|
57
|
-
assert(calls[4]?.url === "https://git.clawmem.ai/api/v3/orgs/acme/invitations/12", "expected org invitation revoke route");
|
|
58
|
-
assert(calls[4]?.init.method === "DELETE", "expected DELETE for org invitation revoke");
|
|
59
|
-
} finally {
|
|
60
|
-
restore();
|
|
61
|
-
}
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
async function testTeamGovernanceRoutes(): Promise<void> {
|
|
65
|
-
const { client, calls, restore } = createClientRecorder();
|
|
66
|
-
try {
|
|
67
|
-
await client.getTeam("acme", "platform");
|
|
68
|
-
await client.updateTeam("acme", "platform", { name: "Platform Eng", description: "Core platform", privacy: "closed" });
|
|
69
|
-
await client.deleteTeam("acme", "platform");
|
|
70
|
-
await client.listTeamMembers("acme", "platform");
|
|
71
|
-
|
|
72
|
-
assert(calls[0]?.url === "https://git.clawmem.ai/api/v3/orgs/acme/teams/platform", "expected team get route");
|
|
73
|
-
assert(calls[1]?.url === "https://git.clawmem.ai/api/v3/orgs/acme/teams/platform", "expected team update route");
|
|
74
|
-
assert(calls[1]?.init.method === "PATCH", "expected PATCH for team update");
|
|
75
|
-
assert(String(calls[1]?.init.body).includes("\"name\":\"Platform Eng\""), "expected team update payload to include name");
|
|
76
|
-
assert(calls[2]?.url === "https://git.clawmem.ai/api/v3/orgs/acme/teams/platform", "expected team delete route");
|
|
77
|
-
assert(calls[2]?.init.method === "DELETE", "expected DELETE for team delete");
|
|
78
|
-
assert(calls[3]?.url === "https://git.clawmem.ai/api/v3/orgs/acme/teams/platform/members", "expected team members route");
|
|
79
|
-
} finally {
|
|
80
|
-
restore();
|
|
81
|
-
}
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
async function testRepoTransferRoute(): Promise<void> {
|
|
85
|
-
const { client, calls, restore } = createClientRecorder();
|
|
86
|
-
try {
|
|
87
|
-
await client.transferRepo("alice", "memory", "acme");
|
|
88
|
-
assert(calls.length === 1, "expected one repo transfer request");
|
|
89
|
-
assert(calls[0]?.url === "https://git.clawmem.ai/api/v3/repos/alice/memory/transfer", "expected repo transfer route");
|
|
90
|
-
assert(calls[0]?.init.method === "POST", "expected POST for repo transfer");
|
|
91
|
-
assert(String(calls[0]?.init.body) === "{\"new_owner\":\"acme\"}", "expected repo transfer payload");
|
|
92
|
-
} finally {
|
|
93
|
-
restore();
|
|
94
|
-
}
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
await testOrgGovernanceRoutes();
|
|
98
|
-
await testTeamGovernanceRoutes();
|
|
99
|
-
await testRepoTransferRoute();
|
|
100
|
-
|
|
101
|
-
console.log("github client tests passed");
|