@clawmem-ai/clawmem 0.1.9 → 0.1.11
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 +37 -189
- package/openclaw.plugin.json +20 -89
- package/package.json +2 -1
- package/skills/clawmem/SKILL.md +78 -0
- package/skills/clawmem/references/collaboration.md +223 -0
- package/skills/clawmem/references/communication.md +82 -0
- package/skills/clawmem/references/manual-ops.md +205 -0
- package/skills/clawmem/references/repair.md +127 -0
- package/skills/clawmem/references/schema.md +63 -0
- package/skills/clawmem/scripts/clawmem_exports.py +54 -0
- package/src/config.test.ts +82 -0
- package/src/config.ts +23 -6
- package/src/github-client.ts +207 -1
- package/src/memory.test.ts +2 -4
- package/src/memory.ts +11 -15
- package/src/service.ts +1249 -56
- package/src/types.ts +9 -2
- package/src/utils.ts +13 -0
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# ClawMem Memory Schema
|
|
2
|
+
|
|
3
|
+
Use this reference when deciding how to label or shape a memory, or when you need to explain the ClawMem graph model to another agent or user.
|
|
4
|
+
|
|
5
|
+
## The memory graph
|
|
6
|
+
|
|
7
|
+
Issues are nodes. Labels are schema. `#ID` references are edges.
|
|
8
|
+
|
|
9
|
+
When one memory depends on, refines, supersedes, or generalizes another memory, mention the related `#ID` in the issue body so the relationship stays explicit in the graph.
|
|
10
|
+
|
|
11
|
+
There are two valid memory shapes:
|
|
12
|
+
- Plugin-managed structured memories: created through `memory_store` or `memory_update`; the plugin manages core labels and may also persist agent-selected `kind:*` and `topic:*` labels
|
|
13
|
+
- Curated graph memories: created manually through `gh` or `curl` when you explicitly need raw issue control
|
|
14
|
+
|
|
15
|
+
## Labels
|
|
16
|
+
|
|
17
|
+
Plugin-managed memories always include:
|
|
18
|
+
- `type:memory`
|
|
19
|
+
|
|
20
|
+
Plugin-managed memories may also include:
|
|
21
|
+
- one `kind:*` label
|
|
22
|
+
- optional `topic:*` labels
|
|
23
|
+
|
|
24
|
+
Lifecycle is carried by native issue state:
|
|
25
|
+
- open issue = active memory
|
|
26
|
+
- closed issue = stale or superseded memory
|
|
27
|
+
|
|
28
|
+
If you create a curated memory manually, include:
|
|
29
|
+
- `type:memory`
|
|
30
|
+
- one `kind:*` label
|
|
31
|
+
- optional `topic:*` labels, usually no more than two or three
|
|
32
|
+
|
|
33
|
+
## Kinds
|
|
34
|
+
|
|
35
|
+
| Kind | Label | Use it for |
|
|
36
|
+
|---|---|---|
|
|
37
|
+
| Core fact | `kind:core-fact` | Stable truths that should update in place as reality changes |
|
|
38
|
+
| Convention | `kind:convention` | Agreed rules or policies |
|
|
39
|
+
| Lesson learned | `kind:lesson` | Corrections, postmortems, or mistakes worth preserving |
|
|
40
|
+
| Skill blueprint | `kind:skill` | Repeatable workflows or playbooks |
|
|
41
|
+
| Active task | `kind:task` | Ongoing work that should stay visible and update over time |
|
|
42
|
+
|
|
43
|
+
## When to create which kind
|
|
44
|
+
|
|
45
|
+
| Trigger | Kind |
|
|
46
|
+
|---|---|
|
|
47
|
+
| User corrects a wrong assumption | `kind:lesson` |
|
|
48
|
+
| You and the user agree on a rule | `kind:convention` |
|
|
49
|
+
| A stable fact about the user or project | `kind:core-fact` |
|
|
50
|
+
| A repeatable workflow you figured out | `kind:skill` |
|
|
51
|
+
| Ongoing work to track | `kind:task` |
|
|
52
|
+
|
|
53
|
+
## Disciplined self-evolution
|
|
54
|
+
|
|
55
|
+
- Before inventing a new `kind` or `topic`, call `memory_labels`.
|
|
56
|
+
- Reuse current schema when it already fits.
|
|
57
|
+
- If the current schema does not fit and a new label would help future retrieval, coordination, or reuse, create it deliberately.
|
|
58
|
+
- New labels should be short, general, and likely to apply again across future memories or agents.
|
|
59
|
+
- Do not invent random label prefixes. Schema evolution must stay within `kind:*` and `topic:*`.
|
|
60
|
+
|
|
61
|
+
## Storage rule
|
|
62
|
+
|
|
63
|
+
If you are writing something so the agent remembers it later, store it in ClawMem. If you are writing something for a tool or human to read directly, write a file instead.
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import shlex
|
|
6
|
+
import subprocess
|
|
7
|
+
import sys
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def normalize_base_url(raw: str) -> str:
|
|
11
|
+
value = (raw or "https://git.clawmem.ai/api/v3").rstrip("/")
|
|
12
|
+
if not value.endswith("/api/v3"):
|
|
13
|
+
value = f"{value}/api/v3"
|
|
14
|
+
return value
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def main() -> int:
|
|
18
|
+
agent_id = (sys.argv[1].strip() if len(sys.argv) > 1 and sys.argv[1].strip() else os.environ.get("OPENCLAW_AGENT_ID", "main"))
|
|
19
|
+
repo_override = sys.argv[2].strip() if len(sys.argv) > 2 else ""
|
|
20
|
+
|
|
21
|
+
try:
|
|
22
|
+
cfg_path = subprocess.check_output(["openclaw", "config", "file"], text=True).strip()
|
|
23
|
+
except FileNotFoundError:
|
|
24
|
+
print("clawmem_exports.py: openclaw CLI was not found in PATH", file=sys.stderr)
|
|
25
|
+
return 1
|
|
26
|
+
with open(os.path.expanduser(cfg_path), "r", encoding="utf-8") as handle:
|
|
27
|
+
root = json.load(handle)
|
|
28
|
+
|
|
29
|
+
cfg = (((root.get("plugins") or {}).get("entries") or {}).get("clawmem") or {}).get("config") or {}
|
|
30
|
+
agents = cfg.get("agents") or {}
|
|
31
|
+
route = agents.get(agent_id) or {}
|
|
32
|
+
|
|
33
|
+
base_url = normalize_base_url(route.get("baseUrl") or cfg.get("baseUrl") or "")
|
|
34
|
+
default_repo = route.get("defaultRepo") or route.get("repo") or cfg.get("defaultRepo") or cfg.get("repo") or ""
|
|
35
|
+
repo = repo_override or default_repo
|
|
36
|
+
token = route.get("token") or ""
|
|
37
|
+
host = base_url.removesuffix("/api/v3").replace("https://", "").replace("http://", "")
|
|
38
|
+
|
|
39
|
+
pairs = {
|
|
40
|
+
"CLAWMEM_AGENT_ID": agent_id,
|
|
41
|
+
"CLAWMEM_BASE_URL": base_url,
|
|
42
|
+
"CLAWMEM_HOST": host,
|
|
43
|
+
"CLAWMEM_DEFAULT_REPO": default_repo,
|
|
44
|
+
"CLAWMEM_REPO": repo,
|
|
45
|
+
"CLAWMEM_TOKEN": token,
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
for key, value in pairs.items():
|
|
49
|
+
print(f"export {key}={shlex.quote(value)}")
|
|
50
|
+
return 0
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
if __name__ == "__main__":
|
|
54
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { hasDefaultRepo, isAgentConfigured, resolveAgentRoute } from "./config.js";
|
|
2
|
+
import type { ClawMemPluginConfig } from "./types.js";
|
|
3
|
+
import { buildAgentBootstrapRegistration, DEFAULT_BOOTSTRAP_REPO_NAME } from "./utils.js";
|
|
4
|
+
|
|
5
|
+
function assert(condition: unknown, message: string): void {
|
|
6
|
+
if (!condition) throw new Error(message);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function baseConfig(): ClawMemPluginConfig {
|
|
10
|
+
return {
|
|
11
|
+
baseUrl: "https://git.clawmem.ai/api/v3",
|
|
12
|
+
authScheme: "token",
|
|
13
|
+
token: "top-token",
|
|
14
|
+
defaultRepo: "global/default-memory",
|
|
15
|
+
repo: "global/legacy-memory",
|
|
16
|
+
agents: {
|
|
17
|
+
main: {
|
|
18
|
+
token: "agent-token",
|
|
19
|
+
defaultRepo: "main/private-memory",
|
|
20
|
+
},
|
|
21
|
+
legacy: {
|
|
22
|
+
token: "legacy-token",
|
|
23
|
+
repo: "legacy/old-memory",
|
|
24
|
+
},
|
|
25
|
+
identityOnly: {
|
|
26
|
+
token: "identity-token",
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
memoryRecallLimit: 5,
|
|
30
|
+
turnCommentDelayMs: 1000,
|
|
31
|
+
summaryWaitTimeoutMs: 120000,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function testDefaultRepoResolution(): void {
|
|
36
|
+
const route = resolveAgentRoute(baseConfig(), "main");
|
|
37
|
+
assert(route.defaultRepo === "main/private-memory", "expected per-agent defaultRepo to be preferred");
|
|
38
|
+
assert(route.repo === "main/private-memory", "expected selected repo to default to defaultRepo");
|
|
39
|
+
assert(route.token === "agent-token", "expected per-agent token to be preferred");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function testRepoOverride(): void {
|
|
43
|
+
const route = resolveAgentRoute(baseConfig(), "main", "org/shared-memory");
|
|
44
|
+
assert(route.defaultRepo === "main/private-memory", "expected defaultRepo to remain unchanged");
|
|
45
|
+
assert(route.repo === "org/shared-memory", "expected explicit repo override to win");
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function testLegacyRepoFallback(): void {
|
|
49
|
+
const route = resolveAgentRoute(baseConfig(), "legacy");
|
|
50
|
+
assert(route.defaultRepo === "legacy/old-memory", "expected legacy repo to act as defaultRepo fallback");
|
|
51
|
+
assert(route.repo === "legacy/old-memory", "expected selected repo to use the legacy repo fallback");
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function testIdentityOnlyStillConfigured(): void {
|
|
55
|
+
const config = baseConfig();
|
|
56
|
+
delete config.defaultRepo;
|
|
57
|
+
delete config.repo;
|
|
58
|
+
const route = resolveAgentRoute(config, "identityOnly");
|
|
59
|
+
assert(isAgentConfigured(route) === true, "expected an identity with baseUrl and token to count as configured");
|
|
60
|
+
assert(hasDefaultRepo(route) === false, "expected no default repo when only credentials are present");
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function testBootstrapRegistrationUsesStableDefaults(): void {
|
|
64
|
+
const registration = buildAgentBootstrapRegistration("Main_Coder");
|
|
65
|
+
assert(registration.prefixLogin === "main-coder", "expected agent bootstrap login prefix to match backend format");
|
|
66
|
+
assert(registration.defaultRepoName === DEFAULT_BOOTSTRAP_REPO_NAME, "expected bootstrap repo name to use the stable default");
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function testBootstrapRegistrationTrimsLongPrefixes(): void {
|
|
70
|
+
const registration = buildAgentBootstrapRegistration("___THIS_IS_A_SUPER_LONG_AGENT_ID_THAT_SHOULD_BE_TRIMMED___");
|
|
71
|
+
assert(/^[a-z0-9][a-z0-9-]*$/.test(registration.prefixLogin), "expected bootstrap login prefix to satisfy backend validation");
|
|
72
|
+
assert(registration.prefixLogin.length <= 32, "expected bootstrap login prefix to fit backend max length");
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
testDefaultRepoResolution();
|
|
76
|
+
testRepoOverride();
|
|
77
|
+
testLegacyRepoFallback();
|
|
78
|
+
testIdentityOnlyStillConfigured();
|
|
79
|
+
testBootstrapRegistrationUsesStableDefaults();
|
|
80
|
+
testBootstrapRegistrationTrimsLongPrefixes();
|
|
81
|
+
|
|
82
|
+
console.log("config tests passed");
|
package/src/config.ts
CHANGED
|
@@ -5,7 +5,7 @@ import { normalizeAgentId } from "./utils.js";
|
|
|
5
5
|
|
|
6
6
|
export const SESSION_TITLE_PREFIX = "Session: ";
|
|
7
7
|
export const MEMORY_TITLE_PREFIX = "Memory: ";
|
|
8
|
-
export const DEFAULT_LABELS: readonly string[] = [
|
|
8
|
+
export const DEFAULT_LABELS: readonly string[] = [];
|
|
9
9
|
export const AGENT_LABEL_PREFIX = "agent:";
|
|
10
10
|
export const LABEL_ACTIVE = "status:active";
|
|
11
11
|
export const LABEL_CLOSED = "status:closed";
|
|
@@ -31,6 +31,7 @@ export function resolvePluginConfig(api: OpenClawPluginApi): ClawMemPluginConfig
|
|
|
31
31
|
const agent = rawAgentConfig as Record<string, unknown>;
|
|
32
32
|
agents[agentId] = {
|
|
33
33
|
baseUrl: str(agent.baseUrl)?.replace(/\/+$/, ""),
|
|
34
|
+
defaultRepo: normalizeRepoName(str(agent.defaultRepo) ?? str(agent.repo)),
|
|
34
35
|
repo: str(agent.repo),
|
|
35
36
|
token: str(agent.token),
|
|
36
37
|
authScheme: agent.authScheme === "bearer" ? "bearer" : agent.authScheme === "token" ? "token" : undefined,
|
|
@@ -38,6 +39,9 @@ export function resolvePluginConfig(api: OpenClawPluginApi): ClawMemPluginConfig
|
|
|
38
39
|
}
|
|
39
40
|
return {
|
|
40
41
|
baseUrl: baseUrl.endsWith("/api/v3") ? baseUrl : `${baseUrl}/api/v3`,
|
|
42
|
+
defaultRepo: normalizeRepoName(str(raw.defaultRepo) ?? str(raw.repo)),
|
|
43
|
+
repo: normalizeRepoName(str(raw.repo)),
|
|
44
|
+
token: str(raw.token),
|
|
41
45
|
authScheme: raw.authScheme === "bearer" ? "bearer" : "token",
|
|
42
46
|
agents,
|
|
43
47
|
memoryRecallLimit: clamp(num(raw.memoryRecallLimit, 5), 1, 20),
|
|
@@ -46,21 +50,28 @@ export function resolvePluginConfig(api: OpenClawPluginApi): ClawMemPluginConfig
|
|
|
46
50
|
};
|
|
47
51
|
}
|
|
48
52
|
|
|
49
|
-
export function resolveAgentRoute(config: ClawMemPluginConfig, agentId?: string): ClawMemResolvedRoute {
|
|
53
|
+
export function resolveAgentRoute(config: ClawMemPluginConfig, agentId?: string, repoOverride?: string): ClawMemResolvedRoute {
|
|
50
54
|
const id = normalizeAgentId(agentId);
|
|
51
55
|
const agent = config.agents[id] ?? {};
|
|
52
56
|
const baseUrl = (agent.baseUrl ?? config.baseUrl).replace(/\/+$/, "");
|
|
57
|
+
const defaultRepo = normalizeRepoName(agent.defaultRepo ?? agent.repo) ?? config.defaultRepo ?? normalizeRepoName(config.repo);
|
|
58
|
+
const repo = normalizeRepoName(repoOverride) ?? defaultRepo;
|
|
53
59
|
return {
|
|
54
60
|
agentId: id,
|
|
55
61
|
baseUrl: baseUrl.endsWith("/api/v3") ? baseUrl : `${baseUrl}/api/v3`,
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
62
|
+
...(defaultRepo ? { defaultRepo } : {}),
|
|
63
|
+
...(repo ? { repo } : {}),
|
|
64
|
+
token: agent.token?.trim() || config.token?.trim() || undefined,
|
|
65
|
+
authScheme: agent.authScheme === "bearer" ? "bearer" : agent.authScheme === "token" ? "token" : config.authScheme,
|
|
59
66
|
};
|
|
60
67
|
}
|
|
61
68
|
|
|
62
69
|
export function isAgentConfigured(route: ClawMemResolvedRoute): boolean {
|
|
63
|
-
return Boolean(route.baseUrl && route.
|
|
70
|
+
return Boolean(route.baseUrl && route.token);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function hasDefaultRepo(route: ClawMemResolvedRoute): boolean {
|
|
74
|
+
return Boolean(route.defaultRepo);
|
|
64
75
|
}
|
|
65
76
|
|
|
66
77
|
export function resolveLabelColor(label: string): string {
|
|
@@ -97,3 +108,9 @@ export function labelVal(labels: string[], prefix: string): string | undefined {
|
|
|
97
108
|
const m = labels.find((l) => l.startsWith(prefix));
|
|
98
109
|
return m ? m.slice(prefix.length).trim() || undefined : undefined;
|
|
99
110
|
}
|
|
111
|
+
|
|
112
|
+
function normalizeRepoName(value: string | undefined): string | undefined {
|
|
113
|
+
if (!value) return undefined;
|
|
114
|
+
const trimmed = value.trim().replace(/^\/+|\/+$/g, "");
|
|
115
|
+
return /^[^/\s]+\/[^/\s]+$/.test(trimmed) ? trimmed : undefined;
|
|
116
|
+
}
|
package/src/github-client.ts
CHANGED
|
@@ -1,11 +1,67 @@
|
|
|
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, ClawMemResolvedRoute } from "./types.js";
|
|
3
|
+
import type { AgentRegistrationResponse, 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 SearchIssuesResponse = { items?: IssueResponse[]; total_count?: number; incomplete_results?: boolean };
|
|
7
7
|
type CommentResponse = { id?: number; body?: string; created_at?: string };
|
|
8
8
|
type LabelResponse = { name?: string; color?: string; description?: string };
|
|
9
|
+
type PermissionMap = Record<string, boolean | undefined>;
|
|
10
|
+
type RepoResponse = {
|
|
11
|
+
name?: string;
|
|
12
|
+
full_name?: string;
|
|
13
|
+
description?: string;
|
|
14
|
+
private?: boolean;
|
|
15
|
+
owner?: { login?: string };
|
|
16
|
+
permissions?: PermissionMap;
|
|
17
|
+
role_name?: string;
|
|
18
|
+
};
|
|
19
|
+
type OrgResponse = {
|
|
20
|
+
id?: number;
|
|
21
|
+
login?: string;
|
|
22
|
+
name?: string;
|
|
23
|
+
description?: string;
|
|
24
|
+
default_repository_permission?: string;
|
|
25
|
+
};
|
|
26
|
+
type TeamResponse = {
|
|
27
|
+
id?: number;
|
|
28
|
+
slug?: string;
|
|
29
|
+
name?: string;
|
|
30
|
+
description?: string;
|
|
31
|
+
privacy?: string;
|
|
32
|
+
permission?: string;
|
|
33
|
+
role_name?: string;
|
|
34
|
+
permissions?: PermissionMap;
|
|
35
|
+
};
|
|
36
|
+
type CollaboratorResponse = {
|
|
37
|
+
id?: number;
|
|
38
|
+
login?: string;
|
|
39
|
+
name?: string;
|
|
40
|
+
permissions?: PermissionMap;
|
|
41
|
+
role_name?: string;
|
|
42
|
+
};
|
|
43
|
+
type RepositoryInvitationResponse = {
|
|
44
|
+
id?: number;
|
|
45
|
+
created_at?: string;
|
|
46
|
+
permissions?: string;
|
|
47
|
+
repository?: RepoResponse;
|
|
48
|
+
invitee?: { login?: string; name?: string };
|
|
49
|
+
inviter?: { login?: string; name?: string };
|
|
50
|
+
};
|
|
51
|
+
type TeamMembershipResponse = { state?: string; role?: string };
|
|
52
|
+
type InvitationResponse = {
|
|
53
|
+
id?: number;
|
|
54
|
+
role?: string;
|
|
55
|
+
created_at?: string;
|
|
56
|
+
expires_at?: string | null;
|
|
57
|
+
email?: string;
|
|
58
|
+
login?: string;
|
|
59
|
+
organization?: OrgResponse;
|
|
60
|
+
invitee?: { login?: string };
|
|
61
|
+
inviter?: { login?: string };
|
|
62
|
+
team_ids?: number[];
|
|
63
|
+
teams?: TeamResponse[];
|
|
64
|
+
};
|
|
9
65
|
type ReqOpts = { allowNotFound?: boolean; allowValidationError?: boolean; omitAuth?: boolean };
|
|
10
66
|
|
|
11
67
|
export class GitHubIssueClient {
|
|
@@ -14,6 +70,9 @@ export class GitHubIssueClient {
|
|
|
14
70
|
repo(): string | undefined {
|
|
15
71
|
return this.config.repo?.trim() || undefined;
|
|
16
72
|
}
|
|
73
|
+
defaultRepo(): string | undefined {
|
|
74
|
+
return this.config.defaultRepo?.trim() || undefined;
|
|
75
|
+
}
|
|
17
76
|
|
|
18
77
|
async createIssue(params: { title: string; body: string; labels: string[] }): Promise<IssueResponse> {
|
|
19
78
|
return this.req<IssueResponse>(this.repoPath("issues"), { method: "POST", body: JSON.stringify(params) });
|
|
@@ -53,6 +112,144 @@ export class GitHubIssueClient {
|
|
|
53
112
|
q.set("per_page", String(params?.perPage ?? 100));
|
|
54
113
|
return this.req<LabelResponse[]>(`${this.repoPath("labels")}?${q}`, { method: "GET" });
|
|
55
114
|
}
|
|
115
|
+
async listUserRepos(): Promise<RepoResponse[]> {
|
|
116
|
+
return this.req<RepoResponse[]>("user/repos", { method: "GET" });
|
|
117
|
+
}
|
|
118
|
+
async createUserRepo(params: { name: string; description?: string; private?: boolean; autoInit?: boolean }): Promise<RepoResponse> {
|
|
119
|
+
return this.req<RepoResponse>("user/repos", {
|
|
120
|
+
method: "POST",
|
|
121
|
+
body: JSON.stringify({
|
|
122
|
+
name: params.name,
|
|
123
|
+
...(params.description ? { description: params.description } : {}),
|
|
124
|
+
private: params.private ?? true,
|
|
125
|
+
auto_init: params.autoInit ?? false,
|
|
126
|
+
}),
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
async listUserOrgs(): Promise<OrgResponse[]> {
|
|
130
|
+
return this.req<OrgResponse[]>("user/orgs", { method: "GET" });
|
|
131
|
+
}
|
|
132
|
+
async createUserOrg(params: { login: string; name?: string; defaultRepositoryPermission?: string }): Promise<OrgResponse> {
|
|
133
|
+
return this.req<OrgResponse>("user/orgs", {
|
|
134
|
+
method: "POST",
|
|
135
|
+
body: JSON.stringify({
|
|
136
|
+
login: params.login,
|
|
137
|
+
...(params.name ? { name: params.name } : {}),
|
|
138
|
+
...(params.defaultRepositoryPermission ? { default_repository_permission: params.defaultRepositoryPermission } : {}),
|
|
139
|
+
}),
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
async getOrg(org: string): Promise<OrgResponse> {
|
|
143
|
+
return this.req<OrgResponse>(`orgs/${encodeURIComponent(org)}`, { method: "GET" });
|
|
144
|
+
}
|
|
145
|
+
async listOrgTeams(org: string): Promise<TeamResponse[]> {
|
|
146
|
+
return this.req<TeamResponse[]>(`orgs/${encodeURIComponent(org)}/teams`, { method: "GET" });
|
|
147
|
+
}
|
|
148
|
+
async createOrgTeam(org: string, params: { name: string; description?: string; privacy?: "closed" | "secret" }): Promise<TeamResponse> {
|
|
149
|
+
return this.req<TeamResponse>(`orgs/${encodeURIComponent(org)}/teams`, {
|
|
150
|
+
method: "POST",
|
|
151
|
+
body: JSON.stringify({
|
|
152
|
+
name: params.name,
|
|
153
|
+
...(params.description ? { description: params.description } : {}),
|
|
154
|
+
privacy: params.privacy ?? "closed",
|
|
155
|
+
}),
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
async setTeamMembership(org: string, teamSlug: string, username: string, role: "member" | "maintainer"): Promise<TeamMembershipResponse> {
|
|
159
|
+
return this.req<TeamMembershipResponse>(
|
|
160
|
+
`orgs/${encodeURIComponent(org)}/teams/${encodeURIComponent(teamSlug)}/memberships/${encodeURIComponent(username)}`,
|
|
161
|
+
{ method: "PUT", body: JSON.stringify({ role }) },
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
async removeTeamMembership(org: string, teamSlug: string, username: string): Promise<void> {
|
|
165
|
+
await this.req(
|
|
166
|
+
`orgs/${encodeURIComponent(org)}/teams/${encodeURIComponent(teamSlug)}/memberships/${encodeURIComponent(username)}`,
|
|
167
|
+
{ method: "DELETE" },
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
async listTeamRepos(org: string, teamSlug: string): Promise<RepoResponse[]> {
|
|
171
|
+
return this.req<RepoResponse[]>(`orgs/${encodeURIComponent(org)}/teams/${encodeURIComponent(teamSlug)}/repos`, { method: "GET" });
|
|
172
|
+
}
|
|
173
|
+
async setTeamRepoAccess(org: string, teamSlug: string, owner: string, repo: string, permission: "read" | "write" | "admin"): Promise<void> {
|
|
174
|
+
await this.req(
|
|
175
|
+
`orgs/${encodeURIComponent(org)}/teams/${encodeURIComponent(teamSlug)}/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`,
|
|
176
|
+
{ method: "PUT", body: JSON.stringify({ permission }) },
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
async removeTeamRepoAccess(org: string, teamSlug: string, owner: string, repo: string): Promise<void> {
|
|
180
|
+
await this.req(
|
|
181
|
+
`orgs/${encodeURIComponent(org)}/teams/${encodeURIComponent(teamSlug)}/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`,
|
|
182
|
+
{ method: "DELETE" },
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
async listRepoCollaborators(owner: string, repo: string): Promise<CollaboratorResponse[]> {
|
|
186
|
+
return this.req<CollaboratorResponse[]>(
|
|
187
|
+
`repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/collaborators`,
|
|
188
|
+
{ method: "GET" },
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
async listRepoInvitations(owner: string, repo: string): Promise<RepositoryInvitationResponse[]> {
|
|
192
|
+
return this.req<RepositoryInvitationResponse[]>(
|
|
193
|
+
`repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/invitations`,
|
|
194
|
+
{ method: "GET" },
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
async setRepoCollaborator(owner: string, repo: string, username: string, permission: "read" | "write" | "admin"): Promise<RepositoryInvitationResponse | undefined> {
|
|
198
|
+
return this.req<RepositoryInvitationResponse>(
|
|
199
|
+
`repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/collaborators/${encodeURIComponent(username)}`,
|
|
200
|
+
{ method: "PUT", body: JSON.stringify({ permission }) },
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
async removeRepoCollaborator(owner: string, repo: string, username: string): Promise<void> {
|
|
204
|
+
await this.req(
|
|
205
|
+
`repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/collaborators/${encodeURIComponent(username)}`,
|
|
206
|
+
{ method: "DELETE" },
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
async getRepo(owner: string, repo: string): Promise<RepoResponse> {
|
|
210
|
+
return this.req<RepoResponse>(`repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`, { method: "GET" });
|
|
211
|
+
}
|
|
212
|
+
async listRepoTeams(owner: string, repo: string): Promise<TeamResponse[]> {
|
|
213
|
+
return this.req<TeamResponse[]>(`repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/teams`, { method: "GET" });
|
|
214
|
+
}
|
|
215
|
+
async listUserRepoInvitations(): Promise<RepositoryInvitationResponse[]> {
|
|
216
|
+
return this.req<RepositoryInvitationResponse[]>("user/repository_invitations", { method: "GET" });
|
|
217
|
+
}
|
|
218
|
+
async acceptUserRepoInvitation(invitationId: number): Promise<void> {
|
|
219
|
+
await this.req(`user/repository_invitations/${invitationId}`, { method: "PATCH" });
|
|
220
|
+
}
|
|
221
|
+
async declineUserRepoInvitation(invitationId: number): Promise<void> {
|
|
222
|
+
await this.req(`user/repository_invitations/${invitationId}`, { method: "DELETE" });
|
|
223
|
+
}
|
|
224
|
+
async listOrgInvitations(org: string): Promise<InvitationResponse[]> {
|
|
225
|
+
return this.req<InvitationResponse[]>(`orgs/${encodeURIComponent(org)}/invitations`, { method: "GET" });
|
|
226
|
+
}
|
|
227
|
+
async createOrgInvitation(
|
|
228
|
+
org: string,
|
|
229
|
+
params: { inviteeLogin: string; role?: "member" | "admin"; teamIds?: number[]; expiresInDays?: number },
|
|
230
|
+
): Promise<InvitationResponse> {
|
|
231
|
+
return this.req<InvitationResponse>(`orgs/${encodeURIComponent(org)}/invitations`, {
|
|
232
|
+
method: "POST",
|
|
233
|
+
body: JSON.stringify({
|
|
234
|
+
invitee_login: params.inviteeLogin,
|
|
235
|
+
role: params.role ?? "member",
|
|
236
|
+
...(params.teamIds && params.teamIds.length > 0 ? { team_ids: params.teamIds } : {}),
|
|
237
|
+
...(typeof params.expiresInDays === "number" ? { expires_in_days: params.expiresInDays } : {}),
|
|
238
|
+
}),
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
async listOrgOutsideCollaborators(org: string): Promise<CollaboratorResponse[]> {
|
|
242
|
+
return this.req<CollaboratorResponse[]>(`orgs/${encodeURIComponent(org)}/outside_collaborators`, { method: "GET" });
|
|
243
|
+
}
|
|
244
|
+
async listUserOrgInvitations(): Promise<InvitationResponse[]> {
|
|
245
|
+
return this.req<InvitationResponse[]>("user/organization_invitations", { method: "GET" });
|
|
246
|
+
}
|
|
247
|
+
async acceptUserOrgInvitation(invitationId: number): Promise<void> {
|
|
248
|
+
await this.req(`user/organization_invitations/${invitationId}`, { method: "PATCH" });
|
|
249
|
+
}
|
|
250
|
+
async declineUserOrgInvitation(invitationId: number): Promise<void> {
|
|
251
|
+
await this.req(`user/organization_invitations/${invitationId}`, { method: "DELETE" });
|
|
252
|
+
}
|
|
56
253
|
async ensureLabels(labels: string[]): Promise<void> {
|
|
57
254
|
for (const label of labels) {
|
|
58
255
|
if (!label.trim()) continue;
|
|
@@ -71,6 +268,15 @@ export class GitHubIssueClient {
|
|
|
71
268
|
async updateRepoDescription(description: string): Promise<void> {
|
|
72
269
|
await this.req(this.repoPath("").replace(/\/$/, ""), { method: "PATCH", body: JSON.stringify({ description }) });
|
|
73
270
|
}
|
|
271
|
+
async registerAgent(prefixLogin: string, defaultRepoName: string): Promise<AgentRegistrationResponse> {
|
|
272
|
+
return this.req<AgentRegistrationResponse>("agents", {
|
|
273
|
+
method: "POST",
|
|
274
|
+
body: JSON.stringify({
|
|
275
|
+
prefix_login: prefixLogin,
|
|
276
|
+
default_repo_name: defaultRepoName,
|
|
277
|
+
}),
|
|
278
|
+
}, { omitAuth: true });
|
|
279
|
+
}
|
|
74
280
|
async createAnonymousSession(locale?: string): Promise<AnonymousSessionResponse> {
|
|
75
281
|
const body = locale ? JSON.stringify({ locale }) : undefined;
|
|
76
282
|
return this.req<AnonymousSessionResponse>("anonymous/session", { method: "POST", ...(body ? { body } : {}) }, { omitAuth: true });
|
package/src/memory.test.ts
CHANGED
|
@@ -7,7 +7,6 @@ function memory(overrides: Partial<ParsedMemoryIssue> = {}): ParsedMemoryIssue {
|
|
|
7
7
|
issueNumber: overrides.issueNumber ?? 1,
|
|
8
8
|
title: overrides.title ?? "Memory: Example",
|
|
9
9
|
memoryId: overrides.memoryId ?? String(overrides.issueNumber ?? 1),
|
|
10
|
-
sessionId: overrides.sessionId ?? "sess-1",
|
|
11
10
|
date: overrides.date ?? "2026-03-23",
|
|
12
11
|
detail: overrides.detail ?? "Example durable detail",
|
|
13
12
|
status: overrides.status ?? "active",
|
|
@@ -32,7 +31,6 @@ function issueFromMemory(m: ParsedMemoryIssue): IssueRecord {
|
|
|
32
31
|
state: m.status === "stale" ? "closed" : "open",
|
|
33
32
|
labels: [
|
|
34
33
|
"type:memory",
|
|
35
|
-
`session:${m.sessionId}`,
|
|
36
34
|
...(m.kind ? [`kind:${m.kind}`] : []),
|
|
37
35
|
...(m.topics ?? []).map((topic) => `topic:${topic}`),
|
|
38
36
|
],
|
|
@@ -158,7 +156,7 @@ async function testStructuredStoreAndSchema(): Promise<void> {
|
|
|
158
156
|
},
|
|
159
157
|
};
|
|
160
158
|
const store = new MemoryStore(client as never, {} as never, { memoryRecallLimit: 5, turnCommentDelayMs: 1000, summaryWaitTimeoutMs: 120000 } as never);
|
|
161
|
-
const result = await store.store({ detail: "Redis Lua scripts are required for atomic rate limiting.", kind: "Lesson", topics: ["Redis Ops", "rate_limit"] }
|
|
159
|
+
const result = await store.store({ detail: "Redis Lua scripts are required for atomic rate limiting.", kind: "Lesson", topics: ["Redis Ops", "rate_limit"] });
|
|
162
160
|
const schema = await store.listSchema();
|
|
163
161
|
|
|
164
162
|
assert(result.created === true, "expected a new structured memory to be created");
|
|
@@ -168,6 +166,7 @@ async function testStructuredStoreAndSchema(): Promise<void> {
|
|
|
168
166
|
assert(created[0]?.labels.includes("kind:lesson"), "expected created labels to include normalized kind");
|
|
169
167
|
assert(created[0]?.labels.includes("topic:redis-ops"), "expected created labels to include normalized topic");
|
|
170
168
|
assert(created[0]?.labels.includes("topic:rate-limit"), "expected created labels to include normalized topic");
|
|
169
|
+
assert(!created[0]?.labels.some((label) => label.startsWith("session:")), "expected manual memory_store writes to omit synthetic session labels");
|
|
171
170
|
assert(!created[0]?.labels.some((label) => label.startsWith("date:")), "expected new memory labels to omit date labels");
|
|
172
171
|
assert(created[0]?.body.includes(`date: ${result.memory.date}`), "expected new memory body to retain logical date metadata");
|
|
173
172
|
assert(ensured[0]?.includes("kind:lesson"), "expected ensureLabels to include kind label");
|
|
@@ -249,7 +248,6 @@ async function testLegacyMemoriesWithoutSessionOrDate(): Promise<void> {
|
|
|
249
248
|
const recalled = await store.search("F1 Dota 2", 5);
|
|
250
249
|
|
|
251
250
|
assert(exact?.issueNumber === 4, "expected legacy memory without session/date to be readable");
|
|
252
|
-
assert(exact?.sessionId === "legacy", "expected missing session label to fall back to legacy");
|
|
253
251
|
assert(exact?.date === "1970-01-01", "expected missing date label to fall back to a placeholder");
|
|
254
252
|
assert(recalled.some((memory) => memory.issueNumber === 4), "expected legacy memory to participate in recall");
|
|
255
253
|
}
|
package/src/memory.ts
CHANGED
|
@@ -71,7 +71,7 @@ export class MemoryStore {
|
|
|
71
71
|
.slice(0, limit);
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
-
async store(draft: MemoryDraft
|
|
74
|
+
async store(draft: MemoryDraft): Promise<{ created: boolean; memory: ParsedMemoryIssue }> {
|
|
75
75
|
const normalized = normalizeDraft(draft);
|
|
76
76
|
const detail = norm(normalized.detail);
|
|
77
77
|
const allActive = await this.listByStatus("active");
|
|
@@ -83,7 +83,7 @@ export class MemoryStore {
|
|
|
83
83
|
}
|
|
84
84
|
|
|
85
85
|
const date = localDate();
|
|
86
|
-
const labels = memLabels(
|
|
86
|
+
const labels = memLabels(normalized.kind, normalized.topics);
|
|
87
87
|
const title = `${MEMORY_TITLE_PREFIX}${trunc(detail, 72)}`;
|
|
88
88
|
const body = stringifyFlatYaml([["memory_hash", hash], ["date", date], ["detail", detail]]);
|
|
89
89
|
await this.client.ensureLabels(labels);
|
|
@@ -95,7 +95,6 @@ export class MemoryStore {
|
|
|
95
95
|
title,
|
|
96
96
|
memoryId: String(issue.number),
|
|
97
97
|
memoryHash: hash,
|
|
98
|
-
sessionId,
|
|
99
98
|
date,
|
|
100
99
|
detail,
|
|
101
100
|
...(normalized.kind ? { kind: normalized.kind } : {}),
|
|
@@ -121,7 +120,7 @@ export class MemoryStore {
|
|
|
121
120
|
if (duplicate) throw new Error(`another active memory already stores this detail as [${duplicate.memoryId}]`);
|
|
122
121
|
const nextTitle = `${MEMORY_TITLE_PREFIX}${trunc(nextDetail, 72)}`;
|
|
123
122
|
const nextBody = stringifyFlatYaml([["memory_hash", nextHash], ["date", current.date], ["detail", nextDetail]]);
|
|
124
|
-
const nextLabels = memLabels(
|
|
123
|
+
const nextLabels = memLabels(nextKind, nextTopics);
|
|
125
124
|
await this.client.ensureLabels(nextLabels);
|
|
126
125
|
await this.client.updateIssue(current.issueNumber, { title: nextTitle, body: nextBody });
|
|
127
126
|
await this.client.syncManagedLabels(current.issueNumber, nextLabels);
|
|
@@ -140,7 +139,7 @@ export class MemoryStore {
|
|
|
140
139
|
if (!id) throw new Error("memoryId is empty");
|
|
141
140
|
const mem = await this.get(id, "active");
|
|
142
141
|
if (!mem) return null;
|
|
143
|
-
await this.client.syncManagedLabels(mem.issueNumber, memLabels(mem.
|
|
142
|
+
await this.client.syncManagedLabels(mem.issueNumber, memLabels(mem.kind, mem.topics));
|
|
144
143
|
await this.client.updateIssue(mem.issueNumber, { state: "closed" });
|
|
145
144
|
return { ...mem, status: "stale" };
|
|
146
145
|
}
|
|
@@ -148,7 +147,7 @@ export class MemoryStore {
|
|
|
148
147
|
async syncFromConversation(session: SessionMirrorState, snapshot: TranscriptSnapshot): Promise<boolean> {
|
|
149
148
|
try {
|
|
150
149
|
const decision = await this.generateDecision(session, snapshot);
|
|
151
|
-
const { savedCount, staledCount } = await this.applyDecision(
|
|
150
|
+
const { savedCount, staledCount } = await this.applyDecision(decision);
|
|
152
151
|
if (savedCount > 0 || staledCount > 0)
|
|
153
152
|
this.api.logger.info?.(`clawmem: synced memories for ${session.sessionId} (saved=${savedCount}, stale=${staledCount})`);
|
|
154
153
|
return true;
|
|
@@ -199,7 +198,7 @@ export class MemoryStore {
|
|
|
199
198
|
private parseIssue(issue: { number: number; title?: string; body?: string; state?: string; labels?: Array<{ name?: string } | string> }): ParsedMemoryIssue | null {
|
|
200
199
|
const labels = extractLabelNames(issue.labels);
|
|
201
200
|
if (!labels.includes("type:memory")) return null;
|
|
202
|
-
const
|
|
201
|
+
const kind = labelVal(labels, "kind:");
|
|
203
202
|
const topics = labels.filter((l) => l.startsWith("topic:")).map((l) => l.slice(6).trim()).filter(Boolean);
|
|
204
203
|
const rawBody = (issue.body ?? "").trim();
|
|
205
204
|
const body = rawBody ? parseFlatYaml(rawBody) : {};
|
|
@@ -210,7 +209,6 @@ export class MemoryStore {
|
|
|
210
209
|
issueNumber: issue.number, title: issue.title?.trim() || "",
|
|
211
210
|
memoryId: body.memory_id?.trim() || String(issue.number),
|
|
212
211
|
memoryHash: body.memory_hash?.trim() || undefined,
|
|
213
|
-
sessionId: sessionId || "legacy",
|
|
214
212
|
date: body.date?.trim() || "1970-01-01",
|
|
215
213
|
detail,
|
|
216
214
|
...(kind ? { kind } : {}),
|
|
@@ -219,7 +217,7 @@ export class MemoryStore {
|
|
|
219
217
|
};
|
|
220
218
|
}
|
|
221
219
|
|
|
222
|
-
private async applyDecision(
|
|
220
|
+
private async applyDecision(decision: MemoryDecision): Promise<{ savedCount: number; staledCount: number }> {
|
|
223
221
|
const allActive = await this.listByStatus("active");
|
|
224
222
|
const activeById = new Map(allActive.map((m) => [m.memoryId, m]));
|
|
225
223
|
const activeByHash = new Map(allActive.map((m) => [m.memoryHash || sha256(norm(m.detail)), m]));
|
|
@@ -235,7 +233,7 @@ export class MemoryStore {
|
|
|
235
233
|
activeByHash.set(hash, merged);
|
|
236
234
|
continue;
|
|
237
235
|
}
|
|
238
|
-
const labels = memLabels(
|
|
236
|
+
const labels = memLabels(draft.kind, draft.topics);
|
|
239
237
|
const date = localDate();
|
|
240
238
|
const title = `${MEMORY_TITLE_PREFIX}${trunc(detail, 72)}`;
|
|
241
239
|
const body = stringifyFlatYaml([["memory_hash", hash], ["date", date], ["detail", detail]]);
|
|
@@ -246,7 +244,6 @@ export class MemoryStore {
|
|
|
246
244
|
title,
|
|
247
245
|
memoryId: String(issue.number),
|
|
248
246
|
memoryHash: hash,
|
|
249
|
-
sessionId,
|
|
250
247
|
date,
|
|
251
248
|
detail,
|
|
252
249
|
...(draft.kind ? { kind: draft.kind } : {}),
|
|
@@ -259,7 +256,7 @@ export class MemoryStore {
|
|
|
259
256
|
for (const id of [...new Set(decision.stale.map((s) => s.trim()).filter(Boolean))]) {
|
|
260
257
|
const mem = activeById.get(id);
|
|
261
258
|
if (!mem) continue;
|
|
262
|
-
await this.client.syncManagedLabels(mem.issueNumber, memLabels(mem.
|
|
259
|
+
await this.client.syncManagedLabels(mem.issueNumber, memLabels(mem.kind, mem.topics));
|
|
263
260
|
await this.client.updateIssue(mem.issueNumber, { state: "closed" });
|
|
264
261
|
staledCount++;
|
|
265
262
|
}
|
|
@@ -318,7 +315,7 @@ export class MemoryStore {
|
|
|
318
315
|
const sameKind = (memory.kind ?? "") === (nextKind ?? "");
|
|
319
316
|
const sameTopics = JSON.stringify(currentTopics) === JSON.stringify(nextTopics);
|
|
320
317
|
if (sameKind && sameTopics) return memory;
|
|
321
|
-
const labels = memLabels(
|
|
318
|
+
const labels = memLabels(nextKind, nextTopics);
|
|
322
319
|
await this.client.ensureLabels(labels);
|
|
323
320
|
await this.client.syncManagedLabels(memory.issueNumber, labels);
|
|
324
321
|
return {
|
|
@@ -329,10 +326,9 @@ export class MemoryStore {
|
|
|
329
326
|
}
|
|
330
327
|
}
|
|
331
328
|
|
|
332
|
-
function memLabels(
|
|
329
|
+
function memLabels(kind?: string, topics?: string[]): string[] {
|
|
333
330
|
return [
|
|
334
331
|
"type:memory",
|
|
335
|
-
`session:${sessionId}`,
|
|
336
332
|
...(kind ? [`kind:${kind}`] : []),
|
|
337
333
|
...((topics ?? []).map((topic) => `topic:${topic}`)),
|
|
338
334
|
];
|