@clawmem-ai/clawmem 0.1.11 → 0.1.13

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 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"`; if backend search fails, clawmem falls back to local lexical ranking.
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).
164
+ - Memory extraction now prefers one atomic fact per memory item instead of bundling whole sessions into a single node.
163
165
  - Memory issue bodies store the durable detail plus flat metadata such as `memory_hash` and logical `date`; labels are reserved for schema and routing.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "id": "clawmem",
3
3
  "name": "ClawMem",
4
- "version": "0.1.11",
4
+ "version": "0.1.13",
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clawmem-ai/clawmem",
3
- "version": "0.1.11",
3
+ "version": "0.1.13",
4
4
  "description": "Mirror OpenClaw sessions into GitHub-compatible issues and comments.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -46,12 +46,16 @@ 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
+ - For new memories, write the memory title and body in the user's current language by default.
51
54
  - Use `memory_forget` when a memory is stale, superseded, or harmful if reused.
52
55
  3. Keep the user posted.
53
- - If a retrieved memory materially shaped the answer, briefly surface the hit with the memory id and title when it helps the user follow the reasoning.
54
- - After creating or updating a memory, announce `Locked memory #<id>: <title>` when the tool response returns an id and title.
56
+ - If a retrieved memory materially shaped the answer, including automatic session-start recall, briefly surface that fact in the user's current language.
57
+ - Include the memory id and title only when they help with debugging, traceability, or an explicit user request.
58
+ - After creating or updating a memory, give a short confirmation in the user's current language instead of forcing fixed English phrasing.
55
59
 
56
60
  Bias toward retrieving and saving. A missed search or missed memory is worse than an extra search.
57
61
 
@@ -63,11 +67,14 @@ Bias toward retrieving and saving. A missed search or missed memory is worse tha
63
67
  - Private personal memory usually belongs in the agent's `defaultRepo`.
64
68
  - Project memory belongs in the relevant project repo.
65
69
  - Shared or team knowledge belongs in the shared repo for that group.
70
+ - Memory titles and bodies default to the user's current language for new memories.
71
+ - When updating an existing memory, keep that node in its current language unless the user explicitly asks to rewrite it.
72
+ - Keep schema labels and machine-oriented fields stable. Do not translate `type:*`, `kind:*`, `topic:*`, or other structural identifiers.
66
73
  - 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`.
67
74
 
68
75
  ## Read the right reference
69
76
 
70
- - For user-facing messaging, first-run notes, memory console links, and post-save confirmations, read [references/communication.md](references/communication.md).
77
+ - For user-facing runtime messaging, memory console links, and post-save confirmations, read [references/communication.md](references/communication.md).
71
78
  - For activation repair, route verification, tool-path verification, and compatibility-file reminders after install, read [references/repair.md](references/repair.md).
72
79
  - For shared repos, team memory, organizations, teams, invitations, collaborators, and collaboration routing, read [references/collaboration.md](references/collaboration.md).
73
80
  - For memory kinds, labels, curated versus plugin-managed nodes, and when to use each shape, read [references/schema.md](references/schema.md).
@@ -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.
@@ -1,60 +1,44 @@
1
- # ClawMem Communication And First-Run Messaging
1
+ # ClawMem Runtime Communication
2
2
 
3
- Use this reference when you need user-facing copy after setup succeeds, need a short first-run message, want to generate a memory console link, or want stronger post-save communication after memory operations.
3
+ Use this reference when memory shaped the answer, a memory was saved or updated, or the user needs a console link to inspect the memory graph.
4
4
 
5
5
  ## Contents
6
6
 
7
7
  - Keep the user posted
8
- - Restart notice protocol
9
- - First-run message
10
8
  - Memory visualization console
11
9
 
12
10
  ## Keep the user posted
13
11
 
14
12
  Nothing interesting should happen silently. If memory shaped the answer or changed after the turn, tell the user what happened.
15
13
 
14
+ When a recalled or auto-injected memory materially shaped the answer, add a brief user-visible note in the user's current language. Keep it short, natural, and easy to skip.
15
+
16
16
  Preferred retrieval transparency:
17
- - `Memory hit #<id>: <title>`
17
+ - say that you recalled or confirmed something from prior memory
18
+ - mention the remembered fact itself
19
+ - include the memory id and title only when they genuinely help the user follow along or when the user is debugging memory behavior
18
20
 
19
21
  Use a miss note only when the user would reasonably expect that you checked:
20
- - `Memory miss: no prior decision found on staging cutover`
21
-
22
- Preferred confirmation:
23
- - `Locked memory #<id>: <title>`
24
-
25
- Friendlier variants are fine as long as they stay truthful and short.
26
-
27
- Examples:
28
- - `Memory hit #14: Team demo moved to Wednesday`
29
- - `Locked memory #10: API rate limiting uses a sliding window policy`
30
- - `Locked memory #27: Client meeting is Thursday at 2pm`
22
+ - explain in the user's current language that no relevant prior memory was found
31
23
 
32
- ## Restart notice protocol
24
+ When a memory is created or updated successfully, add a brief confirmation in the user's current language.
33
25
 
34
- If setup requires an OpenClaw gateway restart and that restart may interrupt outgoing replies, send a short notice before restarting.
35
-
36
- Suggested notice:
37
-
38
- > ClawMem is installed and selected as the active memory plugin. Restarting the OpenClaw gateway to load it now.
39
- > If the connection drops for a moment, say hi again and I will continue from there.
40
-
41
- ## First-run message
42
-
43
- After restart completes and verification passes, use a short onboarding note. Keep it high-signal and avoid dumping the full skill into chat.
26
+ Preferred confirmation:
27
+ - say that you remembered, saved, or updated it
28
+ - include the memory id and title only when they help with debugging, traceability, or explicit user requests
44
29
 
