@stage-labs/metro 0.1.0-beta.234 → 0.1.0-beta.236

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/dist/cli.js CHANGED
@@ -3,7 +3,7 @@ import { whoisAuthorized } from './api.js';
3
3
  import { stopAll } from './control.js';
4
4
  import { tailEvents } from './tail.js';
5
5
  import { launchClaude } from './claude.js';
6
- import { memoryRoutine } from './memory.js';
6
+ import { memoryPaths, memoryRoutine } from './memory.js';
7
7
  import { update } from './update.js';
8
8
  import { serve } from './serve.js';
9
9
  import { service, serviceStopHint } from './service.js';
@@ -32,9 +32,11 @@ const USAGE = `metro: run your agent on this machine
32
32
  open Claude Code with the metro channel and the metro MCP server, its
33
33
  inference routed through the daemon's model gateway (the Model page);
34
34
  every argument is passed through the same
35
- metro memory run the daily memory routine once: when Claude Code transcripts changed
36
- since the last run, Claude Code reads the memory skill and files them into
37
- its memory, with no metro tools and writes only inside the memory folder
35
+ metro memory [skill file]
36
+ run the memory routine once: when Claude Code transcripts changed since
37
+ the last run, Claude Code reads the memory skill (or the file given) and
38
+ files them into its memory, with no metro tools and writes only inside
39
+ the memory folder
38
40
  metro update update to the newest published version (--check only reports)
39
41
  metro version print this CLI's version
40
42
 
@@ -68,7 +70,7 @@ const COMMANDS = {
68
70
  stop: stopDaemon,
69
71
  tail: () => tailEvents(process.argv.slice(3)),
70
72
  claude: () => launchClaude(process.argv.slice(3)),
71
- memory: () => memoryRoutine(),
73
+ memory: () => memoryRoutine(memoryPaths(process.env, process.argv[3])),
72
74
  update: () => update(process.argv.slice(3)),
73
75
  version: async () => {
74
76
  process.stdout.write(`${currentVersion()}\n`);
package/dist/memory.js CHANGED
@@ -46,13 +46,13 @@ function customDir(claude, home) {
46
46
  return undefined;
47
47
  }
48
48
  }
49
- export function memoryPaths(env = process.env) {
49
+ export function memoryPaths(env = process.env, skill) {
50
50
  const home = given(env.HOME) ?? homedir();
51
51
  const claude = claudeDir(env);
52
52
  return {
53
53
  claude,
54
54
  memory: customDir(claude, home) ?? join(projectDir(claude, realHome(home)), 'memory'),
55
- skill: join(claude, 'skills', 'memory', 'SKILL.md'),
55
+ skill: given(skill) ?? join(claude, 'skills', 'memory', 'SKILL.md'),
56
56
  state: join(home, '.metro', 'memory-routine.json'),
57
57
  lock: join(home, '.metro', 'memory-routine.lock'),
58
58
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stage-labs/metro",
3
- "version": "0.1.0-beta.234",
3
+ "version": "0.1.0-beta.236",
4
4
  "description": "The metro command line: run your agent on this machine, relaying its chat channels and connectors to Claude Code.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "metro",
3
- "version": "0.1.0-beta.234",
3
+ "version": "0.1.0-beta.236",
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",
@@ -3,7 +3,7 @@ name: memory
3
3
  description: How this agent keeps its long-term memory in the Instinct layout (people, organizations, facts, preferences, decisions, conversation digests, daily and weekly timelines, active and completed work), the note format and rules, and the daily routine that files each day into it. Use when saving or looking something up in memory, and when the daily memory routine runs.
4
4
  ---
5
5
 
6
- Your memory is a folder of Markdown notes, found with grep and links, not a database. The layout follows Instinct (https://supermemory.ai/blog/reverse-engineering-instinct-memory/). Once a day a scheduled run (the `memory-routine` job on the Scheduled page) reads this skill and files the day into it. You can use the same rules any time you save something.
6
+ Your memory is a folder of Markdown notes, found with grep and links, not a database. The layout follows Instinct (https://supermemory.ai/blog/reverse-engineering-instinct-memory/). Twice a day, at 00:00 and 12:00 UTC, a scheduled run (the `memory-routine` job on the Scheduled page) reads this skill and files what happened into it. You can use the same rules any time you save something.
7
7
 
8
8
  **Where it is.** The auto-memory directory of your Claude Code home folder: `~/.claude/projects/<home folder, with every character that is not a letter or digit turned into ->/memory/` (for `/home/agent` that is `~/.claude/projects/-home-agent/memory/`), or the `autoMemoryDirectory` set in `~/.claude/settings.json`. The daily run names it in its prompt.
9
9
 
@@ -72,7 +72,7 @@ The scheduled run has no chat tools, no shell, no network and no subagents. It c
72
72
  - Work goes into `workstreams/active/`. When it finished, only set its `status` in place (see Rules).
73
73
  - Each chat line gets a digest in `comms/phone/`.
74
74
  - Turn passing anecdotes into general traits. Drop trivia.
75
- 4. Write `timeline/daily/YYYY-MM-DD.md` for each UTC day with activity since the last run: what happened, with links. Things that need the owner go as one line each under a `## Needs <owner's name>` heading, since this run cannot message anyone.
75
+ 4. Write `timeline/daily/YYYY-MM-DD.md` for each UTC day with activity since the last run: what happened, with links. A note an earlier run already wrote gets added to. Things that need the owner go as one line each under a `## Needs <owner's name>` heading, since this run cannot message anyone.
76
76
  5. If the prompt names a previous ISO week whose `timeline/weekly/YYYY-Www.md` does not exist yet, and there are daily notes for that week, write it from those daily notes.
