@stage-labs/metro 0.1.0-beta.179 → 0.1.0-beta.180

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stage-labs/metro",
3
- "version": "0.1.0-beta.179",
3
+ "version": "0.1.0-beta.180",
4
4
  "description": "The metro command line. Sign in once per machine, then hand your MCP connector list to Claude Code without the credentials touching disk, argv or shell history.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "metro",
3
- "version": "0.1.0-beta.179",
3
+ "version": "0.1.0-beta.180",
4
4
  "description": "Metro for Claude Code, installed by the metro daemon on this machine: every connector the agent holds as its own MCP server relayed through the daemon, the orchestrator-only guard on the main thread, and the standing rules loaded at each session start. No vendor credential ever sits in a config file.",
5
5
  "author": {
6
6
  "name": "Bonustrack",
@@ -99,7 +99,10 @@ const ITEMS: Record<string, Handler> = {
99
99
  const { offset, limit } = pageOf(query);
100
100
  return readTranscript(projectOf(query), id, offset, limit, dir);
101
101
  },
102
- memory: (query, dir, name) => ({ name, content: readMemoryFile(projectOf(query), name, dir) }),
102
+ memory: (query, dir, raw) => {
103
+ const name = decodeURIComponent(raw);
104
+ return { name, content: readMemoryFile(projectOf(query), name, dir) };
105
+ },
103
106
  skills: (_query, dir, id) => readClaudeSkill(decodeURIComponent(id), dir),
104
107
  };
105
108
 
@@ -19,6 +19,9 @@ import { writeAtomic } from '@metro-labs/core/secure-fs';
19
19
  export const PROJECT_RE = /^[A-Za-z0-9._-]{1,200}$/;
20
20
  export const SESSION_RE = /^[A-Za-z0-9][A-Za-z0-9-]{7,63}$/;
21
21
  const MEMORY_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,119}\.md$/;
22
+ const FOLDER_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,119}$/;
23
+ const MEMORY_DEPTH = 6;
24
+ const MEMORY_FILES_MAX = 5000;
22
25
  const EDGE_BYTES = 64 * 1024;
23
26
  const TEXT_CAP = 20_000;
24
27
  const RESULT_CAP = 8_000;
@@ -312,16 +315,37 @@ function readCapped(path: string): string {
312
315
  }
313
316
  }
314
317
 
318
+ function memoryName(name: string): string[] {
319
+ const segments = name.split('/');
320
+ const file = segments.at(-1) ?? '';
321
+ const folders = segments.slice(0, -1);
322
+ if (folders.length >= MEMORY_DEPTH || !MEMORY_RE.test(file) || folders.some((f) => !FOLDER_RE.test(f)))
323
+ throw new ApiError('that is not a memory file name', 400);
324
+ return segments;
325
+ }
326
+
327
+ function memoryPath(project: string, name: string, dir: string): string {
328
+ return join(memoryDir(project, dir), ...memoryName(name));
329
+ }
330
+
331
+ function walkMemory(root: string, folders: string[], out: MemoryFile[]): void {
332
+ for (const entry of readdirSync(join(root, ...folders), { withFileTypes: true })) {
333
+ if (out.length >= MEMORY_FILES_MAX) return;
334
+ const inner = [...folders, entry.name];
335
+ if (entry.isDirectory() && FOLDER_RE.test(entry.name) && inner.length < MEMORY_DEPTH) walkMemory(root, inner, out);
336
+ else if (entry.isFile() && MEMORY_RE.test(entry.name) && !(folders.length === 0 && entry.name === 'MEMORY.md')) {
337
+ const s = statSync(join(root, ...inner));
338
+ out.push({ name: inner.join('/'), bytes: s.size, modifiedAt: s.mtime.toISOString() });
339
+ }
340
+ }
341
+ }
342
+
315
343
  export function listMemory(project: string, dir = claudeDir()): MemoryListing {
316
344
  const path = memoryDir(project, dir);
317
345
  if (!existsSync(path)) return { files: [], index: null };
318
- const files = readdirSync(path)
319
- .filter((name) => MEMORY_RE.test(name) && name !== 'MEMORY.md')
320
- .map((name) => {
321
- const s = statSync(join(path, name));
322
- return { name, bytes: s.size, modifiedAt: s.mtime.toISOString() };
323
- })
324
- .sort((a, b) => b.modifiedAt.localeCompare(a.modifiedAt) || a.name.localeCompare(b.name));
346
+ const files: MemoryFile[] = [];
347
+ walkMemory(path, [], files);
348
+ files.sort((a, b) => b.modifiedAt.localeCompare(a.modifiedAt) || a.name.localeCompare(b.name));
325
349
  const index = join(path, 'MEMORY.md');
326
350
  return { files, index: existsSync(index) ? readCapped(index) : null };
327
351
  }
@@ -336,26 +360,23 @@ export function writeMemoryFile(project: string, name: string, text: string, dir
336
360
  if (text.trim() === '') throw new ApiError('a memory file cannot be empty', 400);
337
361
  if (Buffer.byteLength(text, 'utf8') > MEMORY_MAX)
338
362
  throw new ApiError(`a memory file is at most ${String(MEMORY_MAX)} bytes`, 400);
339
- const folder = memoryDir(project, dir);
340
- const file = safeName(name, MEMORY_RE, 'memory file name');
341
- const path = join(folder, file);
363
+ const path = memoryPath(project, name, dir);
342
364
  writeAtomic(path, text, MEMORY_MODE);
343
365
  const stamp = stampOf(modifiedAt);
344
366
  if (stamp !== null) utimesSync(path, stamp, stamp);
345
367
  const stat = statSync(path);
346
- return { name: file, bytes: stat.size, modifiedAt: stat.mtime.toISOString() };
368
+ return { name, bytes: stat.size, modifiedAt: stat.mtime.toISOString() };
347
369
  }
348
370
 
349
371
  export function deleteMemoryFile(project: string, name: string, dir = claudeDir()): string {
350
- const file = safeName(name, MEMORY_RE, 'memory file name');
351
- const path = join(memoryDir(project, dir), file);
372
+ const path = memoryPath(project, name, dir);
352
373
  if (!existsSync(path)) throw new ApiError('no such memory file', 404);
353
374
  rmSync(path);
354
- return file;
375
+ return name;
355
376
  }
356
377
 
357
378
  export function readMemoryFile(project: string, name: string, dir = claudeDir()): string {
358
- const path = join(memoryDir(project, dir), safeName(name, MEMORY_RE, 'memory file name'));
379
+ const path = memoryPath(project, name, dir);
359
380
  if (!existsSync(path)) throw new ApiError('no such memory file', 404);
360
381
  return readCapped(path);
361
382
  }
@@ -1,3 +1,3 @@
1
1
  {
2
- "version": "0.1.0-beta.179"
2
+ "version": "0.1.0-beta.180"
3
3
  }