@yugabytedb/meko-mcp 1.0.8 → 1.2.0

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.
Files changed (51) hide show
  1. package/README.md +242 -44
  2. package/assets/hooks-handlers/before-submit-prompt.sh +44 -0
  3. package/assets/hooks-handlers/cursor-session-start.sh +42 -0
  4. package/assets/hooks-handlers/lib/capture.js +457 -107
  5. package/assets/hooks-handlers/lib/package.json +1 -0
  6. package/assets/hooks-handlers/test_single_conversation.sh +1 -1
  7. package/assets/scripts/package-desktop-skill.sh +36 -0
  8. package/assets/skills/meko-mcp-tools/SKILL.md +167 -53
  9. package/assets/skills/meko-mcp-tools/references/_sections.md +1 -1
  10. package/assets/skills/meko-mcp-tools/references/tools-agent-id-conventions.md +84 -22
  11. package/assets/skills/meko-mcp-tools/references/tools-cookbook.md +89 -56
  12. package/assets/skills/meko-mcp-tools/references/tools-datapack-workflow.md +15 -7
  13. package/assets/skills/meko-mcp-tools/references/tools-known-limitations.md +17 -9
  14. package/assets/skills/meko-mcp-tools/references/tools-memory-vs-conversation.md +94 -5
  15. package/assets/skills/meko-mcp-tools/references/tools-overview.md +52 -37
  16. package/assets/skills/meko-mcp-tools/references/tools-rag-workflow.md +108 -37
  17. package/assets/skills/meko-mcp-tools/references/tools-scope-permissions.md +6 -10
  18. package/assets/skills/meko-mcp-tools/references/tools-troubleshooting.md +30 -18
  19. package/assets/skills/meko-mcp-tools/references/tools-user-id-conventions.md +55 -69
  20. package/assets/skills/meko-mcp-tools-desktop/SKILL.md +251 -0
  21. package/assets/skills/meko-mcp-tools-desktop/desktop/README.md +150 -0
  22. package/assets/skills/meko-mcp-tools-desktop/references/_sections.md +24 -0
  23. package/assets/skills/meko-mcp-tools-desktop/references/tools-agent-id-conventions.md +99 -0
  24. package/assets/skills/meko-mcp-tools-desktop/references/tools-cookbook.md +406 -0
  25. package/assets/skills/meko-mcp-tools-desktop/references/tools-known-limitations.md +69 -0
  26. package/assets/skills/meko-mcp-tools-desktop/references/tools-memory-vs-conversation.md +217 -0
  27. package/assets/skills/meko-mcp-tools-desktop/references/tools-overview.md +112 -0
  28. package/assets/skills/meko-mcp-tools-desktop/references/tools-rag-workflow.md +134 -0
  29. package/assets/skills/meko-mcp-tools-desktop/references/tools-scope-permissions.md +75 -0
  30. package/assets/skills/meko-mcp-tools-desktop/references/tools-troubleshooting.md +155 -0
  31. package/bin/create-meko-setup.mjs +151 -22
  32. package/lib/clients/claude-code.mjs +378 -68
  33. package/lib/clients/claude-desktop.mjs +45 -8
  34. package/lib/clients/codex.mjs +1209 -0
  35. package/lib/clients/common.mjs +223 -1
  36. package/lib/clients/cursor.mjs +118 -13
  37. package/lib/clients/index.mjs +2 -0
  38. package/lib/config.mjs +56 -0
  39. package/lib/detect.mjs +52 -44
  40. package/lib/installer.mjs +11 -4
  41. package/lib/mcp-transport.mjs +61 -2
  42. package/lib/migrate/adapters/memory-md.mjs +13 -6
  43. package/lib/migrate/datapack.mjs +167 -0
  44. package/lib/migrate/id.mjs +127 -9
  45. package/lib/migrate/orchestrator.mjs +68 -10
  46. package/lib/migrate.mjs +346 -3
  47. package/lib/prompt.mjs +22 -4
  48. package/lib/validate.mjs +313 -9
  49. package/lib/version-check.mjs +178 -0
  50. package/package.json +2 -1
  51. package/scripts/bundle-assets.mjs +8 -0