77
77
  6. Tidy up. Merge duplicates into one note, and keep the other slug in its `aliases`. Cut the other note down to its frontmatter and one line: `Merged into [[slug]].` Fix broken `[[links]]` and update `MEMORY.md`.
78
78
  7. End with at most three lines on what changed.
@@ -29,8 +29,8 @@ function textFile(path: string): string | null {
29
29
  }
30
30
 
31
31
  export function scriptOf(command: string): { path: string; text: string } | null {
32
- const words = command.split(/\s+/).filter((w) => w.startsWith('/') && !w.includes('>'));
33
- for (const path of words.slice(0, 3)) {
32
+ const words = command.split(/\s+/).filter((w) => w.startsWith('/') && !w.includes('>')).slice(0, 3);
33
+ for (const path of [...words.filter((w) => w.endsWith('.md')), ...words]) {
34
34
  const text = textFile(path);
35
35
  if (text !== null) return { path, text };
36
36
  }
@@ -1,5 +1,3 @@
1
- import { createHash } from 'node:crypto';
2
- import { hostname } from 'node:os';
3
1
  import { join } from 'node:path';
4
2
  import { errMsg, log } from '@metro-labs/core/log';
5
3
  import { existsSync, readFileSync } from '../agent-user/agent-fs.js';
@@ -10,8 +8,9 @@ import { agentPath, agentUser, type AgentUser } from '../agent-user/user.js';
10
8
 
11
9
  const BEGIN = '# metro memory routine: begin';
12
10
  const END = '# metro memory routine: end';
11
+ const REPLACED = '# replaced by memory-routine: ';
13
12
  const OWN_JOB = /memory/i;
14
- const HOUR = 0;
13
+ const SCHEDULE = '0 0,12 * * *';
15
14
 
16
15
  export type MemoryJobState = 'scheduled' | 'own' | 'off' | 'unavailable';
17
16
 
@@ -25,7 +24,6 @@ export interface MemoryJobDeps {
25
24
  runner?: Runner;
26
25
  cli?: string;
27
26
  port?: string;
28
- seed?: string;
29
27
  }
30
28
 
31
29
  let last: MemoryJob = { state: 'unavailable', job: null };
@@ -33,6 +31,7 @@ let last: MemoryJob = { state: 'unavailable', job: null };
33
31
  export const memoryJobStatus = (): MemoryJob => ({ ...last });
34
32
 
35
33
  export const launcherPath = (user: AgentUser): string => join(user.home, '.metro', 'bin', 'memory-routine');
34
+ export const skillPath = (user: AgentUser): string => join(user.home, '.claude', 'skills', 'memory', 'SKILL.md');
36
35
  const logPath = (user: AgentUser): string => join(user.home, '.metro', 'memory-routine.log');
37
36
  const quoted = (text: string): string => `'${text.replace(/'/g, '\'\\\'\'')}'`;
38
37
 
@@ -43,28 +42,38 @@ export function launcherText(user: AgentUser, cli: string, port: string): string
43
42
  'export PATH',
44
43
  ...(port === '' ? [] : [`METRO_WEBHOOK_PORT=${quoted(port)}`, 'export METRO_WEBHOOK_PORT']),
45
44
  'cd "$HOME" || exit 1',
46
- `exec node ${quoted(cli)} memory`,
45
+ `exec node ${quoted(cli)} memory "$@"`,
47
46
  '',
48
47
  ].join('\n');
49
48
  }