45
- Suggested structure:
46
- - ClawMem is active and now persists durable memories across sessions.
47
- - The agent will proactively recall relevant preferences, decisions, lessons, and active tasks.
48
- - The agent will tell the user when it locks a memory.
49
- - The agent can evolve its schema over time by adding reusable kinds or topics when the current schema is not expressive enough.
50
- - The user can inspect the memory graph in the console link below.
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.
51
34
 
52
- Optional fuller note:
35
+ Do not force English markers like `Memory hit` or `Locked memory` in non-English conversations. Those are examples, not required phrasing.
53
36
 
54
- > ClawMem is active. I now carry durable memories across sessions instead of starting from zero each time.
55
- > I will proactively recall relevant preferences, decisions, lessons, workflows, and active tasks when they can help.
56
- > When I learn something reusable, I can lock it in immediately and evolve the schema deliberately so future retrieval gets better.
57
- > When I save something important, I will tell you. You can inspect the memory graph in the console link below.
37
+ Examples:
38
+ - `我从之前的记忆里确认到:你最近在看《Legal High》。`
39
+ - `这条我记住了,之后我会按这个偏好来推荐。`
40
+ - `I found a relevant prior memory: the team demo moved to Wednesday.`
41
+ - `Saved that preference. I’ll use it in later recommendations.`
58
42
 
59
43
  ## Memory visualization console
60
44
 
@@ -72,7 +56,6 @@ Read `CLAWMEM_TOKEN` from the current route, substitute it into the URL, and sho
72
56
 
73
57
  ### When to show the console link
74
58
 
75
- - During onboarding after a successful install
76
59
  - When the user asks to view memories, the graph, or a dashboard
77
60
  - After significant memory operations, such as bulk saves
78
61
  - When a visual overview would clearly help the user
@@ -75,6 +75,10 @@ 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
+ - When manually updating an existing memory, preserve that memory's current language unless the user explicitly asks for a rewrite.
80
+ - Keep labels and schema markers such as `type:*`, `kind:*`, and `topic:*` in their fixed machine-readable form.
81
+
78
82
  ### With `gh`
79
83
 
80
84
  ```sh
@@ -85,8 +89,8 @@ done
85
89
 
86
90
  GH_HOST="$CLAWMEM_HOST" GH_ENTERPRISE_TOKEN="$CLAWMEM_TOKEN" \
87
91
  gh issue create --repo "$CLAWMEM_REPO" \
88
- --title "Memory: <concise title>" \
89
- --body "<durable detail in plain language>" \
92
+ --title "<concise title in the user's current language>" \
93
+ --body "<durable detail in the user's current language>" \
90
94
  --label "type:memory" \
91
95
  --label "kind:lesson"
92
96
  ```
@@ -105,8 +109,8 @@ curl -sf -X POST -H "Authorization: token $CLAWMEM_TOKEN" \
105
109
  -H "Content-Type: application/json" \
106
110
  "$CLAWMEM_BASE_URL/repos/$CLAWMEM_REPO/issues" \
107
111
  -d "{
108
- \"title\": \"Memory: <concise title>\",
109
- \"body\": \"<durable detail in plain language>\",
112
+ \"title\": \"<concise title in the user's current language>\",
113
+ \"body\": \"<durable detail in the user's current language>\",
110
114
  \"labels\": [\"type:memory\", \"kind:lesson\"]
111
115
  }" | jq '{number, title, url: .html_url}'
