@mnemom/mnemom 0.17.30 → 0.17.31-next.1

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.
@@ -105,6 +105,31 @@ export async function teamShowCommand(teamId, opts) {
105
105
  }
106
106
  console.log();
107
107
  }
108
+ /**
109
+ * What a template write did to the members' SERVED cards (mnemom-platform#2779).
110
+ *
111
+ * Before #2779 this printed "Agents recompose: N" for a count of rows merely
112
+ * FLAGGED for the 15-minute recompose sweep, while `card show` kept serving
113
+ * the stale composed card. The API now recomposes active members in-request
114
+ * and reports `agents_recomposed` / `agents_recompose_pending`; an older API
115
+ * that only flags is reported as queued, never as recomposed.
116
+ */
117
+ function printRecomposeLines(result) {
118
+ if (typeof result.agents_recomposed === "number") {
119
+ const pending = result.agents_recompose_pending ?? 0;
120
+ console.log(` Agents recomposed: ${result.agents_recomposed} ` +
121
+ `(active members of this team; their served card is updated now)`);
122
+ if (pending > 0) {
123
+ console.log(` Agents pending: ${pending} ` +
124
+ `(queued for the recompose sweep, ~15 min; confirm with \`mnemom card show --agent <id>\`)`);
125
+ }
126
+ return;
127
+ }
128
+ if (typeof result.agents_flagged_for_recompose === "number") {
129
+ console.log(` Agents queued: ${result.agents_flagged_for_recompose} ` +
130
+ `(active members of this team only; served cards update on the next recompose sweep, ~15 min)`);
131
+ }
132
+ }
108
133
  export async function teamTemplateCommand(kind, teamId, opts) {
109
134
  await requireAuth();
110
135
  if (!teamId) {
@@ -149,10 +174,7 @@ export async function teamTemplateCommand(kind, teamId, opts) {
149
174
  console.log(` Team ID: ${result.team_id}`);
150
175
  console.log(` Org ID: ${result.org_id}`);
151
176
  console.log(` Enabled: ${result.enabled ? "yes" : "no"}`);
152
- if (typeof result.agents_flagged_for_recompose === "number") {
153
- console.log(` Agents recompose: ${result.agents_flagged_for_recompose} ` +
154
- `(active members of this team only — not blanket fan-out)`);
155
- }
177
+ printRecomposeLines(result);
156
178
  console.log();
157
179
  return;
158
180
  }
@@ -177,9 +199,7 @@ export async function teamTemplateCommand(kind, teamId, opts) {
177
199
  console.log(` Team ID: ${result.team_id}`);
178
200
  console.log(` Org ID: ${result.org_id}`);
179
201
  console.log(` Deleted: yes`);
180
- if (typeof result.agents_flagged_for_recompose === "number") {
181
- console.log(` Agents recompose: ${result.agents_flagged_for_recompose}`);
182
- }
202
+ printRecomposeLines(result);
183
203
  console.log();
184
204
  return;
185
205
  }
package/dist/lib/api.d.ts CHANGED
@@ -470,7 +470,15 @@ export interface TeamTemplateBody {
470
470
  name?: string;
471
471
  template: Record<string, unknown> | null;
472
472
  enabled: boolean;
473
+ /** Active members whose canonical rows were flagged for the recompose sweep. */
473
474
  agents_flagged_for_recompose?: number;
475
+ /**
476
+ * Active members recomposed in-request (mnemom-platform#2779): their served
477
+ * card already reflects this write. Absent on an API that only flags.
478
+ */
479
+ agents_recomposed?: number;
480
+ /** Active members still waiting on the recompose sweep (cap or compose failure). */
481
+ agents_recompose_pending?: number;
474
482
  deleted?: boolean;
475
483
  }
476
484
  export type TeamTemplateKind = "alignment" | "protection";
@@ -1326,10 +1334,16 @@ export interface AgentSessionGuardrails {
1326
1334
  turns: number;
1327
1335
  spend_usd: number;
1328
1336
  stall_run: number;
1337
+ /**
1338
+ * Which ceilings this session has crossed. Under `enforce` a crossing refuses the request
1339
+ * (429) without counting it as a turn — the flag is the record of that refusal (#2782) and
1340
+ * stays set even after the owner raises the ceiling and the session runs on.
1341
+ */
1329
1342
  fired: {
1330
1343
  turns?: boolean;
1331
1344
  budget?: boolean;
1332
1345
  stall?: boolean;
1346
+ time?: boolean;
1333
1347
  };
1334
1348
  } | null;
1335
1349
  }
@@ -1340,14 +1354,20 @@ export interface AgentSessionGuardrails {
1340
1354
  * `false` for a conclusive mechanical allowed/forbidden-paths check, `null` when the contract has
1341
1355
  * no fences or the verdict predates the check. This shape is also what MNE-8234 mirrors — keep
1342
1356
  * the field name and member shape stable.
1357
+ *
1358
+ * `refused` (#2782) is not a judge verdict: it records an enforce-mode ceiling refusal — the
1359
+ * request was turned away (429) and not counted, so `turn` is the turn it would have been and
1360
+ * the counter never reached it; `ceilings` names what was crossed and `fence_breach` is null.
1343
1361
  */
1344
1362
  export interface AgentSessionVerdictTrailEntry {
1345
1363
  request_id: string | null;
1346
1364
  turn: number;
1347
- verdict: "aligned" | "drifting" | "violation";
1365
+ verdict: "aligned" | "drifting" | "violation" | "refused";
1348
1366
  reason: string;
1349
1367
  fence_breach: boolean | null;
1350
1368
  at: string;
1369
+ /** Only on a `refused` entry: the ceilings the refusal named. */
1370
+ ceilings?: Array<"turns" | "budget" | "time">;
1351
1371
  }
1352
1372
  /**
1353
1373
  * The seal's lifecycle (MNE-8149 R2, MNE-8199 D9/R9, MNE-8234/mnemom-api#3541). `live`: a sealed
@@ -0,0 +1,15 @@
1
+ /**
2
+ * lib/slack-fs.ts — the only way the CLI writes a file under ~/.mnemom/slack.
3
+ *
4
+ * A sandboxed agent session is allowed to write a few of these files itself (its own
5
+ * offsets/<session>.off). If it swaps one for a symlink, a plain writeFileSync from the
6
+ * router or channel server — which run outside the sandbox, as the operator — follows
7
+ * the link and truncates whatever it points at. So every write here opens the path with
8
+ * O_NOFOLLOW (a symlink fails with ELOOP instead of being followed), and refuses anything
9
+ * that is not a regular file with a single link (a hard link to another file is the same
10
+ * trick) before a single byte is written or truncated.
11
+ */
12
+ /** Replace `path`'s contents with `data`; never follows a symlink at `path`. */
13
+ export declare function writeFileNoFollow(path: string, data: string, mode?: number): void;
14
+ /** Append `data` to `path`; never follows a symlink at `path`. */
15
+ export declare function appendFileNoFollow(path: string, data: string, mode?: number): void;
@@ -0,0 +1,36 @@
1
+ /**
2
+ * lib/slack-fs.ts — the only way the CLI writes a file under ~/.mnemom/slack.
3
+ *
4
+ * A sandboxed agent session is allowed to write a few of these files itself (its own
5
+ * offsets/<session>.off). If it swaps one for a symlink, a plain writeFileSync from the
6
+ * router or channel server — which run outside the sandbox, as the operator — follows
7
+ * the link and truncates whatever it points at. So every write here opens the path with
8
+ * O_NOFOLLOW (a symlink fails with ELOOP instead of being followed), and refuses anything
9
+ * that is not a regular file with a single link (a hard link to another file is the same
10
+ * trick) before a single byte is written or truncated.
11
+ */
12
+ import { closeSync, constants, fstatSync, ftruncateSync, openSync, writeSync } from "node:fs";
13
+ // O_NOFOLLOW is undefined on Windows, where creating symlinks needs elevation anyway.
14
+ const NOFOLLOW = constants.O_NOFOLLOW ?? 0;
15
+ function writeNoFollow(path, data, flags, truncate, mode) {
16
+ const fd = openSync(path, flags | NOFOLLOW, mode);
17
+ try {
18
+ const st = fstatSync(fd);
19
+ if (!st.isFile() || st.nlink !== 1)
20
+ throw new Error(`refusing to write ${path}: not a regular file with a single link`);
21
+ if (truncate)
22
+ ftruncateSync(fd, 0);
23
+ writeSync(fd, data);
24
+ }
25
+ finally {
26
+ closeSync(fd);
27
+ }
28
+ }
29
+ /** Replace `path`'s contents with `data`; never follows a symlink at `path`. */
30
+ export function writeFileNoFollow(path, data, mode = 0o600) {
31
+ writeNoFollow(path, data, constants.O_WRONLY | constants.O_CREAT, true, mode);
32
+ }
33
+ /** Append `data` to `path`; never follows a symlink at `path`. */
34
+ export function appendFileNoFollow(path, data, mode = 0o600) {
35
+ writeNoFollow(path, data, constants.O_WRONLY | constants.O_CREAT | constants.O_APPEND, false, mode);
36
+ }
@@ -8,12 +8,13 @@
8
8
  * `--dangerously-load-development-channels`, which shows a confirmation dialog;
9
9
  * orgs on Team/Enterprise must also enable `channelsEnabled` in managed settings.
10
10
  */
11
- import { mkdirSync, writeFileSync } from "node:fs";
11
+ import { mkdirSync } from "node:fs";
12
12
  import { join } from "node:path";
13
13
  import { SESSION_NAME_ENV } from "./agent.js";
14
14
  import { ensureRouter } from "./slack-router.js";
15
15
  import { slackDir } from "./slack-state.js";
16
16
  import { normSession } from "./slack-route.js";
17
+ import { writeFileNoFollow } from "./slack-fs.js";
17
18
  export const SLACK_CHANNEL_SERVER = "mnemom-slack";
18
19
  /** The MCP config document for `session` (pure; unit-tested). */
19
20
  export function slackMcpConfig(session, cli) {
@@ -31,7 +32,7 @@ export function slackMcpConfig(session, cli) {
31
32
  export function slackChannelArgs(session, cli = [process.execPath, process.argv[1] ?? "mnemom"]) {
32
33
  mkdirSync(slackDir(), { recursive: true, mode: 0o700 });
33
34
  const path = join(slackDir(), `mcp-${normSession(session)}.json`);
34
- writeFileSync(path, JSON.stringify(slackMcpConfig(session, cli), null, 2), { mode: 0o600 });
35
+ writeFileNoFollow(path, JSON.stringify(slackMcpConfig(session, cli), null, 2));
35
36
  ensureRouter(cli);
36
37
  return [
37
38
  "--mcp-config",
@@ -12,11 +12,12 @@
12
12
  * in-memory user-name cache.
13
13
  */
14
14
  import { spawn } from "node:child_process";
15
- import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
15
+ import { existsSync, readFileSync, mkdirSync } from "node:fs";
16
16
  import { join } from "node:path";
17
17
  import { fetchThread, formatThread, messageBody, slackCall, } from "./slack.js";
18
18
  import { buildThreadIndex, normSession, routeHits, stripAddressing, } from "./slack-route.js";
19
19
  import { appendInbox, inboxSize, listAliases, liveSessions, PENDING_TTL_MS, readAliases, readPosts, readThreadRecords, recordPending, recordRoute, settlePending, slackDir, } from "./slack-state.js";
20
+ import { writeFileNoFollow } from "./slack-fs.js";
20
21
  /** How often the router settles held messages (delivered / expired). */
21
22
  export const PENDING_SWEEP_MS = 30_000;
22
23
  const ttlText = () => `${Math.round(PENDING_TTL_MS / 60_000)} min`;
@@ -92,7 +93,7 @@ export async function runRouter(opts) {
92
93
  if (other && other !== process.pid)
93
94
  throw new Error(`a router is already running (pid ${other}).`);
94
95
  mkdirSync(slackDir(), { recursive: true, mode: 0o700 });
95
- writeFileSync(routerPidFile(), String(process.pid), { mode: 0o600 });
96
+ writeFileNoFollow(routerPidFile(), String(process.pid));
96
97
  log(`aliases ${aliasesOn ? "on" : "off"}, sticky threads ${stickyOn ? "on" : "off"}`);
97
98
  if (aliasesOn) {
98
99
  const sweep = setInterval(() => {
@@ -15,9 +15,10 @@
15
15
  * each session's listener is the only reader of its own inbox. No secret is
16
16
  * stored here.
17
17
  */
18
- import { appendFileSync, closeSync, existsSync, fstatSync, openSync, readSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync, } from "node:fs";
18
+ import { closeSync, existsSync, fstatSync, openSync, readSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, } from "node:fs";
19
19
  import { join } from "node:path";
20
20
  import { MNEMOM_DIR } from "./config.js";
21
+ import { appendFileNoFollow, writeFileNoFollow } from "./slack-fs.js";
21
22
  import { normHandle, normSession, reresolveRoutes, } from "./slack-route.js";
22
23
  export function slackDir() {
23
24
  return join(MNEMOM_DIR, "slack");
@@ -59,14 +60,14 @@ function readJsonl(path) {
59
60
  }
60
61
  export function recordPost(p) {
61
62
  ensure(slackDir());
62
- appendFileSync(postsFile(), JSON.stringify(p) + "\n", { mode: 0o600 });
63
+ appendFileNoFollow(postsFile(), JSON.stringify(p) + "\n");
63
64
  }
64
65
  export function readPosts() {
65
66
  return readJsonl(postsFile()).slice(-MAX_POSTS);
66
67
  }
67
68
  export function recordRoute(r) {
68
69
  ensure(slackDir());
69
- appendFileSync(routesFile(), JSON.stringify(r) + "\n", { mode: 0o600 });
70
+ appendFileNoFollow(routesFile(), JSON.stringify(r) + "\n");
70
71
  }
71
72
  /** Sticky routes still within their TTL at `now`, oldest first (capped like posts). */
72
73
  export function readRoutes(now = Date.now()) {
@@ -112,9 +113,7 @@ export function freshInbox(messages, now = Date.now()) {
112
113
  const fileFor = (session) => `${normSession(session)}`;
113
114
  export function appendInbox(session, msg) {
114
115
  ensure(inboxDir());
115
- appendFileSync(join(inboxDir(), `${fileFor(session)}.jsonl`), JSON.stringify(msg) + "\n", {
116
- mode: 0o600,
117
- });
116
+ appendFileNoFollow(join(inboxDir(), `${fileFor(session)}.jsonl`), JSON.stringify(msg) + "\n");
118
117
  }
119
118
  export function inboxPath(session) {
120
119
  return join(inboxDir(), `${fileFor(session)}.jsonl`);
@@ -142,7 +141,7 @@ export function readOffset(session) {
142
141
  /** `session` has read its inbox up to byte `offset`. */
143
142
  export function writeOffset(session, offset) {
144
143
  ensure(offsetDir());
145
- writeFileSync(offsetFile(session), String(offset), { mode: 0o600 });
144
+ writeFileNoFollow(offsetFile(session), String(offset));
146
145
  }
147
146
  /** True when `session` has saved an offset at all (it has read its inbox before). */
148
147
  export function hasOffset(session) {
@@ -150,7 +149,7 @@ export function hasOffset(session) {
150
149
  }
151
150
  export function recordPending(p) {
152
151
  ensure(slackDir());
153
- appendFileSync(pendingFile(), JSON.stringify(p) + "\n", { mode: 0o600 });
152
+ appendFileNoFollow(pendingFile(), JSON.stringify(p) + "\n");
154
153
  }
155
154
  export function readPending() {
156
155
  return readJsonl(pendingFile());
@@ -198,9 +197,7 @@ export function settlePending(now = Date.now()) {
198
197
  else
199
198
  held.push(p);
200
199
  }
201
- writeFileSync(pendingFile(), held.map((p) => JSON.stringify(p) + "\n").join(""), {
202
- mode: 0o600,
203
- });
200
+ writeFileNoFollow(pendingFile(), held.map((p) => JSON.stringify(p) + "\n").join(""));
204
201
  return { delivered, expired, held };
205
202
  }
206
203
  /** Messages appended after byte `offset`, and the new offset. */
@@ -246,7 +243,7 @@ export function readInboxFrom(session, offset) {
246
243
  }
247
244
  export function registerLive(session, pid = process.pid) {
248
245
  ensure(liveDir());
249
- writeFileSync(join(liveDir(), `${fileFor(session)}.json`), JSON.stringify({ session, pid, since: new Date().toISOString() }), { mode: 0o600 });
246
+ writeFileNoFollow(join(liveDir(), `${fileFor(session)}.json`), JSON.stringify({ session, pid, since: new Date().toISOString() }));
250
247
  }
251
248
  export function unregisterLive(session) {
252
249
  rmSync(join(liveDir(), `${fileFor(session)}.json`), { force: true });
@@ -280,7 +277,7 @@ export function setAlias(handle, session) {
280
277
  session: s,
281
278
  since: prev && prev.session === s ? prev.since : new Date().toISOString(),
282
279
  };
283
- writeFileSync(path, JSON.stringify(rec), { mode: 0o600 });
280
+ writeFileNoFollow(path, JSON.stringify(rec));
284
281
  return rec;
285
282
  }
286
283
  /** Remove `handle`; true when something was removed. */
@@ -70,7 +70,13 @@ export function isPromptEntry(entry) {
70
70
  * The origin Claude Code recorded for a prompt entry, or null when it recorded none we know
71
71
  * (then the entry is not stated at all, and the gateway reads that text as before).
72
72
  * Precedence: summary / transcript-only → meta; channel; scheduled; peer; task; isMeta → meta;
73
- * a human origin or a typed / queued prompt → human.
73
+ * a human origin or a typed / queued prompt → human; an SDK prompt → human.
74
+ *
75
+ * SDK prompts: under the Agent SDK (and `claude -p`) Claude Code records the caller's own
76
+ * prompts as promptSource / turnOrigin "sdk". The caller is the operator driving the session,
77
+ * so its prompt is the user's turn. Every non-user kind (wakeup, task notification, peer,
78
+ * hook or skill text, compaction summary) is matched above it first, so it keeps its own
79
+ * origin in SDK mode too; `source=sdk` still tells the gateway which surface the turn came from.
74
80
  */
75
81
  export function entryOrigin(entry) {
76
82
  if (!entry) return null;
@@ -85,6 +91,7 @@ export function entryOrigin(entry) {
85
91
  if (entry.isMeta === true || turn === "system") return "meta";
86
92
  if (turn === "human" || kind === "human") return "human";
87
93
  if (entry.promptSource === "typed" || entry.promptSource === "queued") return "human";
94
+ if (entry.promptSource === "sdk" || turn === "sdk") return "human";
88
95
  return null;
89
96
  }
90
97
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mnemom/mnemom",
3
- "version": "0.17.30",
3
+ "version": "0.17.31-next.1",
4
4
  "description": "Transparent AI agent tracing",
5
5
  "type": "module",
6
6
  "bin": {
@@ -26,8 +26,9 @@ Mnemom gateway:
26
26
 
27
27
  This plugin is the visible part: the band above the prompt, the status pane, the ◆ sign on the
28
28
  spinner and the turn line, Mnemom trailers in commit attribution, a governance section in the
29
- system prompt, and a guard that denies Bash commands which would point `ANTHROPIC_BASE_URL`,
30
- `ANTHROPIC_AUTH_TOKEN` or `ANTHROPIC_CUSTOM_HEADERS` away from the gateway.
29
+ system prompt, and a guard that denies Bash commands which plainly set or unset
30
+ `ANTHROPIC_BASE_URL`, `ANTHROPIC_AUTH_TOKEN` or `ANTHROPIC_CUSTOM_HEADERS` (a text match on the
31
+ command, not a sandbox).
31
32
 
32
33
  A session that was NOT launched by `mnemom agent` shows an amber "not governed" band. Nothing is
33
34
  recorded for it. Tell the person to launch with `mnemom agent` (install: `npm i -g @mnemom/mnemom`,
@@ -79,9 +80,10 @@ server at `https://docs.mnemom.ai/mcp` answers questions from the published docs
79
80
 
80
81
  - **"Why is there a ◆ on the spinner?"** The session is governed; every model call is being
81
82
  checkpointed through the Mnemom gateway. `/mnemom status` shows by whom.
82
- - **"Can I turn the gateway off for one command?"** Not inside this session: the plugin denies
83
- it, by design. Use another terminal, or `mnemom agent --no-plugin` for a session without the
84
- guard (the routing itself still comes from the launcher).
83
+ - **"Can I turn the gateway off for one command?"** Not from this session: the plugin denies
84
+ commands that set or unset those variables, by design, and you must not work around it. Use
85
+ another terminal, or `mnemom agent --no-plugin` for a session without the guard (the routing
86
+ itself still comes from the launcher).
85
87
  - **"Is my score good?"** Grades A and up are strong; NR means not enough history yet, not a
86
88
  bad score. Point them at the public page for the breakdown.
87
89
  - **"Who sees what?"** The public rating and its chain attestation are public. Alignment and