50
49
 
51
- export function cronLine(user: AgentUser, seed: string): string {
52
- const minute = parseInt(createHash('sha256').update(seed).digest('hex').slice(0, 8), 16) % 60;
53
- return `${String(minute)} ${String(HOUR)} * * * ${launcherPath(user)} >> ${logPath(user)} 2>&1`;
54
- }
50
+ export const cronLine = (user: AgentUser): string => `${SCHEDULE} ${launcherPath(user)} ${skillPath(user)} >> ${logPath(user)} 2>&1`;
55
51
 
56
52
  const commandName = (command: string): string => command.split(/\s+/)[0]?.split('/').pop() ?? 'cron';
57
53
 
54
+ const ownCommand = (user: AgentUser, line: string): string | null => {
55
+ const parsed = parseCronLine(line);
56
+ return parsed === null || parsed.command.includes(launcherPath(user)) || !OWN_JOB.test(parsed.command) ? null : parsed.command;
57
+ };
58
+
59
+ const replaces = (user: AgentUser, line: string): boolean => OWN_JOB.test(commandName(ownCommand(user, line) ?? ''));
60
+
58
61
  function ownJob(user: AgentUser, runner: Runner, lines: string[]): string | null {
59
- const launcher = launcherPath(user);
60
62
  const cron = lines.flatMap((line) => {
61
- const parsed = parseCronLine(line);
62
- return parsed === null || parsed.command.includes(launcher) || !OWN_JOB.test(parsed.command) ? [] : [commandName(parsed.command)];
63
+ const command = ownCommand(user, line);
64
+ return command === null || replaces(user, line) ? [] : [commandName(command)];
63
65
  });
64
66
  const timers = listSchedules(null, runner).filter((job) => job.runsAs === user.name && OWN_JOB.test(`${job.name} ${job.command}`));
65
67
  return cron[0] ?? timers[0]?.name ?? null;
66
68
  }
67
69
 
70
+ const restored = (line: string): string => (line.startsWith(REPLACED) ? line.slice(REPLACED.length) : line);
71
+
72
+ function marked(user: AgentUser, lines: string[], on: boolean, scheduled: boolean): string[] {
73
+ if (!on) return lines.map(restored);
74
+ return lines.map((l) => (scheduled && replaces(user, l) ? `${REPLACED}${l}` : l));
75
+ }
76
+
68
77
  function placeLauncher(path: string, text: string): void {
69
78
  if (existsSync(path) && readFileSync(path, 'utf8') === text) return;
70
79
  writeHomeText(path, text, 0o755);
@@ -78,7 +87,6 @@ function resolved(deps: MemoryJobDeps): Required<MemoryJobDeps> {
78
87
  runner: deps.runner ?? realRunner,
79
88
  cli: deps.cli ?? envText('METRO_CLI_BIN'),
80
89
  port: deps.port ?? envText('METRO_WEBHOOK_PORT'),
81
- seed: deps.seed ?? hostname(),
82
90
  };
83
91
  }
84
92
 
@@ -88,7 +96,7 @@ const stateOf = (scheduled: boolean, own: string | null): MemoryJobState => {
88
96
  };
89
97
 
