@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 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).
163
- - Memory issue bodies store the durable detail plus flat metadata such as `memory_hash` and logical `date`; labels are reserved for schema and routing.
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`.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "id": "clawmem",
3
3
  "name": "ClawMem",
4
- "version": "0.1.12",
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clawmem-ai/clawmem",
3
- "version": "0.1.12",
3
+ "version": "0.1.14",
4
4
  "description": "Mirror OpenClaw sessions into GitHub-compatible issues and comments.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -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 "Memory: <concise title>" \
89
- --body "<durable detail in plain language>" \
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\": \"Memory: <concise title>\",
109
- \"body\": \"<durable detail in plain language>\",
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 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,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
+ }
@@ -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
 
@@ -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, { memoryRecallLimit: 5, turnCommentDelayMs: 1000, summaryWaitTimeoutMs: 120000 } 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, { memoryRecallLimit: 5, turnCommentDelayMs: 1000, summaryWaitTimeoutMs: 120000 } 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, { memoryRecallLimit: 5, turnCommentDelayMs: 1000, summaryWaitTimeoutMs: 120000 } 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, { memoryRecallLimit: 5, turnCommentDelayMs: 1000, summaryWaitTimeoutMs: 120000 } 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
  }