112
116
  ```
@@ -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 it to ClawMem now.
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,12 @@ 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 updating an existing memory node, preserve that node's current language unless the user explicitly asks for a rewrite.
65
+ - Do not translate schema or routing markers such as `type:*`, `kind:*`, `topic:*`, or other machine-oriented field names.
66
+
61
67
  ## Storage rule
62
68
 
63
69
  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
+ }
@@ -27,6 +27,7 @@ function baseConfig(): ClawMemPluginConfig {
27
27
  },
28
28
  },
29
29
  memoryRecallLimit: 5,
30
+ memoryAutoRecallLimit: 5,
30
31
  turnCommentDelayMs: 1000,
31
32
  summaryWaitTimeoutMs: 120000,
32
33
  };
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:", "source:"];
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"], ["source:", "Source"]] as const)
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
  }
@@ -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" | "admin"; teamIds?: number[]; expiresInDays?: number },
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",
@@ -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, { memoryRecallLimit: 5, turnCommentDelayMs: 1000, summaryWaitTimeoutMs: 120000 } 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, { memoryRecallLimit: 5, turnCommentDelayMs: 1000, summaryWaitTimeoutMs: 120000 } 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, { memoryRecallLimit: 5, turnCommentDelayMs: 1000, summaryWaitTimeoutMs: 120000 } 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, { memoryRecallLimit: 5, turnCommentDelayMs: 1000, summaryWaitTimeoutMs: 120000 } 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
 
@@ -211,7 +220,7 @@ async function testGetAndListMemories(): Promise<void> {
211
220
  });
212
221
  },
213
222
  };
214
- const store = new MemoryStore(client as never, {} as never, { memoryRecallLimit: 5, turnCommentDelayMs: 1000, summaryWaitTimeoutMs: 120000 } as never);
223
+ const store = new MemoryStore(client as never, {} as never, testConfig());
215
224
  const exact = await store.get("4");
216
225
  const activeFacts = await store.listMemories({ status: "active", kind: "core-fact", limit: 10 });
217
226
  const sports = await store.listMemories({ status: "all", topic: "sports", limit: 10 });
@@ -243,7 +252,7 @@ async function testLegacyMemoriesWithoutSessionOrDate(): Promise<void> {
243
252
  });
244
253
  },
245
254
  };
246
- const store = new MemoryStore(client as never, {} as never, { memoryRecallLimit: 5, turnCommentDelayMs: 1000, summaryWaitTimeoutMs: 120000 } as never);
255
+ const store = new MemoryStore(client as never, {} as never, testConfig());
247
256
  const exact = await store.get("4");
248
257
  const recalled = await store.search("F1 Dota 2", 5);
249
258
 
@@ -292,7 +301,7 @@ async function testUpdateMemoryInPlace(): Promise<void> {
292
301
  issue.labels = labels;
293
302
  },
294
303
  };
295
- const store = new MemoryStore(client as never, {} as never, { memoryRecallLimit: 5, turnCommentDelayMs: 1000, summaryWaitTimeoutMs: 120000 } as never);
304
+ const store = new MemoryStore(client as never, {} as never, testConfig());
296
305
  const updated = await store.update("4", {
297
306
  detail: "xiangz likes F1, watches Dota 2 as a viewer, and recently follows tennis.",
298
307
  topics: ["preferences", "sports"],
@@ -344,7 +353,7 @@ async function testForgetClosesMemoryIssue(): Promise<void> {
344
353
  return issue;
345
354
  },
346
355
  };
347
- const store = new MemoryStore(client as never, {} as never, { memoryRecallLimit: 5, turnCommentDelayMs: 1000, summaryWaitTimeoutMs: 120000 } as never);
356
+ const store = new MemoryStore(client as never, {} as never, testConfig());
348
357
  const forgotten = await store.forget("12");
349
358
 
350
359
  assert(forgotten?.status === "stale", "expected forgotten memory to be returned as stale");
package/src/memory.ts CHANGED
@@ -3,7 +3,7 @@ import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core";
3
3
  import { LABEL_MEMORY_STALE, MEMORY_TITLE_PREFIX, extractLabelNames, labelVal } from "./config.js";
4
4
  import type { GitHubIssueClient } from "./github-client.js";
5
5
  import { normalizeMessages } from "./transcript.js";
6
- import type { ClawMemPluginConfig, MemoryDraft, MemoryListOptions, MemorySchema, NormalizedMessage, ParsedMemoryIssue, SessionMirrorState, TranscriptSnapshot } from "./types.js";
6
+ import type { ClawMemPluginConfig, MemoryDraft, MemoryListOptions, MemorySchema, ParsedMemoryIssue, SessionMirrorState, TranscriptSnapshot } from "./types.js";
7
7
  import { fmtTranscript, localDate, sha256, subKey } from "./utils.js";
8
8
  import { parseFlatYaml, stringifyFlatYaml } from "./yaml.js";
9
9
 
@@ -206,7 +206,8 @@ export class MemoryStore {
206
206
  const status = issue.state === "closed" || labels.includes(LABEL_MEMORY_STALE) ? "stale" : "active";
207
207
  if (!detail) return null;
208
208
  return {
209
- issueNumber: issue.number, title: issue.title?.trim() || "",
209
+ issueNumber: issue.number,
210
+ title: issue.title?.trim() || "",
210
211
  memoryId: body.memory_id?.trim() || String(issue.number),
211
212
  memoryHash: body.memory_hash?.trim() || undefined,
212
213
  date: body.date?.trim() || "1970-01-01",
@@ -280,6 +281,7 @@ export class MemoryStore {
280
281
  const message = [
281
282
  "Extract durable memories from the conversation below.",
282
283
  'Return JSON only in the form {"save":[{"detail":"...","kind":"...","topics":["..."]}],"stale":["memory-id"]}.',
284
+ "Each save item must contain one durable fact. If a turn contains several independent facts, save them separately instead of bundling them into one summary memory.",
283
285
  "Use save for stable, reusable facts, preferences, decisions, constraints, workflows, and ongoing context worth remembering later.",
284
286
  "Use stale for existing memory IDs only when the conversation clearly supersedes or invalidates them.",
285
287
  "Infer kind and topics when they would help future retrieval. Reuse existing kinds and topics when possible.",
@@ -293,9 +295,12 @@ export class MemoryStore {
293
295
  ].join("\n");
294
296
  try {
295
297
  const run = await subagent.run({
296
- sessionKey, message, deliver: false, lane: "clawmem-memory",
298
+ sessionKey,
299
+ message,
300
+ deliver: false,
301
+ lane: "clawmem-memory",
297
302
  idempotencyKey: sha256(`${session.sessionId}:${snapshot.messages.length}:memory-decision`),
298
- extraSystemPrompt: "You extract durable memory updates from OpenClaw conversations. Output JSON only with save objects containing detail, optional kind, and optional topics, plus stale string ids.",
303
+ extraSystemPrompt: "You extract durable memory updates from OpenClaw conversations. Output JSON only with save objects containing detail, optional kind, and optional topics, plus stale string ids. Keep each save item to one durable fact.",
299
304
  });
300
305
  const wait = await subagent.waitForRun({ runId: run.runId, timeoutMs: this.config.summaryWaitTimeoutMs });
301
306
  if (wait.status === "timeout") throw new Error("memory decision subagent timed out");
@@ -304,7 +309,9 @@ export class MemoryStore {
304
309
  const text = [...msgs].reverse().find((e) => e.role === "assistant" && e.text.trim())?.text;
305
310
  if (!text) throw new Error("memory decision subagent returned no assistant text");
306
311
  return parseDecision(text);
307
- } finally { subagent.deleteSession({ sessionKey, deleteTranscript: true }).catch(() => {}); }
312
+ } finally {
313
+ subagent.deleteSession({ sessionKey, deleteTranscript: true }).catch(() => {});
314
+ }
308
315
  }
309
316
 
310
317
  private async mergeSchema(memory: ParsedMemoryIssue, draft: MemoryDraft): Promise<ParsedMemoryIssue> {
@@ -333,11 +340,13 @@ function memLabels(kind?: string, topics?: string[]): string[] {
333
340
  ...((topics ?? []).map((topic) => `topic:${topic}`)),
334
341
  ];
335
342
  }
343
+
336
344
  function norm(v: string): string { return v.replace(/\s+/g, " ").trim(); }
337
345
  function trunc(v: string, max: number): string { const s = norm(v); return s.length <= max ? s : `${s.slice(0, max - 1).trimEnd()}…`; }
338
346
  function normalizeSearch(v: string): string {
339
347
  return v.normalize("NFKC").toLowerCase().replace(/\s+/g, " ").trim();
340
348
  }
349
+
341
350
  function buildSearchIndex(memory: ParsedMemoryIssue): SearchIndex {
342
351
  return {
343
352
  title: normalizeSearch(memory.title),
@@ -346,6 +355,7 @@ function buildSearchIndex(memory: ParsedMemoryIssue): SearchIndex {
346
355
  topics: (memory.topics ?? []).map(normalizeSearch).filter(Boolean),
347
356
  };
348
357
  }
358
+
349
359
  function searchTokens(v: string): string[] {
350
360
  const seen = new Set<string>();
351
361
  for (const token of v.split(/[^0-9\p{L}]+/u)) {
@@ -359,6 +369,7 @@ function searchTokens(v: string): string[] {
359
369
  }
360
370
  return [...seen];
361
371
  }
372
+
362
373
  function charBigrams(v: string): Set<string> {
363
374
  const compact = v.replace(/\s+/g, "");
364
375
  if (compact.length < 2) return new Set(compact ? [compact] : []);
@@ -366,16 +377,19 @@ function charBigrams(v: string): Set<string> {
366
377
  for (let i = 0; i < compact.length - 1; i++) out.add(compact.slice(i, i + 2));
367
378
  return out;
368
379
  }
380
+
369
381
  function overlapRatio(left: Set<string>, right: Set<string>): number {
370
382
  if (left.size === 0 || right.size === 0) return 0;
371
383
  let hits = 0;
372
384
  for (const token of left) if (right.has(token)) hits++;
373
385
  return hits / Math.max(left.size, right.size);
374
386
  }
387
+
375
388
  function buildMemorySearchQuery(query: string, repo: string): string {
376
389
  const parts = [query.trim(), `repo:${repo}`, "is:issue", "state:open", 'label:"type:memory"'].filter(Boolean);
377
390
  return parts.join(" ");
378
391
  }
392
+
379
393
  export function scoreMemoryMatch(memory: ParsedMemoryIssue, rawQuery: string): number {
380
394
  const query = normalizeSearch(rawQuery);
381
395
  if (!query) return 0;
@@ -411,6 +425,7 @@ export function scoreMemoryMatch(memory: ParsedMemoryIssue, rawQuery: string): n
411
425
 
412
426
  return score;
413
427
  }
428
+
414
429
  function normalizeDraft(input: MemoryDraft): MemoryDraft {
415
430
  const detail = norm(input.detail);
416
431
  if (!detail) throw new Error("memory detail is empty");
@@ -422,6 +437,7 @@ function normalizeDraft(input: MemoryDraft): MemoryDraft {
422
437
  ...(topics.length > 0 ? { topics } : {}),
423
438
  };
424
439
  }
440
+
425
441
  function normalizeLabelValue(value: string | undefined, prefix: string): string | undefined {
426
442
  if (!value) return undefined;
427
443
  const raw = value.trim().replace(new RegExp(`^${prefix}`, "i"), "");
@@ -433,6 +449,7 @@ function normalizeLabelValue(value: string | undefined, prefix: string): string
433
449
  .replace(/^-+|-+$/g, "");
434
450
  return normalized || undefined;
435
451
  }
452
+
436
453
  function normalizeOptionalLabelValue(value: string | undefined, prefix: string): string | undefined {
437
454
  try {
438
455
  return normalizeLabelValue(value, prefix);
@@ -440,16 +457,22 @@ function normalizeOptionalLabelValue(value: string | undefined, prefix: string):
440
457
  return undefined;
441
458
  }
442
459
  }
460
+
443
461
  function uniqueNormalized(values: string[]): string[] {
444
462
  return [...new Set(values.map((value) => value.trim()).filter(Boolean))].sort();
445
463
  }
464
+
446
465
  function parseDecision(raw: string): MemoryDecision {
447
466
  const tryParse = (s: string): MemoryDecision | null => {
448
467
  try {
449
468
  const p = JSON.parse(s) as Record<string, unknown>;
450
- return { save: Array.isArray(p.save) ? p.save.map(parseSaveItem).filter((v): v is MemoryDraft => Boolean(v)) : [],
451
- stale: Array.isArray(p.stale) ? p.stale.filter((v): v is string => typeof v === "string") : [] };
452
- } catch { return null; }
469
+ return {
470
+ save: Array.isArray(p.save) ? p.save.map(parseSaveItem).filter((v): v is MemoryDraft => Boolean(v)) : [],
471
+ stale: Array.isArray(p.stale) ? p.stale.filter((v): v is string => typeof v === "string") : [],
472
+ };
473
+ } catch {
474
+ return null;
475
+ }
453
476
  };
454
477
  const t = raw.trim();
455
478
  return tryParse(t) ?? (() => {
@@ -459,6 +482,7 @@ function parseDecision(raw: string): MemoryDecision {
459
482
  throw new Error("memory decision subagent returned invalid JSON");
460
483
  })();
461
484
  }
485
+
462
486
  function parseSaveItem(value: unknown): MemoryDraft | null {
463
487
  if (typeof value === "string") {
464
488
  const detail = norm(value);
package/src/service.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  // Thin orchestrator: wires conversation mirroring, memory store, and plugin lifecycle.
2
2
  import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core";
3
3
  import { hasDefaultRepo, isAgentConfigured, resolveAgentRoute, resolvePluginConfig } from "./config.js";
4
+ import { filterDirectCollaborators, listRepoAccessTeams, resolveOrgInvitationRole } from "./collaboration.js";
4
5
  import { ConversationMirror } from "./conversation.js";
5
6
  import { GitHubIssueClient } from "./github-client.js";
6
7
  import { KeyedAsyncQueue } from "./keyed-async-queue.js";
@@ -13,15 +14,17 @@ import { buildAgentBootstrapRegistration, inferAgentIdFromTranscriptPath, normal
13
14
  type TurnPayload = { sessionId?: string; sessionKey?: string; agentId?: string; messages: unknown[] };
14
15
  type FinalizePayload = { sessionId?: string; sessionKey?: string; sessionFile?: string; agentId?: string; reason?: string; messages?: unknown[] };
15
16
  type CollaborationPermission = "read" | "write" | "admin";
16
- type CollaborationOrgRole = "member" | "admin";
17
17
  type CollaborationTeamRole = "member" | "maintainer";
18
18
 
19
+ const SESSION_MAINTENANCE_RETRY_DELAYS_MS = [5000, 30000, 120000] as const;
20
+
19
21
  class ClawMemService {
20
22
  private readonly config: ClawMemPluginConfig;
21
23
  private readonly queue = new KeyedAsyncQueue();
22
24
  private readonly stateQueue = new KeyedAsyncQueue();
23
25
  private readonly pending = new Set<Promise<unknown>>();
24
26
  private readonly syncTimers = new Map<string, ReturnType<typeof setTimeout>>();
27
+ private readonly maintenanceTimers = new Map<string, ReturnType<typeof setTimeout>>();
25
28
  private statePath = "";
26
29
  private state: PluginState = { version: 2, sessions: {} };
27
30
  private unsubTranscript?: () => void;
@@ -62,6 +65,8 @@ class ClawMemService {
62
65
  this.unsubTranscript?.();
63
66
  for (const t of this.syncTimers.values()) clearTimeout(t);
64
67
  this.syncTimers.clear();
68
+ for (const t of this.maintenanceTimers.values()) clearTimeout(t);
69
+ this.maintenanceTimers.clear();
65
70
  await Promise.allSettled([...this.pending]);
66
71
  },
67
72
  });
@@ -224,7 +229,7 @@ class ClawMemService {
224
229
 
225
230
  this.api.registerTool({
226
231
  name: "memory_recall",
227
- description: "Search ClawMem active memories for relevant prior facts, decisions, conventions, and lessons.",
232
+ description: "Search ClawMem active memories for relevant prior facts, decisions, conventions, and lessons. Use this before answering questions about prior conversations, earlier assistant responses, user preferences, or historical project context.",
228
233
  required: true,
229
234
  parameters: {
230
235
  type: "object",
@@ -287,7 +292,7 @@ class ClawMemService {
287
292
 
288
293
  this.api.registerTool({
289
294
  name: "memory_store",
290
- description: "Store a durable ClawMem memory immediately instead of waiting for session finalization.",
295
+ description: "Store one atomic durable ClawMem memory immediately instead of waiting for session finalization. Keep each write to a single fact, preference, decision, or timeline update.",
291
296
  required: true,
292
297
  parameters: {
293
298
  type: "object",
@@ -316,7 +321,11 @@ class ClawMemService {
316
321
  if ("error" in resolved) return toolText(resolved.error);
317
322
  const kind = typeof p.kind === "string" && p.kind.trim() ? p.kind.trim() : undefined;
318
323
  const topics = Array.isArray(p.topics) ? p.topics.filter((topic): topic is string => typeof topic === "string" && topic.trim().length > 0) : undefined;
319
- const result = await resolved.mem.store({ detail, ...(kind ? { kind } : {}), ...(topics && topics.length > 0 ? { topics } : {}) });
324
+ const result = await resolved.mem.store({
325
+ detail,
326
+ ...(kind ? { kind } : {}),
327
+ ...(topics && topics.length > 0 ? { topics } : {}),
328
+ });
320
329
  if (!result.created) return toolText(`Memory already exists in ${resolved.route.repo}.\n${renderMemoryBlock(result.memory)}`);
321
330
  return toolText(`Stored memory in ${resolved.route.repo}.\n${renderMemoryBlock(result.memory)}`);
322
331
  },
@@ -1006,7 +1015,7 @@ class ClawMemService {
1006
1015
  properties: {
1007
1016
  org: { type: "string", minLength: 1, description: "Organization login." },
1008
1017
  inviteeLogin: { type: "string", minLength: 1, description: "Username to invite." },
1009
- role: { type: "string", enum: ["member", "admin"], description: "Org role for the invitation. Defaults to member." },
1018
+ role: { type: "string", enum: ["member", "owner"], description: "Org role for the invitation. Defaults to member." },
1010
1019
  teamIds: {
1011
1020
  type: "array",
1012
1021
  description: "Optional numeric team ids to pre-assign on acceptance.",
@@ -1027,7 +1036,8 @@ class ClawMemService {
1027
1036
  const org = typeof p.org === "string" ? p.org.trim() : "";
1028
1037
  const inviteeLogin = typeof p.inviteeLogin === "string" ? p.inviteeLogin.trim() : "";
1029
1038
  if (!org || !inviteeLogin) return toolText("org and inviteeLogin are required.");
1030
- const role: CollaborationOrgRole = p.role === "admin" ? "admin" : "member";
1039
+ const role = resolveOrgInvitationRole(p.role, "member");
1040
+ if ("error" in role) return toolText(role.error);
1031
1041
  const teamIds = Array.isArray(p.teamIds)
1032
1042
  ? p.teamIds.filter((value): value is number => typeof value === "number" && Number.isInteger(value) && value > 0)
1033
1043
  : undefined;
@@ -1039,7 +1049,7 @@ class ClawMemService {
1039
1049
  try {
1040
1050
  const invitation = await resolved.client.createOrgInvitation(org, {
1041
1051
  inviteeLogin,
1042
- role,
1052
+ role: role.role,
1043
1053
  ...(teamIds && teamIds.length > 0 ? { teamIds } : {}),
1044
1054
  ...(expiresInDays ? { expiresInDays } : {}),
1045
1055
  });
@@ -1228,9 +1238,9 @@ class ClawMemService {
1228
1238
  }
1229
1239
 
1230
1240
  try {
1231
- const collaborators = await target.client.listRepoCollaborators(target.owner, target.repo);
1241
+ const collaborators = filterDirectCollaborators(await target.client.listRepoCollaborators(target.owner, target.repo), target.owner);
1232
1242
  lines.push("");
1233
- lines.push("Direct collaborators:");
1243
+ lines.push("Explicit collaborators (excluding owner):");
1234
1244
  if (collaborators.length === 0) lines.push("- None visible");
1235
1245
  else lines.push(...collaborators.map((collaborator) => `- ${renderCollaboratorLine(collaborator)}`));
1236
1246
  } catch (error) {
@@ -1247,14 +1257,17 @@ class ClawMemService {
1247
1257
  notes.push(`Repo invitation lookup failed: ${String(error)}`);
1248
1258
  }
1249
1259
 
1250
- try {
1251
- const teams = await target.client.listRepoTeams(target.owner, target.repo);
1252
- lines.push("");
1253
- lines.push("Teams with repo access:");
1254
- if (teams.length === 0) lines.push("- None visible");
1255
- else lines.push(...teams.map((team) => `- ${renderTeamLine(team)}`));
1256
- } catch (error) {
1257
- notes.push(`Repo team grant lookup failed: ${String(error)}`);
1260
+ if (orgName) {
1261
+ try {
1262
+ const teamAccess = await listRepoAccessTeams(target.client, orgName, target.fullName);
1263
+ lines.push("");
1264
+ lines.push("Teams with repo access:");
1265
+ if (teamAccess.teams.length === 0) lines.push("- None visible");
1266
+ else lines.push(...teamAccess.teams.map((team) => `- ${renderTeamLine(team)}`));
1267
+ notes.push(...teamAccess.notes);
1268
+ } catch (error) {
1269
+ notes.push(`Repo team grant lookup failed: ${String(error)}`);
1270
+ }
1258
1271
  }
1259
1272
 
1260
1273
  try {
@@ -1283,13 +1296,13 @@ class ClawMemService {
1283
1296
  private async handleBeforeAgentStart(prompt: unknown, agentId?: string): Promise<{ prependContext: string } | void> {
1284
1297
  const routeAgentId = normalizeAgentId(agentId);
1285
1298
  if (!(await this.ensureDefaultRepoConfigured(routeAgentId))) return;
1286
- await this.runRequestMaintenance(routeAgentId);
1299
+ this.scheduleRecentSessionMaintenance(routeAgentId);
1287
1300
  if (typeof prompt !== "string" || prompt.trim().length < 5) return;
1288
1301
  try {
1289
1302
  const { mem } = this.getServices(routeAgentId);
1290
- const memories = await mem.search(prompt, this.config.memoryRecallLimit);
1303
+ const memories = await mem.search(prompt, this.config.memoryAutoRecallLimit);
1291
1304
  if (memories.length === 0) return;
1292
- const text = memories.map((m) => `- ${m.detail}`).join("\n");
1305
+ const text = memories.map((m) => `- ${formatInjectedMemory(m)}`).join("\n");
1293
1306
  return { prependContext: `<relevant-memories>\nThe following active memories may be relevant to this conversation:\n${text}\n</relevant-memories>` };
1294
1307
  } catch (error) { this.api.logger.warn(`clawmem: memory recall failed: ${String(error)}`); }
1295
1308
  }
@@ -1357,6 +1370,7 @@ class ClawMemService {
1357
1370
  private async finalize(p: FinalizePayload): Promise<void> {
1358
1371
  if (!p.sessionId) return;
1359
1372
  const agentId = normalizeAgentId(p.agentId);
1373
+ const scopeKey = sessionScopeKey(p.sessionId, agentId);
1360
1374
  if (!(await this.ensureDefaultRepoConfigured(agentId))) return;
1361
1375
  const { conv } = this.getServices(agentId);
1362
1376
  const s = this.getOrCreate(p.sessionId, agentId);
@@ -1375,6 +1389,7 @@ class ClawMemService {
1375
1389
  s.summaryStatus = "pending";
1376
1390
  if (allOk) s.finalizedAt = new Date().toISOString();
1377
1391
  await this.persistState();
1392
+ this.scheduleSessionMaintenance(scopeKey, agentId, { reason: p.reason ?? "finalize" });
1378
1393
  }
1379
1394
 
1380
1395
  // --- Infrastructure ---
@@ -1531,55 +1546,109 @@ class ClawMemService {
1531
1546
  },
1532
1547
  });
1533
1548
  }
1534
- private async runRequestMaintenance(agentId: string): Promise<void> {
1549
+ private scheduleRecentSessionMaintenance(agentId: string): void {
1535
1550
  const sessions = Object.values(this.state.sessions)
1536
1551
  .filter((session) => normalizeAgentId(session.agentId) === agentId)
1537
1552
  .sort((a, b) => Date.parse(b.updatedAt ?? b.createdAt ?? "") - Date.parse(a.updatedAt ?? a.createdAt ?? ""))
1538
1553
  .slice(0, 8);
1539
- if (sessions.length === 0) return;
1554
+ for (const session of sessions) {
1555
+ if (!this.sessionNeedsMaintenance(session)) continue;
1556
+ this.scheduleSessionMaintenance(sessionScopeKey(session.sessionId, session.agentId), agentId, {
1557
+ reason: "request-start-fallback",
1558
+ delayMs: 0,
1559
+ });
1560
+ break;
1561
+ }
1562
+ }
1563
+
1564
+ private scheduleSessionMaintenance(
1565
+ scopeKey: string,
1566
+ agentId: string,
1567
+ options: { delayMs?: number; attempt?: number; reason?: string } = {},
1568
+ ): void {
1569
+ const prev = this.maintenanceTimers.get(scopeKey);
1570
+ if (prev) clearTimeout(prev);
1571
+ const delayMs = Math.max(0, options.delayMs ?? 0);
1572
+ const attempt = Math.max(0, options.attempt ?? 0);
1573
+ const reason = options.reason ?? "scheduled";
1574
+ const timer = setTimeout(() => {
1575
+ this.maintenanceTimers.delete(scopeKey);
1576
+ void this.track(this.enqueueSession(scopeKey, () => this.runSessionMaintenance(scopeKey, agentId, attempt, reason)))
1577
+ .catch((error) => this.warn(`background maintenance for ${scopeKey}`, error));
1578
+ }, delayMs);
1579
+ timer.unref?.();
1580
+ this.maintenanceTimers.set(scopeKey, timer);
1581
+ }
1582
+
1583
+ private async runSessionMaintenance(scopeKey: string, agentId: string, attempt: number, reason: string): Promise<void> {
1584
+ const session = this.state.sessions[scopeKey];
1585
+ if (!session || !this.sessionNeedsMaintenance(session)) return;
1586
+ if (!(await this.ensureDefaultRepoConfigured(agentId))) return;
1540
1587
  const { conv, mem } = this.getServices(agentId);
1588
+ const snap = await conv.loadSnapshot(session, []);
1589
+ if (!conv.shouldMirror(session.sessionId, snap.messages) || snap.messages.length === 0) return;
1541
1590
  let changed = false;
1542
- let workDone = 0;
1543
- for (const session of sessions) {
1544
- if (workDone >= 3) break;
1545
- const snap = await conv.loadSnapshot(session, []);
1546
- if (!conv.shouldMirror(session.sessionId, snap.messages) || snap.messages.length === 0) continue;
1547
- if (!session.issueNumber) {
1548
- await conv.ensureIssue(session, snap);
1549
- changed = true;
1550
- }
1551
- if (session.summaryStatus === "pending") {
1552
- try {
1553
- const result = await conv.generateSummaryAndTitle(session, snap);
1554
- await conv.syncLabels(session, snap, true);
1555
- await conv.syncBody(session, snap, result.summary, true, result.title);
1556
- session.summaryStatus = "complete";
1557
- if (result.title?.trim()) {
1558
- session.issueTitle = result.title.trim();
1559
- session.titleSource = "llm";
1560
- }
1561
- this.maybeAutoNameRepo(agentId, result.summary, result.title);
1562
- changed = true;
1563
- workDone++;
1564
- } catch (error) {
1565
- this.warn(`request-scoped summary sync for ${session.sessionId}`, error);
1591
+ let retryNeeded = false;
1592
+ if (!session.issueNumber) {
1593
+ await conv.ensureIssue(session, snap);
1594
+ changed = true;
1595
+ }
1596
+ if (session.summaryStatus === "pending") {
1597
+ try {
1598
+ const result = await conv.generateSummaryAndTitle(session, snap);
1599
+ await conv.syncLabels(session, snap, true);
1600
+ await conv.syncBody(session, snap, result.summary, true, result.title);
1601
+ session.summaryStatus = "complete";
1602
+ if (result.title?.trim()) {
1603
+ session.issueTitle = result.title.trim();
1604
+ session.titleSource = "llm";
1566
1605
  }
1606
+ this.maybeAutoNameRepo(agentId, result.summary, result.title);
1607
+ changed = true;
1608
+ } catch (error) {
1609
+ retryNeeded = true;
1610
+ this.warn(`background summary sync for ${session.sessionId}`, error);
1567
1611
  }
1568
- if (session.titleSource !== "llm" && snap.messages.length >= 2) {
1612
+ }
1613
+ if (session.titleSource !== "llm" && snap.messages.length >= 2) {
1614
+ try {
1569
1615
  await conv.syncTitle(session, snap);
1570
1616
  changed = true;
1571
- workDone++;
1617
+ } catch (error) {
1618
+ retryNeeded = true;
1619
+ this.warn(`background title sync for ${session.sessionId}`, error);
1572
1620
  }
1573
- if (snap.messages.length >= 2 && snap.messages.length > (session.lastMemorySyncCount ?? 0)) {
1574
- const ok = await mem.syncFromConversation(session, snap);
1575
- if (ok) {
1576
- session.lastMemorySyncCount = snap.messages.length;
1577
- changed = true;
1578
- }
1579
- workDone++;
1621
+ }
1622
+ if (snap.messages.length >= 2 && snap.messages.length > (session.lastMemorySyncCount ?? 0)) {
1623
+ const ok = await mem.syncFromConversation(session, snap);
1624
+ if (ok) {
1625
+ session.lastMemorySyncCount = snap.messages.length;
1626
+ changed = true;
1627
+ } else {
1628
+ retryNeeded = true;
1580
1629
  }
1581
1630
  }
1582
1631
  if (changed) await this.persistState();
1632
+ if (!retryNeeded || !this.sessionNeedsMaintenance(session)) return;
1633
+ if (attempt < SESSION_MAINTENANCE_RETRY_DELAYS_MS.length) {
1634
+ const delayMs = SESSION_MAINTENANCE_RETRY_DELAYS_MS[attempt];
1635
+ this.api.logger.warn?.(
1636
+ `clawmem: background maintenance incomplete for ${session.sessionId}; retrying in ${Math.round(delayMs / 1000)}s (${reason})`,
1637
+ );
1638
+ this.scheduleSessionMaintenance(scopeKey, agentId, { delayMs, attempt: attempt + 1, reason: "retry" });
1639
+ return;
1640
+ }
1641
+ this.api.logger.warn?.(
1642
+ `clawmem: background maintenance remains pending for ${session.sessionId}; it will be retried opportunistically on future requests`,
1643
+ );
1644
+ }
1645
+
1646
+ private sessionNeedsMaintenance(session: SessionMirrorState): boolean {
1647
+ if (session.summaryStatus === "pending") return true;
1648
+ const hasMeaningfulTranscript = Math.max(session.lastMirroredCount, session.turnCount) >= 2;
1649
+ if (!hasMeaningfulTranscript) return false;
1650
+ if (session.titleSource !== "llm") return true;
1651
+ return (session.lastMemorySyncCount ?? 0) < session.lastMirroredCount;
1583
1652
  }
1584
1653
 
1585
1654
  private getServices(agentId?: string, repo?: string): { route: ClawMemResolvedRoute; conv: ConversationMirror; mem: MemoryStore; client: GitHubIssueClient } {
@@ -1706,11 +1775,30 @@ function shouldFallbackToAnonymousBootstrap(error: unknown): boolean {
1706
1775
  function toolText(text: string): { content: Array<{ type: "text"; text: string }> } {
1707
1776
  return { content: [{ type: "text", text }] };
1708
1777
  }
1709
- function renderMemoryLine(memory: { memoryId: string; title?: string; detail: string; kind?: string; topics?: string[]; status: "active" | "stale" }): string {
1710
- const schema = [memory.kind ? `kind:${memory.kind}` : "", ...(memory.topics ?? []).map((topic) => `topic:${topic}`)].filter(Boolean).join(", ");
1778
+ function renderMemoryLine(memory: {
1779
+ memoryId: string;
1780
+ title?: string;
1781
+ detail: string;
1782
+ kind?: string;
1783
+ topics?: string[];
1784
+ status: "active" | "stale";
1785
+ }): string {
1786
+ const schema = [
1787
+ memory.kind ? `kind:${memory.kind}` : "",
1788
+ ...(memory.topics ?? []).map((topic) => `topic:${topic}`),
1789
+ ].filter(Boolean).join(", ");
1711
1790
  return `[${memory.memoryId}] ${memory.title || "Memory"}${schema ? ` (${schema})` : ""}${memory.status === "stale" ? " [stale]" : ""}: ${memory.detail}`;
1712
1791
  }
1713
- function renderMemoryBlock(memory: { memoryId: string; issueNumber?: number; title?: string; detail: string; kind?: string; topics?: string[]; status: "active" | "stale"; date?: string }): string {
1792
+ function renderMemoryBlock(memory: {
1793
+ memoryId: string;
1794
+ issueNumber?: number;
1795
+ title?: string;
1796
+ detail: string;
1797
+ kind?: string;
1798
+ topics?: string[];
1799
+ status: "active" | "stale";
1800
+ date?: string;
1801
+ }): string {
1714
1802
  const lines = [
1715
1803
  `Memory ID: ${memory.memoryId}`,
1716
1804
  ...(typeof memory.issueNumber === "number" ? [`Issue Number: ${memory.issueNumber}`] : []),
@@ -1724,6 +1812,12 @@ function renderMemoryBlock(memory: { memoryId: string; issueNumber?: number; tit
1724
1812
  return lines.join("\n");
1725
1813
  }
1726
1814
 
1815
+ function formatInjectedMemory(memory: {
1816
+ detail: string;
1817
+ }): string {
1818
+ return memory.detail;
1819
+ }
1820
+
1727
1821
  function renderOrgLine(org: { login?: string; name?: string; default_repository_permission?: string; description?: string }): string {
1728
1822
  const login = org.login?.trim() || "unknown-org";
1729
1823
  const name = org.name?.trim() ? ` (${org.name.trim()})` : "";
package/src/types.ts CHANGED
@@ -15,6 +15,7 @@ export type ClawMemPluginConfig = {
15
15
  authScheme: "token" | "bearer";
16
16
  agents: Record<string, ClawMemAgentConfig>;
17
17
  memoryRecallLimit: number;
18
+ memoryAutoRecallLimit: number;
18
19
  turnCommentDelayMs: number;
19
20
  summaryWaitTimeoutMs: number;
20
21
  };