90
98
  export function ensureMemoryJob(on: boolean, deps: MemoryJobDeps = {}): MemoryJob {
91
- const { user, runner, cli, port, seed } = resolved(deps);
99
+ const { user, runner, cli, port } = resolved(deps);
92
100
  if (user === null || cli === '') {
93
101
  last = { state: 'unavailable', job: null };
94
102
  return memoryJobStatus();
@@ -98,10 +106,11 @@ export function ensureMemoryJob(on: boolean, deps: MemoryJobDeps = {}): MemoryJo
98
106
  const scheduled = on && own === null;
99
107
  const launcher = launcherPath(user);
100
108
  if (scheduled) placeLauncher(launcher, launcherText(user, cli, port));
101
- const next = withBlock(lines, BEGIN, END, scheduled ? [cronLine(user, seed)] : [], 'end', (l) => l.includes(launcher));
109
+ const kept = marked(user, lines, on, scheduled);
110
+ const next = withBlock(kept, BEGIN, END, scheduled ? [cronLine(user)] : [], 'end', (l) => l.includes(launcher));
102
111
  const changed = writeCrontab(user, runner, lines, next);
103
112
  last = { state: stateOf(scheduled, own), job: own };
104
- if (changed) log.info({ ...last }, 'memory-routine: updated the daily memory job');
113
+ if (changed) log.info({ ...last, rewritten: kept.filter((l, i) => l !== lines[i]) }, 'memory-routine: updated the memory job');
105
114
  return memoryJobStatus();
106
115
  }
107
116
 
@@ -166,7 +166,7 @@ const PRIOR_STAGE: ReadonlySet<string> = new Set([
166
166
  'e3b8f9bbdb205311bc643aaf91d9d903a91204fd5b55ca949b85cdfa6fd00136',
167
167
  '08a30f5cd5cf8013ab70c1fa9e1109018c0337552296a8efe5c0cf543b284991',
168
168
  ]);
169
- const PRIOR_MEMORY: ReadonlySet<string> = new Set();
169
+ const PRIOR_MEMORY: ReadonlySet<string> = new Set(['08795399635c8cdec2b50cb4e336f89ae880f3997ae52560ef647a9cdc8f1b08']);
170
170
 
171
171
  interface ShippedSkill {
172
172
  name: string;
@@ -1,4 +1,4 @@
1
- import { IdentifierKind } from '@xmtp/node-sdk';
1
+ import { IdentifierKind, type DecodedMessage } from '@xmtp/node-sdk';
2
2
  import { accountForCall, convOf, lineOf, parseLine } from './accounts.js';
3
3
  import { respond } from '@metro-labs/core/stations/station-runtime';
4
4
  import { resolveMsgId } from './wire.js';
@@ -14,6 +14,7 @@ import {
14
14
  groupRemoveMembers,
15
15
  } from './group.js';
16
16
  import { updateChannelMeta } from './actions-meta.js';
17
+ import { deletedByRequests, isDeleteRequest, superAdminCheck } from './delete-requests.js';
17
18
 
18
19
  type Args = Record<string, unknown>;
19
20
  type Handler = (id: string, args: Args) => Promise<void>;
@@ -42,6 +43,17 @@ function upTo<T extends { id: string }>(all: T[], before: string | undefined): T
42
43
  return all.slice(0, at);
43
44
  }
44
45
 
46
+ function textOf(m: DecodedMessage): string {
47
+ try {
48
+ const cc: unknown = m.content;
49
+ return typeof cc === 'string' ? cc : `[${m.contentType?.typeId ?? 'unknown'}]`;
50
+ } catch {
51
+ return `[${m.contentType?.typeId ?? 'unknown'}]`;
52
+ }
53
+ }
54
+
55
+ const DELETED = { text: '[deletedMessage]', contentType: 'deletedMessage' };
56
+
45
57
  async function read(id: string, args: Args): Promise<void> {
46
58
  const { line, limit, before } = args as { line: string; limit?: number; before?: string };
47
59
  const { conv } = await convOf(line);
@@ -50,28 +62,18 @@ async function read(id: string, args: Args): Promise<void> {
50
62
  const lim = Math.min(Math.max(1, limit ?? 20), 200);
51
63
  await conv.sync().catch(() => undefined);
52
64
  const all = await conv.messages();
53
- const slice = upTo(all, before).slice(-lim);
65
+ const deleted = deletedByRequests(all, superAdminCheck(conv));
66
+ const slice = upTo(all.filter((m) => !isDeleteRequest(m)), before).slice(-lim);
54
67
  const parsed = parseLine(line);
55
68
  if (!parsed)
56
69
  throw new TrainError('NOT_FOUND', `could not parse line ${line}`);
57
70
  const acctId = parsed.accountId;
58
- const messages = slice.map((m) => {
59
- let text = '';
60
- try {
61
- const cc: unknown = m.content;
62
- text =
63
- typeof cc === 'string' ? cc : `[${m.contentType?.typeId ?? 'unknown'}]`;
64
- } catch {
65
- text = `[${m.contentType?.typeId ?? 'unknown'}]`;
66
- }
67
- return {
68
- id: m.id,
69
- ts: new Date(Number(m.sentAtNs / 1_000_000n)).toISOString(),
70
- from: `metro://xmtp/${acctId}/user/${m.senderInboxId}`,
71
- text,
72
- contentType: m.contentType?.typeId ?? 'unknown',
73
- };
74
- });
71
+ const messages = slice.map((m) => ({
72
+ id: m.id,
73
+ ts: new Date(Number(m.sentAtNs / 1_000_000n)).toISOString(),
74
+ from: `metro://xmtp/${acctId}/user/${m.senderInboxId}`,
75
+ ...(deleted.has(m.id) ? DELETED : { text: textOf(m), contentType: m.contentType?.typeId ?? 'unknown' }),
76
+ }));
75
77
  respond(id, { result: { line, count: messages.length, messages } });
76
78
  }
77
79
 
@@ -200,6 +200,14 @@ export const frameActionText = (c: FrameActionContent): string => {
200
200
  };
201
201
  const FrameActionCodec = makeJsonCodec<FrameActionContent>(ContentTypeFrameAction, frameActionText);
202
202
 
203
+ export const ContentTypeDeleteRequest: ContentTypeId = {
204
+ authorityId: 'stage.box',
205
+ typeId: 'deleteRequest',
206
+ versionMajor: 1,
207
+ versionMinor: 0,
208
+ };
209
+ const DeleteRequestCodec = makeJsonCodec<{ messageId: string }>(ContentTypeDeleteRequest, () => 'Message deleted');
210
+
203
211
  const ContentTypeDeleteMessage: ContentTypeId = {
204
212
  authorityId: 'xmtp.org',
205
213
  typeId: 'deleteMessage',
@@ -233,4 +241,5 @@ export const CODECS = (): ContentCodec[] => [
233
241
  new SignatureReferenceCodec(),
234
242
  new FrameCodec(),
235
243
  new FrameActionCodec(),
244
+ new DeleteRequestCodec(),
236
245
  ];
@@ -0,0 +1,34 @@
1
+ import type { DecodedMessage } from '@xmtp/node-sdk';
2
+ import { ContentTypeDeleteRequest } from './codecs.js';
3
+
4
+ type Listed = Pick<DecodedMessage, 'id' | 'senderInboxId' | 'contentType' | 'content'>;
5
+
6
+ export const isDeleteRequest = (m: Pick<Listed, 'contentType'>): boolean =>
7
+ m.contentType?.authorityId === ContentTypeDeleteRequest.authorityId &&
8
+ m.contentType.typeId === ContentTypeDeleteRequest.typeId;
9
+
10
+ function targetOf(m: Listed): string | undefined {
11
+ if (!isDeleteRequest(m)) return undefined;
12
+ const id = (m.content as { messageId?: unknown } | undefined)?.messageId;
13
+ return typeof id === 'string' && id ? id : undefined;
14
+ }
15
+
16
+ export function superAdminCheck(conv: unknown): (inboxId: string) => boolean {
17
+ const group = conv as { isSuperAdmin?: (inboxId: string) => boolean };
18
+ return (inboxId) => typeof group.isSuperAdmin === 'function' && group.isSuperAdmin(inboxId);
19
+ }
20
+
21
+ export function deletedByRequests(
22
+ messages: Listed[],
23
+ isSuperAdmin: (inboxId: string) => boolean,
24
+ ): Set<string> {
25
+ const senders = new Map(messages.map((m) => [m.id, m.senderInboxId]));
26
+ const deleted = new Set<string>();
27
+ for (const m of messages) {
28
+ const target = targetOf(m);
29
+ const sender = target === undefined ? undefined : senders.get(target);
30
+ if (target !== undefined && sender !== undefined && (sender === m.senderInboxId || isSuperAdmin(m.senderInboxId)))
31
+ deleted.add(target);
32
+ }
33
+ return deleted;
34
+ }
@@ -23,6 +23,7 @@ const SILENT_TYPES = new Set([
23
23
  'walletSendCalls',
24
24
  'groupUpdated',
25
25
  'group_updated',
26
+ 'deleteRequest',
26
27
  ]);
27
28
 
28
29
  async function bootSync(acct: Account): Promise<void> {
@@ -1,3 +1,3 @@
1
1
  {
2
- "version": "0.1.0-beta.234"
2
+ "version": "0.1.0-beta.236"
3
3
  }