@clawmem-ai/clawmem 0.1.12 → 0.1.14
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 +5 -3
- package/openclaw.plugin.json +10 -1
- package/package.json +1 -1
- package/skills/clawmem/SKILL.md +10 -0
- package/skills/clawmem/references/collaboration.md +2 -0
- package/skills/clawmem/references/communication.md +5 -0
- package/skills/clawmem/references/manual-ops.md +9 -4
- package/skills/clawmem/references/repair.md +2 -1
- package/skills/clawmem/references/schema.md +8 -0
- package/src/collaboration.test.ts +71 -0
- package/src/collaboration.ts +109 -0
- package/src/config.test.ts +1 -0
- package/src/config.ts +4 -3
- package/src/github-client.ts +4 -4
- package/src/memory.test.ts +85 -8
- package/src/memory.ts +83 -22
- package/src/service.test.ts +141 -0
- package/src/service.ts +344 -65
- package/src/types.ts +2 -1
package/README.md
CHANGED
|
@@ -137,7 +137,8 @@ Full config with all options:
|
|
|
137
137
|
},
|
|
138
138
|
turnCommentDelayMs: 1000,
|
|
139
139
|
summaryWaitTimeoutMs: 120000,
|
|
140
|
-
memoryRecallLimit: 5
|
|
140
|
+
memoryRecallLimit: 5,
|
|
141
|
+
memoryAutoRecallLimit: 5
|
|
141
142
|
}
|
|
142
143
|
}
|
|
143
144
|
}
|
|
@@ -152,7 +153,7 @@ Full config with all options:
|
|
|
152
153
|
- Conversation comments exclude tool calls, tool results, system messages, and heartbeat noise.
|
|
153
154
|
- Summary failures do not block finalization; the `summary` field is written as `failed: ...`.
|
|
154
155
|
- Memory search and auto-injection only return open `type:memory` issues. Closed memory issues are treated as stale.
|
|
155
|
-
- `memory_recall` now prefers the backend `/api/v3/search/issues` endpoint scoped to the current repo plus `label:"type:memory"
|
|
156
|
+
- `memory_recall` now prefers the backend `/api/v3/search/issues` endpoint scoped to the current repo plus `label:"type:memory"`, with a simple local lexical fallback when backend search is unavailable or returns no matches.
|
|
156
157
|
- Durable memories are extracted best-effort during later request-scoped maintenance, not by background subagent work after a request has already ended.
|
|
157
158
|
- The plugin exposes `memory_repos`, `memory_repo_create`, `memory_list`, `memory_get`, `memory_labels`, `memory_recall`, `memory_store`, `memory_update`, and `memory_forget` for mid-session use.
|
|
158
159
|
- Route resolution is now: agent identity supplies credentials, `defaultRepo` is the fallback memory space, and explicit tool calls may override repo per operation.
|
|
@@ -160,4 +161,5 @@ Full config with all options:
|
|
|
160
161
|
- Memory issues no longer use `session:*` labels. Session linkage remains a conversation concern, not part of the durable memory schema.
|
|
161
162
|
- `memory_update` updates one existing memory issue in place; use it for evolving canonical facts or active tasks instead of creating a duplicate node.
|
|
162
163
|
- Conversation lifecycle is stored in native issue state (`open` while live, `closed` after finalize); memory lifecycle uses native issue state too (`open` active, `closed` stale).
|
|
163
|
-
- Memory
|
|
164
|
+
- Memory extraction now prefers one atomic fact per memory item instead of bundling whole sessions into a single node.
|
|
165
|
+
- Memory issue bodies store the durable detail in a YAML `detail` field plus flat metadata such as `memory_hash` and logical `date`; this matches the current Console parser in `agent-git-service/web`.
|
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.14",
|
|
5
5
|
"description": "Mirror OpenClaw sessions into GitHub-compatible issues and comments.",
|
|
6
6
|
"kind": "memory",
|
|
7
7
|
"skills": [
|
|
@@ -76,6 +76,11 @@
|
|
|
76
76
|
"type": "integer",
|
|
77
77
|
"minimum": 1,
|
|
78
78
|
"maximum": 20
|
|
79
|
+
},
|
|
80
|
+
"memoryAutoRecallLimit": {
|
|
81
|
+
"type": "integer",
|
|
82
|
+
"minimum": 1,
|
|
83
|
+
"maximum": 20
|
|
79
84
|
}
|
|
80
85
|
}
|
|
81
86
|
},
|
|
@@ -118,6 +123,10 @@
|
|
|
118
123
|
},
|
|
119
124
|
"memoryRecallLimit": {
|
|
120
125
|
"label": "Memory Recall Limit",
|
|
126
|
+
"help": "Default maximum number of active memories returned by memory recall tools."
|
|
127
|
+
},
|
|
128
|
+
"memoryAutoRecallLimit": {
|
|
129
|
+
"label": "Auto Recall Limit",
|
|
121
130
|
"help": "Maximum number of active memories injected into context before an agent starts."
|
|
122
131
|
}
|
|
123
132
|
}
|
package/package.json
CHANGED
package/skills/clawmem/SKILL.md
CHANGED
|
@@ -46,8 +46,13 @@ On every user turn, run this loop:
|
|
|
46
46
|
- Never treat a `memory_recall` miss by itself as proof that no relevant memory exists.
|
|
47
47
|
2. After answering, ask: did this turn create durable knowledge?
|
|
48
48
|
- Default to yes for corrections, preferences, decisions, workflows, lessons, and status changes.
|
|
49
|
+
- Prefer one durable fact per memory. If a turn contains several independent facts, save them separately instead of bundling them into one summary memory.
|
|
49
50
|
- Use `memory_update` when the same canonical fact or ongoing task should keep evolving as one node.
|
|
51
|
+
- When updating an existing memory, preserve that node's current language unless the user explicitly asks for a rewrite.
|
|
50
52
|
- Use `memory_store` when this is a genuinely new memory.
|
|
53
|
+
- When using `memory_store`, pass both `title` and `detail` when you can. Keep the title concise and human-readable, and keep `detail` as the full durable fact.
|
|
54
|
+
- When using `memory_update`, pass `title` as well if the existing title is too short, outdated, or less precise than the current canonical fact.
|
|
55
|
+
- For new memories, write the memory title and body in the user's current language by default.
|
|
51
56
|
- Use `memory_forget` when a memory is stale, superseded, or harmful if reused.
|
|
52
57
|
3. Keep the user posted.
|
|
53
58
|
- If a retrieved memory materially shaped the answer, including automatic session-start recall, briefly surface that fact in the user's current language.
|
|
@@ -64,6 +69,11 @@ Bias toward retrieving and saving. A missed search or missed memory is worse tha
|
|
|
64
69
|
- Private personal memory usually belongs in the agent's `defaultRepo`.
|
|
65
70
|
- Project memory belongs in the relevant project repo.
|
|
66
71
|
- Shared or team knowledge belongs in the shared repo for that group.
|
|
72
|
+
- Memory titles and bodies default to the user's current language for new memories.
|
|
73
|
+
- Prefer a short standalone title plus a fuller `detail` body instead of stuffing the whole memory into the title.
|
|
74
|
+
- If you omit `title`, the plugin may derive it from `detail`, but providing an explicit title is preferred for readability in the Console.
|
|
75
|
+
- When updating an existing memory, keep that node in its current language unless the user explicitly asks to rewrite it.
|
|
76
|
+
- Keep schema labels and machine-oriented fields stable. Do not translate `type:*`, `kind:*`, `topic:*`, or other structural identifiers.
|
|
67
77
|
- If the user is asking about collaboration, organizations, teams, invitations, collaborators, shared repo access, or why someone can or cannot access a memory repo, switch from normal memory reasoning to the collaboration workflow in `references/collaboration.md`.
|
|
68
78
|
|
|
69
79
|
## Read the right reference
|
|
@@ -96,7 +96,9 @@ Reason with these rules before every collaboration action:
|
|
|
96
96
|
- Teams are org-scoped authorization groups, not social groups.
|
|
97
97
|
- Effective repo access is `max(org base permission, direct collaborator grant, team grant)` after owner or admin shortcuts.
|
|
98
98
|
- Runtime permissions are only `none`, `read`, `write`, and `admin`.
|
|
99
|
+
- Organization invitation roles are `member` and `owner`.
|
|
99
100
|
- `memory_repos` only shows repos that are already accessible now; it does not prove there are no pending invitations.
|
|
101
|
+
- The repo collaborators API includes the repository owner row; reason about direct collaborators as explicit non-owner shares.
|
|
100
102
|
- A repo collaborator grant may create a pending repository invitation instead of immediate access when the target user is not already a collaborator.
|
|
101
103
|
- Accepting a repository invitation is what turns a pending share into visible repo access for the invitee.
|
|
102
104
|
- Outside collaborators are non-members who still have direct collaborator access to at least one org-owned repo.
|
|
@@ -27,6 +27,11 @@ Preferred confirmation:
|
|
|
27
27
|
- say that you remembered, saved, or updated it
|
|
28
28
|
- include the memory id and title only when they help with debugging, traceability, or explicit user requests
|
|
29
29
|
|
|
30
|
+
Storage language defaults:
|
|
31
|
+
- For new memories, store the human-readable title and body in the user's current language.
|
|
32
|
+
- For updates, preserve the memory node's current language unless the user explicitly asks for a rewrite.
|
|
33
|
+
- Keep labels and structural markers such as `type:*`, `kind:*`, and `topic:*` in their fixed machine-readable form.
|
|
34
|
+
|
|
30
35
|
Do not force English markers like `Memory hit` or `Locked memory` in non-English conversations. Those are examples, not required phrasing.
|
|
31
36
|
|
|
32
37
|
Examples:
|
|
@@ -75,6 +75,11 @@ Do not export `GH_HOST` or `GH_ENTERPRISE_TOKEN` globally for unrelated github.c
|
|
|
75
75
|
|
|
76
76
|
Use the tool path first. If raw issue control is required:
|
|
77
77
|
|
|
78
|
+
- For new memories, write the issue title and body in the user's current language.
|
|
79
|
+
- Prefer a concise standalone title and keep the full durable fact in the body.
|
|
80
|
+
- When manually updating an existing memory, preserve that memory's current language unless the user explicitly asks for a rewrite.
|
|
81
|
+
- Keep labels and schema markers such as `type:*`, `kind:*`, and `topic:*` in their fixed machine-readable form.
|
|
82
|
+
|
|
78
83
|
### With `gh`
|
|
79
84
|
|
|
80
85
|
```sh
|
|
@@ -85,8 +90,8 @@ done
|
|
|
85
90
|
|
|
86
91
|
GH_HOST="$CLAWMEM_HOST" GH_ENTERPRISE_TOKEN="$CLAWMEM_TOKEN" \
|
|
87
92
|
gh issue create --repo "$CLAWMEM_REPO" \
|
|
88
|
-
--title "
|
|
89
|
-
--body "<durable detail in
|
|
93
|
+
--title "<concise title in the user's current language>" \
|
|
94
|
+
--body "<durable detail in the user's current language>" \
|
|
90
95
|
--label "type:memory" \
|
|
91
96
|
--label "kind:lesson"
|
|
92
97
|
```
|
|
@@ -105,8 +110,8 @@ curl -sf -X POST -H "Authorization: token $CLAWMEM_TOKEN" \
|
|
|
105
110
|
-H "Content-Type: application/json" \
|
|
106
111
|
"$CLAWMEM_BASE_URL/repos/$CLAWMEM_REPO/issues" \
|
|
107
112
|
-d "{
|
|
108
|
-
\"title\": \"
|
|
109
|
-
\"body\": \"<durable detail in
|
|
113
|
+
\"title\": \"<concise title in the user's current language>\",
|
|
114
|
+
\"body\": \"<durable detail in the user's current language>\",
|
|
110
115
|
\"labels\": [\"type:memory\", \"kind:lesson\"]
|
|
111
116
|
}" | jq '{number, title, url: .html_url}'
|
|
112
117
|
```
|
|
@@ -98,7 +98,8 @@ When prior context may help, I search ClawMem before answering.
|
|
|
98
98
|
|
|
99
99
|
```markdown
|
|
100
100
|
Before ending every response, ask: "Did I learn anything durable this turn?"
|
|
101
|
-
If yes or unsure, save
|
|
101
|
+
If yes or unsure, save new memory content to ClawMem in the user's current language.
|
|
102
|
+
When updating an existing memory, keep that node in its current language unless the user asks to rewrite it.
|
|
102
103
|
```
|
|
103
104
|
|
|
104
105
|
### Optional TOOLS.md reminder
|
|
@@ -58,6 +58,14 @@ If you create a curated memory manually, include:
|
|
|
58
58
|
- New labels should be short, general, and likely to apply again across future memories or agents.
|
|
59
59
|
- Do not invent random label prefixes. Schema evolution must stay within `kind:*` and `topic:*`.
|
|
60
60
|
|
|
61
|
+
## Storage language
|
|
62
|
+
|
|
63
|
+
- For new memory nodes, write the human-readable title and body in the user's current language by default.
|
|
64
|
+
- When using plugin tools, prefer passing an explicit short `title` plus a fuller `detail` body.
|
|
65
|
+
- Do not treat the title as the only durable content. The body detail should still contain the full reusable fact.
|
|
66
|
+
- When updating an existing memory node, preserve that node's current language unless the user explicitly asks for a rewrite.
|
|
67
|
+
- Do not translate schema or routing markers such as `type:*`, `kind:*`, `topic:*`, or other machine-oriented field names.
|
|
68
|
+
|
|
61
69
|
## Storage rule
|
|
62
70
|
|
|
63
71
|
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,71 @@
|
|
|
1
|
+
import {
|
|
2
|
+
filterDirectCollaborators,
|
|
3
|
+
listRepoAccessTeams,
|
|
4
|
+
repoSummaryFullName,
|
|
5
|
+
resolveOrgInvitationRole,
|
|
6
|
+
} from "./collaboration.js";
|
|
7
|
+
|
|
8
|
+
function assert(condition: unknown, message: string): void {
|
|
9
|
+
if (!condition) throw new Error(message);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function testOrgInvitationRoleValidation(): void {
|
|
13
|
+
const fallback = resolveOrgInvitationRole(undefined, "member");
|
|
14
|
+
assert("role" in fallback && fallback.role === "member", "expected undefined role to fall back to member");
|
|
15
|
+
|
|
16
|
+
const owner = resolveOrgInvitationRole("owner", "member");
|
|
17
|
+
assert("role" in owner && owner.role === "owner", "expected owner role to pass through");
|
|
18
|
+
|
|
19
|
+
const invalid = resolveOrgInvitationRole("admin", "member");
|
|
20
|
+
assert("error" in invalid, "expected admin to be rejected because backend expects owner");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function testDirectCollaboratorFiltering(): void {
|
|
24
|
+
const collaborators = filterDirectCollaborators([
|
|
25
|
+
{ login: "Acme" },
|
|
26
|
+
{ login: "alice", outside_collaborator: true },
|
|
27
|
+
{ login: "bob", organization_member: true },
|
|
28
|
+
], "acme");
|
|
29
|
+
|
|
30
|
+
assert(collaborators.length === 2, "expected owner row to be excluded from explicit collaborators");
|
|
31
|
+
assert(collaborators[0]?.login === "alice", "expected alice to remain after owner filtering");
|
|
32
|
+
assert(collaborators[1]?.login === "bob", "expected bob to remain after owner filtering");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function testRepoSummaryFallback(): void {
|
|
36
|
+
assert(repoSummaryFullName({ full_name: "acme/project" }) === "acme/project", "expected explicit full_name to win");
|
|
37
|
+
assert(repoSummaryFullName({ owner: { login: "acme" }, name: "project" }) === "acme/project", "expected owner/name fallback to work");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function testRepoAccessTeamsDerivation(): Promise<void> {
|
|
41
|
+
const result = await listRepoAccessTeams({
|
|
42
|
+
async listOrgTeams() {
|
|
43
|
+
return [
|
|
44
|
+
{ slug: "admins", name: "admins" },
|
|
45
|
+
{ slug: "writers", name: "writers" },
|
|
46
|
+
{ slug: "broken", name: "broken" },
|
|
47
|
+
];
|
|
48
|
+
},
|
|
49
|
+
async listTeamRepos(_org: string, teamSlug: string) {
|
|
50
|
+
if (teamSlug === "admins") return [{ full_name: "acme/project", role_name: "admin" }];
|
|
51
|
+
if (teamSlug === "writers") return [{ owner: { login: "acme" }, name: "project", role_name: "write" }];
|
|
52
|
+
if (teamSlug === "broken") throw new Error("boom");
|
|
53
|
+
return [];
|
|
54
|
+
},
|
|
55
|
+
}, "acme", "acme/project");
|
|
56
|
+
|
|
57
|
+
assert(result.teams.length === 2, "expected two teams with access to be discovered via org->team->repo traversal");
|
|
58
|
+
assert(result.teams[0]?.slug === "admins", "expected admins team to be included");
|
|
59
|
+
assert(result.teams[1]?.slug === "writers", "expected writers team to be included");
|
|
60
|
+
assert(result.notes.length === 1 && result.notes[0]?.includes("broken"), "expected per-team lookup failures to be recorded as notes");
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function main(): Promise<void> {
|
|
64
|
+
testOrgInvitationRoleValidation();
|
|
65
|
+
testDirectCollaboratorFiltering();
|
|
66
|
+
testRepoSummaryFallback();
|
|
67
|
+
await testRepoAccessTeamsDerivation();
|
|
68
|
+
console.log("collaboration tests passed");
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
await main();
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
export type CollaborationPermission = "read" | "write" | "admin";
|
|
2
|
+
export type CollaborationOrgInvitationRole = "member" | "owner";
|
|
3
|
+
|
|
4
|
+
type PermissionMap = Record<string, boolean | undefined>;
|
|
5
|
+
|
|
6
|
+
export type CollaborationRepoSummary = {
|
|
7
|
+
full_name?: string;
|
|
8
|
+
owner?: { login?: string };
|
|
9
|
+
name?: string;
|
|
10
|
+
permissions?: PermissionMap;
|
|
11
|
+
role_name?: string;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export type CollaborationTeamSummary = {
|
|
15
|
+
id?: number;
|
|
16
|
+
slug?: string;
|
|
17
|
+
name?: string;
|
|
18
|
+
description?: string;
|
|
19
|
+
privacy?: string;
|
|
20
|
+
permission?: string;
|
|
21
|
+
role_name?: string;
|
|
22
|
+
permissions?: PermissionMap;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export type CollaborationCollaboratorSummary = {
|
|
26
|
+
id?: number;
|
|
27
|
+
login?: string;
|
|
28
|
+
name?: string;
|
|
29
|
+
permissions?: PermissionMap;
|
|
30
|
+
role_name?: string;
|
|
31
|
+
organization_member?: boolean;
|
|
32
|
+
outside_collaborator?: boolean;
|
|
33
|
+
type?: string;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
type RepoAccessTeamClient = {
|
|
37
|
+
listOrgTeams(org: string): Promise<CollaborationTeamSummary[]>;
|
|
38
|
+
listTeamRepos(org: string, teamSlug: string): Promise<CollaborationRepoSummary[]>;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export function normalizePermissionAlias(value: unknown): "none" | CollaborationPermission | undefined {
|
|
42
|
+
if (typeof value !== "string") return undefined;
|
|
43
|
+
const normalized = value.trim().toLowerCase();
|
|
44
|
+
if (!normalized) return undefined;
|
|
45
|
+
if (normalized === "none") return "none";
|
|
46
|
+
if (normalized === "read" || normalized === "pull" || normalized === "triage") return "read";
|
|
47
|
+
if (normalized === "write" || normalized === "push" || normalized === "maintain") return "write";
|
|
48
|
+
if (normalized === "admin") return "admin";
|
|
49
|
+
return undefined;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function resolveOrgInvitationRole(
|
|
53
|
+
value: unknown,
|
|
54
|
+
fallback: CollaborationOrgInvitationRole,
|
|
55
|
+
): { role: CollaborationOrgInvitationRole } | { error: string } {
|
|
56
|
+
if (value === undefined || value === null || value === "") return { role: fallback };
|
|
57
|
+
if (typeof value !== "string") return { error: "role must be member or owner." };
|
|
58
|
+
const normalized = value.trim().toLowerCase();
|
|
59
|
+
if (normalized === "member" || normalized === "owner") return { role: normalized };
|
|
60
|
+
return { error: `Unsupported role "${value}". Use member or owner.` };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function repoSummaryFullName(repo?: CollaborationRepoSummary): string | undefined {
|
|
64
|
+
const fullName = repo?.full_name?.trim();
|
|
65
|
+
if (fullName) return fullName;
|
|
66
|
+
const owner = repo?.owner?.login?.trim();
|
|
67
|
+
const name = repo?.name?.trim();
|
|
68
|
+
if (owner && name) return `${owner}/${name}`;
|
|
69
|
+
return name || undefined;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function filterDirectCollaborators(
|
|
73
|
+
collaborators: CollaborationCollaboratorSummary[],
|
|
74
|
+
ownerLogin: string,
|
|
75
|
+
): CollaborationCollaboratorSummary[] {
|
|
76
|
+
const owner = ownerLogin.trim().toLowerCase();
|
|
77
|
+
if (!owner) return collaborators;
|
|
78
|
+
return collaborators.filter((collaborator) => (collaborator.login?.trim().toLowerCase() || "") !== owner);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export async function listRepoAccessTeams(
|
|
82
|
+
client: RepoAccessTeamClient,
|
|
83
|
+
org: string,
|
|
84
|
+
fullName: string,
|
|
85
|
+
): Promise<{ teams: CollaborationTeamSummary[]; notes: string[] }> {
|
|
86
|
+
const notes: string[] = [];
|
|
87
|
+
const teams = await client.listOrgTeams(org);
|
|
88
|
+
const withAccess: CollaborationTeamSummary[] = [];
|
|
89
|
+
for (const team of teams) {
|
|
90
|
+
const teamSlug = team.slug?.trim() || team.name?.trim();
|
|
91
|
+
if (!teamSlug) {
|
|
92
|
+
notes.push(`Skipped a team in org "${org}" because it had no slug or name.`);
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
try {
|
|
96
|
+
const repos = await client.listTeamRepos(org, teamSlug);
|
|
97
|
+
const matchingRepo = repos.find((repo) => repoSummaryFullName(repo) === fullName);
|
|
98
|
+
if (!matchingRepo) continue;
|
|
99
|
+
withAccess.push({
|
|
100
|
+
...team,
|
|
101
|
+
...(matchingRepo.permissions ? { permissions: matchingRepo.permissions } : {}),
|
|
102
|
+
...(matchingRepo.role_name ? { role_name: matchingRepo.role_name } : {}),
|
|
103
|
+
});
|
|
104
|
+
} catch (error) {
|
|
105
|
+
notes.push(`Team repo lookup failed for ${org}/${teamSlug}: ${String(error)}`);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return { teams: withAccess, notes };
|
|
109
|
+
}
|
package/src/config.test.ts
CHANGED
package/src/config.ts
CHANGED
|
@@ -12,13 +12,14 @@ export const LABEL_CLOSED = "status:closed";
|
|
|
12
12
|
export const LABEL_MEMORY_ACTIVE = "memory-status:active";
|
|
13
13
|
export const LABEL_MEMORY_STALE = "memory-status:stale";
|
|
14
14
|
|
|
15
|
-
const MANAGED_PREFIXES = ["type:", "kind:", "session:", "date:", "topic:", "agent:"
|
|
15
|
+
const MANAGED_PREFIXES = ["type:", "kind:", "session:", "date:", "topic:", "agent:"];
|
|
16
16
|
const MANAGED_EXACT = new Set([LABEL_ACTIVE, LABEL_CLOSED, LABEL_MEMORY_ACTIVE, LABEL_MEMORY_STALE]);
|
|
17
17
|
|
|
18
18
|
export function resolvePluginConfig(api: OpenClawPluginApi): ClawMemPluginConfig {
|
|
19
19
|
const raw = (api.pluginConfig ?? {}) as Record<string, unknown>;
|
|
20
20
|
const str = (v: unknown) => typeof v === "string" && v.trim() ? v.trim() : undefined;
|
|
21
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;
|
|
22
23
|
const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v));
|
|
23
24
|
const baseUrl = (str(raw.baseUrl) ?? "https://git.clawmem.ai").replace(/\/+$/, "");
|
|
24
25
|
const rawAgents = raw.agents && typeof raw.agents === "object" && !Array.isArray(raw.agents)
|
|
@@ -45,6 +46,7 @@ export function resolvePluginConfig(api: OpenClawPluginApi): ClawMemPluginConfig
|
|
|
45
46
|
authScheme: raw.authScheme === "bearer" ? "bearer" : "token",
|
|
46
47
|
agents,
|
|
47
48
|
memoryRecallLimit: clamp(num(raw.memoryRecallLimit, 5), 1, 20),
|
|
49
|
+
memoryAutoRecallLimit: clamp(num(raw.memoryAutoRecallLimit, num(raw.memoryRecallLimit, 5)), 1, 20),
|
|
48
50
|
turnCommentDelayMs: num(raw.turnCommentDelayMs, 1000),
|
|
49
51
|
summaryWaitTimeoutMs: clamp(num(raw.summaryWaitTimeoutMs, 120000), 1000, 600000),
|
|
50
52
|
};
|
|
@@ -83,14 +85,13 @@ export function resolveLabelColor(label: string): string {
|
|
|
83
85
|
if (label.startsWith("topic:")) return "fbca04";
|
|
84
86
|
if (label.startsWith("session:")) return "bfdadc";
|
|
85
87
|
if (label.startsWith("agent:")) return "1d76db";
|
|
86
|
-
if (label.startsWith("source:")) return "0e8a16";
|
|
87
88
|
return "0e8a16";
|
|
88
89
|
}
|
|
89
90
|
|
|
90
91
|
export function labelDescription(label: string): string {
|
|
91
92
|
for (const [pfx, d] of [["type:", "Issue type"], ["kind:", "Memory kind"], ["memory-status:", "Memory lifecycle status"],
|
|
92
93
|
["status:", "Conversation lifecycle status"], ["session:", "Session association"],
|
|
93
|
-
["date:", "Date"], ["topic:", "Topic"], ["agent:", "Agent"]
|
|
94
|
+
["date:", "Date"], ["topic:", "Topic"], ["agent:", "Agent"]] as const)
|
|
94
95
|
if (label.startsWith(pfx)) return `${d} label managed by clawmem.`;
|
|
95
96
|
return "Label managed by clawmem.";
|
|
96
97
|
}
|
package/src/github-client.ts
CHANGED
|
@@ -39,6 +39,9 @@ type CollaboratorResponse = {
|
|
|
39
39
|
name?: string;
|
|
40
40
|
permissions?: PermissionMap;
|
|
41
41
|
role_name?: string;
|
|
42
|
+
organization_member?: boolean;
|
|
43
|
+
outside_collaborator?: boolean;
|
|
44
|
+
type?: string;
|
|
42
45
|
};
|
|
43
46
|
type RepositoryInvitationResponse = {
|
|
44
47
|
id?: number;
|
|
@@ -209,9 +212,6 @@ export class GitHubIssueClient {
|
|
|
209
212
|
async getRepo(owner: string, repo: string): Promise<RepoResponse> {
|
|
210
213
|
return this.req<RepoResponse>(`repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`, { method: "GET" });
|
|
211
214
|
}
|
|
212
|
-
async listRepoTeams(owner: string, repo: string): Promise<TeamResponse[]> {
|
|
213
|
-
return this.req<TeamResponse[]>(`repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/teams`, { method: "GET" });
|
|
214
|
-
}
|
|
215
215
|
async listUserRepoInvitations(): Promise<RepositoryInvitationResponse[]> {
|
|
216
216
|
return this.req<RepositoryInvitationResponse[]>("user/repository_invitations", { method: "GET" });
|
|
217
217
|
}
|
|
@@ -226,7 +226,7 @@ export class GitHubIssueClient {
|
|
|
226
226
|
}
|
|
227
227
|
async createOrgInvitation(
|
|
228
228
|
org: string,
|
|
229
|
-
params: { inviteeLogin: string; role?: "member" | "
|
|
229
|
+
params: { inviteeLogin: string; role?: "member" | "owner"; teamIds?: number[]; expiresInDays?: number },
|
|
230
230
|
): Promise<InvitationResponse> {
|
|
231
231
|
return this.req<InvitationResponse>(`orgs/${encodeURIComponent(org)}/invitations`, {
|
|
232
232
|
method: "POST",
|
package/src/memory.test.ts
CHANGED
|
@@ -41,6 +41,15 @@ function assert(condition: unknown, message: string): void {
|
|
|
41
41
|
if (!condition) throw new Error(message);
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
+
function testConfig(): never {
|
|
45
|
+
return {
|
|
46
|
+
memoryRecallLimit: 5,
|
|
47
|
+
memoryAutoRecallLimit: 5,
|
|
48
|
+
turnCommentDelayMs: 1000,
|
|
49
|
+
summaryWaitTimeoutMs: 120000,
|
|
50
|
+
} as never;
|
|
51
|
+
}
|
|
52
|
+
|
|
44
53
|
async function testSearchRanking(): Promise<void> {
|
|
45
54
|
const issues = [
|
|
46
55
|
issueFromMemory(memory({
|
|
@@ -60,7 +69,7 @@ async function testSearchRanking(): Promise<void> {
|
|
|
60
69
|
const client = {
|
|
61
70
|
listIssues: async () => issues,
|
|
62
71
|
};
|
|
63
|
-
const store = new MemoryStore(client as never, {} as never,
|
|
72
|
+
const store = new MemoryStore(client as never, {} as never, testConfig());
|
|
64
73
|
const found = await store.search("redis rate limiting", 5);
|
|
65
74
|
assert(found.length === 2, "expected both memories to match");
|
|
66
75
|
assert(found[0]?.issueNumber === 1, "expected the more specific Redis rate limiting memory to rank first");
|
|
@@ -93,7 +102,7 @@ async function testBackendSearchPreferredForRecall(): Promise<void> {
|
|
|
93
102
|
return searched;
|
|
94
103
|
},
|
|
95
104
|
};
|
|
96
|
-
const store = new MemoryStore(client as never, {} as never,
|
|
105
|
+
const store = new MemoryStore(client as never, {} as never, testConfig());
|
|
97
106
|
const found = await store.search("redis rate limiting", 5);
|
|
98
107
|
|
|
99
108
|
assert(queries.length === 1, "expected backend search to be called once");
|
|
@@ -117,7 +126,7 @@ async function testBackendSearchFallsBackToLocalLexical(): Promise<void> {
|
|
|
117
126
|
listIssues: async () => issues,
|
|
118
127
|
searchIssues: async () => { throw new Error("search unavailable"); },
|
|
119
128
|
};
|
|
120
|
-
const store = new MemoryStore(client as never, { logger: { warn: () => {} } } as never,
|
|
129
|
+
const store = new MemoryStore(client as never, { logger: { warn: () => {} } } as never, testConfig());
|
|
121
130
|
const found = await store.search("redis rate limiting", 5);
|
|
122
131
|
|
|
123
132
|
assert(found.length === 1 && found[0]?.issueNumber === 3, "expected lexical fallback when backend search fails");
|
|
@@ -155,7 +164,7 @@ async function testStructuredStoreAndSchema(): Promise<void> {
|
|
|
155
164
|
return { number: 99, title: payload.title };
|
|
156
165
|
},
|
|
157
166
|
};
|
|
158
|
-
const store = new MemoryStore(client as never, {} as never,
|
|
167
|
+
const store = new MemoryStore(client as never, {} as never, testConfig());
|
|
159
168
|
const result = await store.store({ detail: "Redis Lua scripts are required for atomic rate limiting.", kind: "Lesson", topics: ["Redis Ops", "rate_limit"] });
|
|
160
169
|
const schema = await store.listSchema();
|
|
161
170
|
|
|
@@ -168,12 +177,36 @@ async function testStructuredStoreAndSchema(): Promise<void> {
|
|
|
168
177
|
assert(created[0]?.labels.includes("topic:rate-limit"), "expected created labels to include normalized topic");
|
|
169
178
|
assert(!created[0]?.labels.some((label) => label.startsWith("session:")), "expected manual memory_store writes to omit synthetic session labels");
|
|
170
179
|
assert(!created[0]?.labels.some((label) => label.startsWith("date:")), "expected new memory labels to omit date labels");
|
|
180
|
+
assert(created[0]?.body.includes("memory_hash:"), "expected new memory body to retain metadata fields");
|
|
181
|
+
assert(created[0]?.body.includes("detail: Redis Lua scripts are required for atomic rate limiting."), "expected new memory body to store detail in YAML");
|
|
171
182
|
assert(created[0]?.body.includes(`date: ${result.memory.date}`), "expected new memory body to retain logical date metadata");
|
|
172
183
|
assert(ensured[0]?.includes("kind:lesson"), "expected ensureLabels to include kind label");
|
|
173
184
|
assert(schema.kinds.includes("lesson"), "expected schema to expose existing kind labels");
|
|
174
185
|
assert(schema.topics.includes("redis"), "expected schema to expose existing topic labels");
|
|
175
186
|
}
|
|
176
187
|
|
|
188
|
+
async function testStoreKeepsFullAutoTitleAndSupportsExplicitTitle(): Promise<void> {
|
|
189
|
+
const created: Array<{ title: string; body: string; labels: string[] }> = [];
|
|
190
|
+
const client = {
|
|
191
|
+
listIssues: async () => [] as IssueRecord[],
|
|
192
|
+
listLabels: async () => [] as LabelRecord[],
|
|
193
|
+
ensureLabels: async () => {},
|
|
194
|
+
createIssue: async (payload: { title: string; body: string; labels: string[] }) => {
|
|
195
|
+
created.push(payload);
|
|
196
|
+
return { number: created.length + 100, title: payload.title };
|
|
197
|
+
},
|
|
198
|
+
};
|
|
199
|
+
const store = new MemoryStore(client as never, {} as never, testConfig());
|
|
200
|
+
const longDetail = "Tech Decision #001: Frontend = React Native, Backend = FastAPI, Database = PostgreSQL, and analytics events must stay append-only for auditability.";
|
|
201
|
+
const auto = await store.store({ detail: longDetail });
|
|
202
|
+
const explicit = await store.store({ title: "Architecture Decision #001", detail: "Use React Native + FastAPI for the first mobile stack." });
|
|
203
|
+
|
|
204
|
+
assert(auto.memory.title === `Memory: ${longDetail}`, "expected auto-generated memory title to keep the full detail without truncation");
|
|
205
|
+
assert(explicit.memory.title === "Memory: Architecture Decision #001", "expected explicit memory title to be preserved");
|
|
206
|
+
assert(created[0]?.title === `Memory: ${longDetail}`, "expected created issue title to keep the full auto title");
|
|
207
|
+
assert(created[1]?.title === "Memory: Architecture Decision #001", "expected created issue title to use the explicit title");
|
|
208
|
+
}
|
|
209
|
+
|
|
177
210
|
async function testGetAndListMemories(): Promise<void> {
|
|
178
211
|
const issues = [
|
|
179
212
|
issueFromMemory(memory({
|
|
@@ -211,7 +244,7 @@ async function testGetAndListMemories(): Promise<void> {
|
|
|
211
244
|
});
|
|
212
245
|
},
|
|
213
246
|
};
|
|
214
|
-
const store = new MemoryStore(client as never, {} as never,
|
|
247
|
+
const store = new MemoryStore(client as never, {} as never, testConfig());
|
|
215
248
|
const exact = await store.get("4");
|
|
216
249
|
const activeFacts = await store.listMemories({ status: "active", kind: "core-fact", limit: 10 });
|
|
217
250
|
const sports = await store.listMemories({ status: "all", topic: "sports", limit: 10 });
|
|
@@ -243,7 +276,7 @@ async function testLegacyMemoriesWithoutSessionOrDate(): Promise<void> {
|
|
|
243
276
|
});
|
|
244
277
|
},
|
|
245
278
|
};
|
|
246
|
-
const store = new MemoryStore(client as never, {} as never,
|
|
279
|
+
const store = new MemoryStore(client as never, {} as never, testConfig());
|
|
247
280
|
const exact = await store.get("4");
|
|
248
281
|
const recalled = await store.search("F1 Dota 2", 5);
|
|
249
282
|
|
|
@@ -292,7 +325,7 @@ async function testUpdateMemoryInPlace(): Promise<void> {
|
|
|
292
325
|
issue.labels = labels;
|
|
293
326
|
},
|
|
294
327
|
};
|
|
295
|
-
const store = new MemoryStore(client as never, {} as never,
|
|
328
|
+
const store = new MemoryStore(client as never, {} as never, testConfig());
|
|
296
329
|
const updated = await store.update("4", {
|
|
297
330
|
detail: "xiangz likes F1, watches Dota 2 as a viewer, and recently follows tennis.",
|
|
298
331
|
topics: ["preferences", "sports"],
|
|
@@ -303,10 +336,52 @@ async function testUpdateMemoryInPlace(): Promise<void> {
|
|
|
303
336
|
assert(JSON.stringify(updated?.topics) === JSON.stringify(["preferences", "sports"]), "expected topics to be replaced");
|
|
304
337
|
assert(updatedIssues.length === 1, "expected a single issue update");
|
|
305
338
|
assert(updatedIssues[0]?.title !== "Memory: xiangz preferences", "expected title to refresh from updated detail");
|
|
339
|
+
assert(updatedIssues[0]?.body?.includes("memory_hash:"), "expected updated body to retain metadata");
|
|
340
|
+
assert(updatedIssues[0]?.body?.includes("detail:"), "expected updated body to store a detail field in YAML");
|
|
341
|
+
assert(updatedIssues[0]?.body?.includes("recently follows tennis"), "expected updated body to contain the updated detail text");
|
|
306
342
|
assert(ensured[0]?.includes("topic:sports"), "expected new topic label to be ensured");
|
|
307
343
|
assert(syncedLabels[0]?.labels.includes("kind:core-fact"), "expected existing kind label to be preserved");
|
|
308
344
|
}
|
|
309
345
|
|
|
346
|
+
async function testUpdateSupportsExplicitRetitle(): Promise<void> {
|
|
347
|
+
const issues: IssueRecord[] = [
|
|
348
|
+
issueFromMemory(memory({
|
|
349
|
+
issueNumber: 20,
|
|
350
|
+
title: "Memory: old short title",
|
|
351
|
+
detail: "We use append-only audit events for billing changes.",
|
|
352
|
+
kind: "convention",
|
|
353
|
+
})),
|
|
354
|
+
];
|
|
355
|
+
const updatedIssues: Array<{ number: number; title?: string; body?: string }> = [];
|
|
356
|
+
const client = {
|
|
357
|
+
listIssues: async (params?: { labels?: string[]; state?: "open" | "closed" | "all" }) => {
|
|
358
|
+
const labels = params?.labels ?? [];
|
|
359
|
+
const state = params?.state ?? "open";
|
|
360
|
+
return issues.filter((issue) => {
|
|
361
|
+
const issueLabels = issue.labels ?? [];
|
|
362
|
+
if (!labels.every((label) => issueLabels.includes(label))) return false;
|
|
363
|
+
if (state === "all") return true;
|
|
364
|
+
return (issue.state ?? "open") === state;
|
|
365
|
+
});
|
|
366
|
+
},
|
|
367
|
+
ensureLabels: async () => {},
|
|
368
|
+
updateIssue: async (number: number, patch: { title?: string; body?: string }) => {
|
|
369
|
+
updatedIssues.push({ number, ...patch });
|
|
370
|
+
const issue = issues.find((entry) => entry.number === number);
|
|
371
|
+
if (!issue) throw new Error("issue missing");
|
|
372
|
+
if (patch.title) issue.title = patch.title;
|
|
373
|
+
if (patch.body) issue.body = patch.body;
|
|
374
|
+
return issue;
|
|
375
|
+
},
|
|
376
|
+
syncManagedLabels: async () => {},
|
|
377
|
+
};
|
|
378
|
+
const store = new MemoryStore(client as never, {} as never, testConfig());
|
|
379
|
+
const updated = await store.update("20", { title: "Billing Audit Convention" });
|
|
380
|
+
|
|
381
|
+
assert(updated?.title === "Memory: Billing Audit Convention", "expected memory_update to support explicit retitle");
|
|
382
|
+
assert(updatedIssues[0]?.title === "Memory: Billing Audit Convention", "expected issue title patch to use the explicit retitle");
|
|
383
|
+
}
|
|
384
|
+
|
|
310
385
|
async function testForgetClosesMemoryIssue(): Promise<void> {
|
|
311
386
|
const issues: IssueRecord[] = [
|
|
312
387
|
issueFromMemory(memory({
|
|
@@ -344,7 +419,7 @@ async function testForgetClosesMemoryIssue(): Promise<void> {
|
|
|
344
419
|
return issue;
|
|
345
420
|
},
|
|
346
421
|
};
|
|
347
|
-
const store = new MemoryStore(client as never, {} as never,
|
|
422
|
+
const store = new MemoryStore(client as never, {} as never, testConfig());
|
|
348
423
|
const forgotten = await store.forget("12");
|
|
349
424
|
|
|
350
425
|
assert(forgotten?.status === "stale", "expected forgotten memory to be returned as stale");
|
|
@@ -358,9 +433,11 @@ async function main(): Promise<void> {
|
|
|
358
433
|
await testBackendSearchFallsBackToLocalLexical();
|
|
359
434
|
testCjkScoring();
|
|
360
435
|
await testStructuredStoreAndSchema();
|
|
436
|
+
await testStoreKeepsFullAutoTitleAndSupportsExplicitTitle();
|
|
361
437
|
await testGetAndListMemories();
|
|
362
438
|
await testLegacyMemoriesWithoutSessionOrDate();
|
|
363
439
|
await testUpdateMemoryInPlace();
|
|
440
|
+
await testUpdateSupportsExplicitRetitle();
|
|
364
441
|
await testForgetClosesMemoryIssue();
|
|
365
442
|
console.log("memory tests passed");
|
|
366
443
|
}
|