package/lib/installer.mjs CHANGED
@@ -32,11 +32,12 @@ export async function install(options) {
32
32
  name = "meko",
33
33
  skipSkills = false,
34
34
  skipValidation = false,
35
+ skipCanary = false,
35
36
  dryRun = false,
36
37
  verbose = false,
37
38
  yes = false,
38
39
  projectRoot = process.cwd(),
39
- cursorHookMode = "hybrid",
40
+ cursorHookMode = "native",
40
41
  } = options;
41
42
 
42
43
  const adapters = getClientAdapters();
@@ -107,7 +108,7 @@ export async function install(options) {
107
108
 
108
109
  if (!skipMcpRegistration) {
109
110
  try {
110
- adapter.registerServer({
111
+ await adapter.registerServer({
111
112
  name,
112
113
  url,
113
114
  scope,
@@ -116,6 +117,7 @@ export async function install(options) {
116
117
  verbose,
117
118
  log: LOG,
118
119
  projectRoot,
120
+ yes,
119
121
  });
120
122
  stepOk(currentStep, totalSteps, dryRun ? "Registering MCP server (dry-run)." : "MCP server registered.");
121
123
  } catch (err) {
@@ -151,6 +153,7 @@ export async function install(options) {
151
153
  if (!skipSkills) {
152
154
  try {
153
155
  const result = adapter.installSkills({
156
+ name,
154
157
  scope,
155
158
  dryRun,
156
159
  verbose,
@@ -176,7 +179,8 @@ export async function install(options) {
176
179
  try {
177
180
  const results = await runValidation(url, apiKey, name, {
178
181
  validateConfig: async ({ serverName }) =>
179
- adapter.validateConfig({ serverName, scope, projectRoot }),
182
+ adapter.validateConfig({ serverName, scope, projectRoot, url, apiKey }),
183
+ skipCanary,
180
184
  });
181
185
 
182
186
  const checks = [
@@ -184,6 +188,9 @@ export async function install(options) {
184
188
  { label: "Connectivity", ...results.connectivity },
185
189
  { label: "Tools", ...results.tools },
186
190
  ];
191
+ if (results.canary) {
192
+ checks.push({ label: "Canary", ...results.canary });
193
+ }
187
194
  for (const check of checks) {
188
195
  if (check.ok) success(`${check.label}: ${check.message}`);
189
196
  else warn(`${check.label}: ${check.message}`);
@@ -218,7 +225,7 @@ export async function uninstall(options = {}) {
218
225
  scope = "user",
219
226
  verbose = false,
220
227
  projectRoot = process.cwd(),
221
- cursorHookMode = "hybrid",
228
+ cursorHookMode = "native",
222
229
  } = options;
223
230
 
224
231
  const adapters = getClientAdapters();
@@ -29,9 +29,16 @@ export function jsonRpcRequest(url, payload, apiKey, sessionId, opts = {}) {
29
29
  const parsed = new URL(url);
30
30
  const transport = parsed.protocol === "https:" ? https : http;
31
31
 
32
+ // MCP Streamable HTTP requires the client to advertise both content
33
+ // types: a non-streaming JSON response uses application/json, a
34
+ // streaming response (which is what FastMCP returns for `initialize`
35
+ // and any other call when stateless_http isn't `json_response=True`)
36
+ // uses text/event-stream. Sending only `application/json` produces a
37
+ // 406 Not Acceptable from spec-compliant servers (see meko-mcp-server
38
+ // PR #97 / MEKO-137 which turned this on for prod).
32
39
  const headers = {
33
40
  "Content-Type": "application/json",
34
- Accept: "application/json",
41
+ Accept: "application/json, text/event-stream",
35
42
  };
36
43
  if (apiKey) {
37
44
  headers["Authorization"] = `Bearer ${apiKey}`;
@@ -56,10 +63,19 @@ export function jsonRpcRequest(url, payload, apiKey, sessionId, opts = {}) {
56
63
  const chunks = [];
57
64
  res.on("data", (chunk) => chunks.push(chunk));
58
65
  res.on("end", () => {
66
+ const raw = Buffer.concat(chunks).toString("utf8");
67
+ // If the server answered with text/event-stream (the MCP spec's
68
+ // streaming response shape), extract the JSON-RPC payload from
69
+ // the first SSE `data:` field. Callers downstream want a plain
70
+ // JSON-RPC body — they don't care which framing the wire used.
71
+ const contentType = String(res.headers["content-type"] ?? "").toLowerCase();
72
+ const body = contentType.includes("text/event-stream")
73
+ ? extractSseData(raw)
74
+ : raw;
59
75
  resolve({
60
76
  statusCode: res.statusCode,
61
77
  headers: res.headers,
62
- body: Buffer.concat(chunks).toString("utf8"),
78
+ body,
63
79
  });
64
80
  });
65
81
  },
@@ -75,6 +91,49 @@ export function jsonRpcRequest(url, payload, apiKey, sessionId, opts = {}) {
75
91
  });
76
92
  }
77
93
 
94
+ /**
95
+ * Extract the JSON-RPC payload from an SSE-framed response body.
96
+ *
97
+ * MCP servers running Streamable HTTP wrap each JSON-RPC response in an
98
+ * SSE event of the form:
99
+ *
100
+ * event: message
101
+ * data: {"jsonrpc":"2.0","id":1,"result":{...}}
102
+ *
103
+ * For a single non-streaming JSON-RPC response (initialize, tools/list,
104
+ * tools/call) there's exactly one event in the body. SSE allows the
105
+ * payload to span multiple `data:` lines; per the spec they are joined
106
+ * with `\n`. We concatenate every `data:` line we see in the first event
107
+ * and return that. Returns the raw body unchanged if no `data:` line is
108
+ * found (defensive fallback so a misbehaving server still produces a
109
+ * useful "non-JSON response" error downstream rather than an empty
110
+ * string).
111
+ *
112
+ * @param {string} raw - Raw response body (already utf8-decoded).
113
+ * @returns {string}
114
+ */
115
+ function extractSseData(raw) {
116
+ const dataLines = [];
117
+ // Split on all three SSE-permitted line terminators per the WHATWG SSE
118
+ // spec: LF, CRLF, and bare CR. Most servers (including FastMCP) emit
119
+ // LF or CRLF, but the spec is explicit about CR-only being valid and
120
+ // we shouldn't break on a producer that uses it.
121
+ for (const rawLine of raw.split(/\r\n|\r|\n/)) {
122
+ if (rawLine === "") {
123
+ // Blank line terminates the event. We only need the first event for
124
+ // a JSON-RPC response.
125
+ if (dataLines.length > 0) break;
126
+ continue;
127
+ }
128
+ // `data:` field — capture the value, stripping the optional single
129
+ // leading space the spec allows after the colon. Anything else
130
+ // (`event:`, `id:`, `retry:`, comments starting with `:`) we ignore.
131
+ const match = /^data: ?(.*)$/.exec(rawLine);
132
+ if (match) dataLines.push(match[1]);
133
+ }
134
+ return dataLines.length > 0 ? dataLines.join("\n") : raw;
135
+ }
136
+
78
137
  /**
79
138
  * Produce a human-friendly message from a network error.
80
139
  *
@@ -64,9 +64,9 @@ export async function enumerateMemoryItems(opts) {
64
64
  if (!cwd) continue;
65
65
  const agentId = agentIdForClaude(cwd);
66
66
  const projMd = path.join(cwd, ".claude", "CLAUDE.md");
67
- items.push(...readClaudeMd(projMd, agentId, "project"));
67
+ items.push(...readClaudeMd(projMd, agentId, "project", cwd));
68
68
  const memDir = path.join(dir, "memory");
69
- items.push(...readMemoryDir(memDir, agentId, "project"));
69
+ items.push(...readMemoryDir(memDir, agentId, "project", cwd));
70
70
  }
71
71
  }
72
72
  } else {
@@ -74,10 +74,10 @@ export async function enumerateMemoryItems(opts) {
74
74
  if (!cwd) return [];
75
75
  const agentId = agentIdForClaude(cwd);
76
76
  const projMd = path.join(cwd, ".claude", "CLAUDE.md");
77
- items.push(...readClaudeMd(projMd, agentId, "project"));
77
+ items.push(...readClaudeMd(projMd, agentId, "project", cwd));
78
78
  const slug = slugifyCwd(cwd);
79
79
  const memDir = path.join(home, ".claude", "projects", slug, "memory");
80
- items.push(...readMemoryDir(memDir, agentId, "project"));
80
+ items.push(...readMemoryDir(memDir, agentId, "project", cwd));
81
81
  }
82
82
 
83
83
  return items;
@@ -90,9 +90,13 @@ export async function enumerateMemoryItems(opts) {
90
90
  * @param {string} filePath
91
91
  * @param {string} agentId
92
92
  * @param {"user"|"project"} scope
93
+ * @param {string|null} [cwd] - Absolute project path. Attached to metadata
94
+ * so downstream consumers can correlate even after the agent_id is
95
+ * sanitized down to a basename. Null for user-scope items (which use the
96
+ * `meko_agent` common bucket).
93
97
  * @returns {MemoryItem[]}
94
98
  */
95
- export function readClaudeMd(filePath, agentId, scope) {
99
+ export function readClaudeMd(filePath, agentId, scope, cwd = null) {
96
100
  let raw;
97
101
  try {
98
102
  raw = fs.readFileSync(filePath, "utf8");
@@ -115,6 +119,7 @@ export function readClaudeMd(filePath, agentId, scope) {
115
119
  source: path.basename(filePath),
116
120
  scope,
117
121
  sourcePath: filePath,
122
+ ...(cwd ? { cwd } : {}),
118
123
  ...(heading ? { heading } : {}),
119
124
  },
120
125
  };
@@ -160,9 +165,10 @@ export function splitTopLevelSections(md) {
160
165
  * @param {string} memDir
161
166
  * @param {string} agentId
162
167
  * @param {"user"|"project"} scope
168
+ * @param {string|null} [cwd] - Absolute project path; attached to metadata.
163
169
  * @returns {MemoryItem[]}
164
170
  */
165
- export function readMemoryDir(memDir, agentId, scope) {
171
+ export function readMemoryDir(memDir, agentId, scope, cwd = null) {
166
172
  let entries;
167
173
  try {
168
174
  entries = fs.readdirSync(memDir);
@@ -193,6 +199,7 @@ export function readMemoryDir(memDir, agentId, scope) {
193
199
  source: `memory/${entry}`,
194
200
  scope,
195
201
  sourcePath: full,
202
+ ...(cwd ? { cwd } : {}),
196
203
  ...(frontmatter.name ? { name: frontmatter.name } : {}),
197
204
  ...(frontmatter.description ? { description: frontmatter.description } : {}),
198
205
  ...(frontmatter.type ? { type: frontmatter.type } : {}),
@@ -0,0 +1,167 @@
1
+ /**
2
+ * datapack.mjs — Resolve a migration's destination datapack.
3
+ *
4
+ * The migrator was historically silent about destination: tools were called
5
+ * without `datapack_id`, the server filled in `tenant_ctx.default_datapack_id`,
6
+ * and the user never saw which pack their data actually landed in. With
7
+ * `--datapack <name-or-id>` we want both an explicit override and a printed
8
+ * confirmation of the default-fallback case. This module owns the lookup.
9
+ *
10
+ * Wire model: the MCP server routes by `datapack_id` parameter, not URL host.
11
+ * `datapack_list` returns the user's visible packs; we resolve here, then the
12
+ * orchestrator threads `datapack_id` into every subsequent `tools/call`.
13
+ */
14
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
15
+ const DEFAULT_DATAPACK_NAME = "meko_default_datapack";
16
+
17
+ export function isUuid(value) {
18
+ return typeof value === "string" && UUID_RE.test(value);
19
+ }
20
+
21
+ /**
22
+ * Parse a `datapack_list` response body. Mirrors the shape-validation in
23
+ * validate.mjs: accepts a JSON array whose elements look like datapack
24
+ * records (`datapack_id` string present). Returns null on any other shape so
25
+ * callers can produce a precise error rather than a TypeError on .find().
26
+ *
27
+ * Note: response bodies can include connection-string secrets — never log
28
+ * `text` directly; only the resolved `{id, name}` is safe to surface.
29
+ *
30
+ * @param {string|null} text
31
+ * @returns {Array|null}
32
+ */
33
+ export function parseDatapackList(text) {
34
+ if (!text) return null;
35
+ let body;
36
+ try {
37
+ body = JSON.parse(text);
38
+ } catch {
39
+ return null;
40
+ }
41
+ if (!Array.isArray(body)) return null;
42
+ if (body.length === 0) return body;
43
+ const allLook = body.every(
44
+ (e) => e && typeof e === "object" && typeof e.datapack_id === "string",
45
+ );
46
+ return allLook ? body : null;
47
+ }
48
+
49
+ /**
50
+ * Resolve the destination datapack for a migration.
51
+ *
52
+ * @param {object} opts
53
+ * @param {{callTool: Function}} opts.client Connected McpClient.
54
+ * @param {string|null} opts.requested Raw `--datapack` value (UUID or
55
+ * name) or null for default.
56
+ * @returns {Promise<{
57
+ * ok: boolean,
58
+ * id?: string|null,
59
+ * name?: string|null,
60
+ * source?: "default"|"flag-id"|"flag-name"|"server-fallback",
61
+ * defaultId?: string|null,
62
+ * message?: string,
63
+ * }>}
64
+ *
65
+ * `source` describes how the destination was picked:
66
+ * - `default`: requested=null and a `meko_default_datapack` row exists.
67
+ * - `flag-id` / `flag-name`: explicit override resolved.
68
+ * - `server-fallback`: requested=null and no default row found in the list;
69
+ * we let the server's own fallback chain handle it (id stays null).
70
+ *
71
+ * `defaultId` is the id of the `meko_default_datapack` row if visible — surfaced
72
+ * even when `requested` is non-null so the caller can detect "user named the
73
+ * default explicitly" and reuse the same on-disk state file.
74
+ *
75
+ * Failure cases (`ok: false`):
76
+ * - `datapack_list` call failed (auth, network, server error).
77
+ * - response body wasn't a recognizable datapack list.
78
+ * - explicit override didn't match any row.
79
+ */
80
+ export async function resolveDestinationDatapack(opts) {
81
+ const { client, requested } = opts;
82
+ const res = await client.callTool("datapack_list", {
83
+ scope: "read",
84
+ conversation_id: "00000000-0000-0000-0000-000000000000",
85
+ });
86
+ if (!res.ok) {
87
+ return {
88
+ ok: false,
89
+ message:
90
+ `datapack_list failed: ${res.error?.message ?? "unknown"}. ` +
91
+ `Cannot resolve --datapack.`,
92
+ };
93
+ }
94
+ const list = parseDatapackList(res.text);
95
+ if (!list) {
96
+ return {
97
+ ok: false,
98
+ message:
99
+ "datapack_list returned an unrecognized response shape. " +
100
+ "Cannot resolve --datapack.",
101
+ };
102
+ }
103
+
104
+ const defaultRow = list.find((d) => d.datapack_name === DEFAULT_DATAPACK_NAME);
105
+ const defaultId = defaultRow?.datapack_id ?? null;
106
+
107
+ if (!requested) {
108
+ if (defaultRow) {
109
+ return {
110
+ ok: true,
111
+ id: defaultRow.datapack_id,
112
+ name: defaultRow.datapack_name,
113
+ source: "default",
114
+ defaultId,
115
+ };
116
+ }
117
+ return { ok: true, id: null, name: null, source: "server-fallback", defaultId };
118
+ }
119
+
120
+ if (isUuid(requested)) {
121
+ const hit = list.find((d) => d.datapack_id?.toLowerCase() === requested.toLowerCase());
122
+ if (!hit) {
123
+ return {
124
+ ok: false,
125
+ message:
126
+ `--datapack ${requested}: no datapack with that id is visible to ` +
127
+ `this API key. ${formatAvailable(list)}`,
128
+ };
129
+ }
130
+ return {
131
+ ok: true,
132
+ id: hit.datapack_id,
133
+ name: hit.datapack_name ?? null,
134
+ source: "flag-id",
135
+ defaultId,
136
+ };
137
+ }
138
+
139
+ const hit = list.find((d) => d.datapack_name === requested);
140
+ if (!hit) {
141
+ return {
142
+ ok: false,
143
+ message:
144
+ `--datapack ${JSON.stringify(requested)}: no datapack with that name. ` +
145
+ formatAvailable(list),
146
+ };
147
+ }
148
+ return {
149
+ ok: true,
150
+ id: hit.datapack_id,
151
+ name: hit.datapack_name,
152
+ source: "flag-name",
153
+ defaultId,
154
+ };
155
+ }
156
+
157
+ /**
158
+ * Format a one-line "Available: a, b, c" hint. Names only — never ids,
159
+ * because the underlying response can carry credentials.
160
+ */
161
+ function formatAvailable(list) {
162
+ const names = list
163
+ .map((d) => d.datapack_name)
164
+ .filter((n) => typeof n === "string" && n.length > 0);
165
+ if (names.length === 0) return "(no datapacks visible to this API key.)";
166
+ return `Available: ${names.join(", ")}.`;
167
+ }
@@ -3,6 +3,7 @@
3
3
  */
4
4
 
5
5
  import { createHash } from "node:crypto";
6
+ import path from "node:path";
6
7
 
7
8
  /**
8
9
  * Slugify an absolute path the way Claude Code stores project sessions:
@@ -34,25 +35,102 @@ export function unslugifyCwd(slug) {
34
35
  }
35
36
 
36
37
  /**
37
- * @param {string} cwd - Absolute project path.
38
- * @returns {string} Agent id, e.g. `claude-code:-Users-foo-bar`.
38
+ * Server-side fallback for empty/missing agent_id. Anything written without
39
+ * an explicit value lands here. Used as the cross-project common bucket for
40
+ * genuinely cross-project facts (user identity, global preferences) that any
41
+ * agent should see without knowing the writer's project.
42
+ */
43
+ export const COMMON_BUCKET_AGENT_ID = "meko_agent";
44
+
45
+ const AGENT_ID_MAX_LEN = 64;
46
+ const KNOWN_LOOSE_CLIENTS = new Set(["claude-desktop", "claude_desktop"]);
47
+ const KNOWN_CODING_CLIENTS = new Set(["claude_code", "cursor"]);
48
+
49
+ /**
50
+ * Sanitize a project segment: lowercase, collapse non-`[a-z0-9-]` to `-`,
51
+ * strip leading/trailing `-`, cap at 64 chars. Returns "" if the input
52
+ * yields nothing usable (caller falls back to the bare client name).
53
+ *
54
+ * @param {string} segment
55
+ * @returns {string}
56
+ */
57
+ function sanitizeProjectSegment(segment) {
58
+ if (!segment) return "";
59
+ const lower = String(segment).toLowerCase();
60
+ const collapsed = lower.replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, "");
61
+ return collapsed.slice(0, AGENT_ID_MAX_LEN);
62
+ }
63
+
64
+ /**
65
+ * Derive an `agent_id` for a Meko write.
66
+ *
67
+ * Contract:
68
+ * - `envOverride` (typically `MEKO_AGENT_ID`) wins if non-empty.
69
+ * - Loose clients (Claude Desktop) → bare `claude_desktop`; no schema.
70
+ * - Coding clients (Claude Code, Cursor) → `<client>:<repo-basename>`.
71
+ * Outside a repo (basename is empty/`.`/`/`) → bare client name to
72
+ * keep coding-agent traffic out of the common bucket.
73
+ * - Anything else → `meko_agent` (the common cross-project bucket).
74
+ *
75
+ * @param {object} opts
76
+ * @param {string} [opts.client] e.g. "claude_code", "cursor", "claude-desktop"
77
+ * @param {string} [opts.cwd] Absolute working directory, for coding clients
78
+ * @param {string} [opts.envOverride] Raw value of `MEKO_AGENT_ID`, if set
79
+ * @returns {string}
80
+ */
81
+ export function deriveAgentId(opts = {}) {
82
+ const override = (opts.envOverride ?? "").trim();
83
+ if (override) return override;
84
+
85
+ const client = (opts.client ?? "").trim();
86
+ if (KNOWN_LOOSE_CLIENTS.has(client)) return "claude_desktop";
87
+ if (!KNOWN_CODING_CLIENTS.has(client)) return COMMON_BUCKET_AGENT_ID;
88
+
89
+ const rawBase = opts.cwd ? path.basename(opts.cwd) : "";
90
+ if (!rawBase || rawBase === "." || rawBase === "/") return client;
91
+ const project = sanitizeProjectSegment(rawBase);
92
+ if (!project) return client;
93
+ return `${client}:${project}`;
94
+ }
95
+
96
+ /**
97
+ * Back-compat alias. New code should call `deriveAgentId(...)`.
98
+ */
99
+ export const AGENT_ID = COMMON_BUCKET_AGENT_ID;
100
+
101
+ /**
102
+ * @param {string} [cwd] Absolute project cwd. Required for project-scope
103
+ * writes; omit for user-scope writes (returns the common bucket).
104
+ * @returns {string}
39
105
  */
40
106
  export function agentIdForClaude(cwd) {
41
- return `claude-code:${slugifyCwd(cwd)}`;
107
+ if (!cwd) return COMMON_BUCKET_AGENT_ID;
108
+ return deriveAgentId({
109
+ client: "claude_code",
110
+ cwd,
111
+ envOverride: process.env.MEKO_AGENT_ID,
112
+ });
42
113
  }
43
114
 
44
115
  /**
45
- * User-scope agent id for artifacts that aren't tied to a specific project
46
- * (e.g., `~/.claude/CLAUDE.md`).
116
+ * User-scope agent id for content like `~/.claude/CLAUDE.md` that is genuinely
117
+ * cross-project. Goes into the `meko_agent` common bucket so any agent can
118
+ * read it regardless of the writer's project.
47
119
  */
48
- export const CLAUDE_CODE_USER_AGENT_ID = "claude-code:user";
120
+ export const CLAUDE_CODE_USER_AGENT_ID = COMMON_BUCKET_AGENT_ID;
49
121
 
50
122
  /**
51
- * @param {string} projectName - Cursor's human-readable project folder name.
123
+ * @param {string} [cwd] Absolute project cwd (or basename — Cursor's projects
124
+ * live under a flat `~/.cursor/projects/<name>/`). Omit for user-scope.
52
125
  * @returns {string}
53
126
  */
54
- export function agentIdForCursor(projectName) {
55
- return `cursor:${projectName}`;
127
+ export function agentIdForCursor(cwd) {
128
+ if (!cwd) return COMMON_BUCKET_AGENT_ID;
129
+ return deriveAgentId({
130
+ client: "cursor",
131
+ cwd,
132
+ envOverride: process.env.MEKO_AGENT_ID,
133
+ });
56
134
  }
57
135
 
58
136
  /**
@@ -81,3 +159,43 @@ export function seedFor(agentId, sessionUuid, messageUuid) {
81
159
  export function memoryHash(text, name = "") {
82
160
  return createHash("sha256").update(`${name}\0${text}`).digest("hex");
83
161
  }
162
+
163
+ /**
164
+ * Derive a stable, filesystem-safe key for the destination datapack so the
165
+ * migrator can scope its resume state per datapack.
166
+ *
167
+ * Strategy:
168
+ * - For the canonical cloud URL shape `https://<uuid>.mcp.mekodev.com/mcp`,
169
+ * the leading host label is a UUID — use it verbatim. This keeps the
170
+ * on-disk file name recognizable at a glance (it matches the datapack
171
+ * UUID you'd see in the cloud UI).
172
+ * - For anything else (localhost stacks, custom deployments), fall back
173
+ * to `sha256(normalizedUrl).slice(0, 16)` — still stable, still short,
174
+ * but unambiguously derived from the URL. We strip trailing slashes
175
+ * before hashing so `http://localhost:8000/mcp` and
176
+ * `http://localhost:8000/mcp/` resolve to the same key; otherwise a
177
+ * user copy-pasting the endpoint with or without a trailing slash
178
+ * would land on different state files and break resume.
179
+ *
180
+ * A bare `null`/empty string yields `"unknown"` so callers don't need a
181
+ * null-check just to pick a path. Callers that truly want to error on a
182
+ * missing URL should do so upstream.
183
+ *
184
+ * @param {string|null|undefined} url
185
+ * @returns {string}
186
+ */
187
+ export function datapackKeyFromUrl(url) {
188
+ if (!url) return "unknown";
189
+ const trimmed = String(url).trim();
190
+ if (!trimmed) return "unknown";
191
+ // UUID host label, e.g. https://352ded8e-...mcp.mekodev.com/mcp
192
+ const m = trimmed.match(
193
+ /^https?:\/\/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\./i,
194
+ );
195
+ if (m) return m[1].toLowerCase();
196
+ // Normalize trailing slashes so `.../mcp` and `.../mcp/` (and `.../mcp//`)
197
+ // all hash to the same key. We keep everything else as-is — paths are
198
+ // case-sensitive on most targets, so we deliberately don't touch case.
199
+ const normalized = trimmed.replace(/\/+$/, "");
200
+ return createHash("sha256").update(normalized).digest("hex").slice(0, 16);
201
+ }