@mnemom/mnemom 0.17.4-next.5 → 0.17.4-next.7
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 +19 -0
- package/dist/commands/agent-config.js +3 -0
- package/dist/commands/agent-setup.d.ts +9 -0
- package/dist/commands/agent-setup.js +56 -15
- package/dist/commands/agent.d.ts +38 -4
- package/dist/commands/agent.js +117 -18
- package/dist/commands/wrap.d.ts +3 -1
- package/dist/commands/wrap.js +7 -2
- package/dist/index.js +11 -0
- package/dist/lib/agent.d.ts +55 -11
- package/dist/lib/agent.js +68 -11
- package/dist/lib/experimental.d.ts +3 -2
- package/dist/lib/experimental.js +22 -2
- package/dist/lib/launcher-config.d.ts +7 -1
- package/dist/lib/launcher-config.js +19 -1
- package/dist/lib/served-agent-cache.d.ts +18 -0
- package/dist/lib/served-agent-cache.js +50 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -129,6 +129,25 @@ mnemom agent config set context_handoff off # back to the default
|
|
|
129
129
|
This is experimental: it only ever ADDS the one tool (no gateway behavior changes for a
|
|
130
130
|
session that leaves the setting off, and no change to Claude Code's own `/compact`).
|
|
131
131
|
|
|
132
|
+
#### Compaction signal and intercept
|
|
133
|
+
|
|
134
|
+
Every launch shape (terminal, `-- -p …` headless, `--remote-control`, `--server`) starts Claude
|
|
135
|
+
Code with `CLAUDE_CODE_GATEWAY_HINT_HEADERS=1`, so its requests carry the
|
|
136
|
+
`x-claude-code-request-class` / `x-claude-code-compaction` hint headers, which mark exactly
|
|
137
|
+
which request is a compaction (a gateway that does not read them ignores them). A value you set yourself (even `0`) wins. The launcher
|
|
138
|
+
never sets or changes your own auto-compaction settings (`CLAUDE_AUTOCOMPACT_PCT_OVERRIDE`,
|
|
139
|
+
`CLAUDE_CODE_AUTO_COMPACT_WINDOW`, or `autoCompactWindow` in settings.json).
|
|
140
|
+
|
|
141
|
+
**Experimental, off by default.** `--compaction-intercept` (after `mnemom experimental enable
|
|
142
|
+
compaction-intercept`; standing default `mnemom agent config set compaction_intercept on`, or
|
|
143
|
+
`MNEMOM_AGENT_COMPACTION_INTERCEPT=1`) sends `GATEWAY_USAGE_REAL_SIZE=on` and
|
|
144
|
+
`GATEWAY_COMPACTION_INTERCEPT=on` in the per-session config header, asking the gateway to report
|
|
145
|
+
the real context size (so Claude Code compacts at its own threshold) and to answer that
|
|
146
|
+
compaction at once from its fold instead of a blocking model call. This needs the companion
|
|
147
|
+
gateway support: a gateway without it drops both keys (they are echoed back in
|
|
148
|
+
`x-mnemom-session-config-dropped`) and the session behaves exactly as with the setting off. `--no-compaction-intercept` turns it off for
|
|
149
|
+
one launch.
|
|
150
|
+
|
|
132
151
|
## How It Works
|
|
133
152
|
|
|
134
153
|
```
|
|
@@ -193,6 +193,9 @@ function deleteKey(cfg, key) {
|
|
|
193
193
|
case "context_handoff":
|
|
194
194
|
delete cfg.contextHandoff;
|
|
195
195
|
break;
|
|
196
|
+
case "compaction_intercept":
|
|
197
|
+
delete cfg.compactionIntercept;
|
|
198
|
+
break;
|
|
196
199
|
case "context_profile":
|
|
197
200
|
delete cfg.contextProfile;
|
|
198
201
|
break;
|
|
@@ -74,11 +74,20 @@ export interface EnsureAgentOptions {
|
|
|
74
74
|
gatewayHost: string;
|
|
75
75
|
/** Override interactivity (defaults to isInteractive()). */
|
|
76
76
|
interactive?: boolean;
|
|
77
|
+
/** Where the served-agent cache lives (defaults to ~/.mnemom). */
|
|
78
|
+
cacheDir?: string;
|
|
77
79
|
}
|
|
80
|
+
/** A bare launch never waits on the served-agent probe longer than this. */
|
|
81
|
+
export declare const SERVED_PROBE_TIMEOUT_MS = 10000;
|
|
78
82
|
export interface EnsureAgentResult {
|
|
79
83
|
slug: string;
|
|
80
84
|
/** true only when this call actually birthed/claimed/published. */
|
|
81
85
|
provisioned: boolean;
|
|
86
|
+
/**
|
|
87
|
+
* The mnm- id the gateway serves for (key, slug), when this call learned it (a verified name
|
|
88
|
+
* match, a mismatch, or a fresh birth). Absent when it never asked the gateway.
|
|
89
|
+
*/
|
|
90
|
+
agentId?: string;
|
|
82
91
|
}
|
|
83
92
|
/**
|
|
84
93
|
* Ensure `slug` is a usable governed agent in the customer's org. Returns
|
|
@@ -26,11 +26,12 @@
|
|
|
26
26
|
import { claimAgent, getAgentByName, getMe, getMuBalance, getMyPersonalOrg, MnemomApiError, mintProtectionCapability, putAlignmentCard, putProtectionCard, } from "../lib/api.js";
|
|
27
27
|
import { getAuthInfo, isLoggedIn, loginWithBrowser, loginWithDeviceFlow, resolveAuth, } from "../lib/auth.js";
|
|
28
28
|
import { getActiveOrg } from "../lib/cli-config.js";
|
|
29
|
-
import { getApiUrl, getGatewayUrl, getWebsiteUrl } from "../lib/config.js";
|
|
29
|
+
import { getApiUrl, getGatewayUrl, getWebsiteUrl, MNEMOM_DIR } from "../lib/config.js";
|
|
30
30
|
import { fmt } from "../lib/format.js";
|
|
31
31
|
import { askYesNo, isInteractive } from "../lib/prompt.js";
|
|
32
32
|
import { resolveAnthropicDoor, resolveGatewayHost, sanitizeAgentName, singleCellError, } from "../lib/agent.js";
|
|
33
33
|
import { configKeySpec, loadLauncherConfig, writeLauncherConfig } from "../lib/launcher-config.js";
|
|
34
|
+
import { readServedAgent, servedFingerprint, writeServedAgent } from "../lib/served-agent-cache.js";
|
|
34
35
|
import { deriveHashProof } from "./agents.js";
|
|
35
36
|
import { birthThroughGateway } from "./wrap.js";
|
|
36
37
|
import { resolveAnthropicKey } from "./agent.js";
|
|
@@ -104,6 +105,8 @@ export class AgentIdentityMismatchError extends Error {
|
|
|
104
105
|
this.name = "AgentIdentityMismatchError";
|
|
105
106
|
}
|
|
106
107
|
}
|
|
108
|
+
/** A bare launch never waits on the served-agent probe longer than this. */
|
|
109
|
+
export const SERVED_PROBE_TIMEOUT_MS = 10_000;
|
|
107
110
|
/**
|
|
108
111
|
* Ensure `slug` is a usable governed agent in the customer's org. Returns
|
|
109
112
|
* {provisioned:false} when it is already usable, when provisioning is not
|
|
@@ -115,33 +118,61 @@ export class AgentIdentityMismatchError extends Error {
|
|
|
115
118
|
*/
|
|
116
119
|
export async function ensureGovernedAgent(opts) {
|
|
117
120
|
const slug = opts.slug;
|
|
121
|
+
const cacheDir = opts.cacheDir ?? MNEMOM_DIR;
|
|
118
122
|
const interactive = opts.interactive ?? isInteractive();
|
|
119
123
|
// 1. Already usable? A named agent in the caller's fleet is claimed by them.
|
|
120
124
|
const loggedIn = await isLoggedIn();
|
|
121
125
|
if (loggedIn) {
|
|
122
126
|
const existing = await getAgentByName(slug).catch(() => null);
|
|
123
127
|
if (existing) {
|
|
128
|
+
// MNE-7650: the name is client-controlled and the API exposes no
|
|
129
|
+
// agent_hash, so a name match proves nothing about which agent the
|
|
130
|
+
// gateway will serve for (key, slug) — it looks agents up by
|
|
131
|
+
// hash(credential, name) only. Ask the gateway on EVERY launch, not just
|
|
132
|
+
// under --setup: the same 16-token "hello" the birth path uses answers
|
|
133
|
+
// with the served mnm- id on x-mnemom-agent. A bare launch that trusted
|
|
134
|
+
// the name recorded and reported an org agent while the gateway governed
|
|
135
|
+
// a different, unclaimed one (a second Anthropic key on a shared name).
|
|
136
|
+
//
|
|
137
|
+
// The answer is stable per (key, name, gateway), so a bare launch reuses a cached one and
|
|
138
|
+
// only probes (time-boxed) the first time; --setup always re-asks.
|
|
139
|
+
const gatewayUrl = opts.gatewayHost.replace(/\/+$/, "");
|
|
140
|
+
const fp = servedFingerprint(opts.anthropicKey, slug, gatewayUrl);
|
|
141
|
+
const cached = opts.setup === true ? null : readServedAgent(cacheDir, fp);
|
|
142
|
+
let served;
|
|
143
|
+
try {
|
|
144
|
+
served =
|
|
145
|
+
cached ??
|
|
146
|
+
(await birthThroughGateway(gatewayUrl, "anthropic", slug, opts.anthropicKey, {
|
|
147
|
+
timeoutMs: opts.setup === true ? undefined : SERVED_PROBE_TIMEOUT_MS,
|
|
148
|
+
}));
|
|
149
|
+
}
|
|
150
|
+
catch (err) {
|
|
151
|
+
// --setup must know which agent it governs; a bare launch carries on
|
|
152
|
+
// with the id unresolved rather than guessing it from the name.
|
|
153
|
+
if (opts.setup === true)
|
|
154
|
+
throw err;
|
|
155
|
+
say(fmt.dim(`mnemom agent: couldn't confirm which agent the gateway serves for '${slug}' (${err instanceof Error ? err.message : String(err)}) — launching anyway.`));
|
|
156
|
+
return { slug, provisioned: false };
|
|
157
|
+
}
|
|
158
|
+
if (!cached)
|
|
159
|
+
writeServedAgent(cacheDir, fp, served);
|
|
160
|
+
if (served !== existing.id) {
|
|
161
|
+
if (opts.setup === true)
|
|
162
|
+
throw new AgentIdentityMismatchError(slug, existing.id, served);
|
|
163
|
+
warnServedMismatch(slug, existing.id, served);
|
|
164
|
+
return { slug, provisioned: false, agentId: served };
|
|
165
|
+
}
|
|
124
166
|
// On an EXPLICIT `mnemom agent setup`, repair/complete the cards on the
|
|
125
167
|
// already-claimed agent (e.g. a protection card that failed on a prior run
|
|
126
168
|
// before we minted its one-time grant). A bare launch leaves it alone.
|
|
127
169
|
if (opts.setup === true) {
|
|
128
|
-
// MNE-7650: the name is client-controlled and the API exposes no
|
|
129
|
-
// agent_hash, so a name match proves nothing about which agent the
|
|
130
|
-
// gateway will serve for (key, slug) — it looks agents up by
|
|
131
|
-
// hash(credential, name) only. Ask the gateway: the same 16-token
|
|
132
|
-
// "hello" the birth path uses answers with the served mnm- id on
|
|
133
|
-
// x-mnemom-agent. Govern that agent or none.
|
|
134
|
-
const gatewayUrl = opts.gatewayHost.replace(/\/+$/, "");
|
|
135
|
-
const served = await birthThroughGateway(gatewayUrl, "anthropic", slug, opts.anthropicKey);
|
|
136
|
-
if (served !== existing.id) {
|
|
137
|
-
throw new AgentIdentityMismatchError(slug, existing.id, served);
|
|
138
|
-
}
|
|
139
170
|
say(fmt.dim(`mnemom agent: '${slug}' is already claimed (${existing.id}, the agent the gateway serves) — refreshing its cards.`));
|
|
140
171
|
await publishDefaultCards(existing.id);
|
|
141
172
|
persistAgentSlug(slug);
|
|
142
|
-
return { slug, provisioned: true };
|
|
173
|
+
return { slug, provisioned: true, agentId: existing.id };
|
|
143
174
|
}
|
|
144
|
-
return { slug, provisioned: false };
|
|
175
|
+
return { slug, provisioned: false, agentId: existing.id };
|
|
145
176
|
}
|
|
146
177
|
}
|
|
147
178
|
// 2. Are we allowed to provision here?
|
|
@@ -186,7 +217,17 @@ export async function ensureGovernedAgent(opts) {
|
|
|
186
217
|
await publishDefaultCards(agentId);
|
|
187
218
|
// 6. Remember the slug so future launches skip straight to usable.
|
|
188
219
|
persistAgentSlug(slug);
|
|
189
|
-
return { slug, provisioned: true };
|
|
220
|
+
return { slug, provisioned: true, agentId };
|
|
221
|
+
}
|
|
222
|
+
/**
|
|
223
|
+
* A bare launch whose name matches an org agent the gateway does NOT serve for this key. Not
|
|
224
|
+
* fatal (no --setup asked to govern it), but the user must know their governance lands on the
|
|
225
|
+
* served agent, and how to get a governed one of their own.
|
|
226
|
+
*/
|
|
227
|
+
function warnServedMismatch(slug, namedId, servedId) {
|
|
228
|
+
say(fmt.warn(`mnemom agent: '${slug}' in your org is ${namedId}, but the gateway serves ${servedId} for this Anthropic key + name — this session is governed as ${servedId}, which is not claimed to your org.`));
|
|
229
|
+
say(fmt.dim(" The gateway identity is hash(Anthropic key, name), so another key on the same name is a different agent. " +
|
|
230
|
+
`Use a name of your own: \`mnemom agent <scenario> --agent <unique-name> --setup\` (or claim the served one: \`mnemom agent claim ${servedId} --key <this key>\`).`));
|
|
190
231
|
}
|
|
191
232
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
192
233
|
/**
|
package/dist/commands/agent.d.ts
CHANGED
|
@@ -79,6 +79,13 @@ export interface AgentOptions {
|
|
|
79
79
|
* ⇒ no header key, no MCP server — the launch is byte-identical to a build without this flag.
|
|
80
80
|
*/
|
|
81
81
|
contextHandoff?: boolean;
|
|
82
|
+
/**
|
|
83
|
+
* EXPERIMENTAL, off by default (payload-bound fix): tells the gateway
|
|
84
|
+
* `GATEWAY_USAGE_REAL_SIZE=on` + `GATEWAY_COMPACTION_INTERCEPT=on` for this session only (real
|
|
85
|
+
* context size in usage, so Claude Code compacts at its OWN threshold; the compaction request
|
|
86
|
+
* answered at once from the gateway's fold). Absent/false ⇒ no header key.
|
|
87
|
+
*/
|
|
88
|
+
compactionIntercept?: boolean;
|
|
82
89
|
/**
|
|
83
90
|
* Governed-agent provisioning: undefined = AUTO (provision-with-confirm when
|
|
84
91
|
* interactive, warn-and-continue otherwise), true = force (`--setup`), false =
|
|
@@ -116,11 +123,30 @@ export declare function passthroughResumeId(passthrough: readonly string[]): str
|
|
|
116
123
|
* - `-- --resume <cc-session-id>` → the matching row that pinned that Claude Code session
|
|
117
124
|
* (else the newest matching row), reused without asking;
|
|
118
125
|
* - no `--resume` → "Resume previous session <name>? [Y/n]", default Y; Y reuses that row's
|
|
119
|
-
* conversation, n mints a new one.
|
|
120
|
-
* resumes inside it with /resume). No TTY: Y.
|
|
126
|
+
* conversation, n mints a new one. No TTY: Y.
|
|
121
127
|
* The ledger read is best-effort: a failure means a new id.
|
|
122
128
|
*/
|
|
123
|
-
export declare function resolveLaunchConversationId(input:
|
|
129
|
+
export declare function resolveLaunchConversationId(input: LaunchContinuationInput): Promise<string>;
|
|
130
|
+
/**
|
|
131
|
+
* Whether Claude Code still has the transcript of session `id`
|
|
132
|
+
* (`${CLAUDE_CONFIG_DIR:-~/.claude}/projects/<any project>/<id>.jsonl`; `--resume` finds it in any
|
|
133
|
+
* project). Claude Code exits on `--resume` of an id it has no transcript for, so a relaunch only
|
|
134
|
+
* resumes one that is there. Best-effort: an unreadable directory or a non-UUID id → false.
|
|
135
|
+
*/
|
|
136
|
+
export declare function ccSessionTranscriptExists(id: string, env: NodeJS.ProcessEnv): boolean;
|
|
137
|
+
/**
|
|
138
|
+
* resolveLaunchConversationId, plus the Claude Code session to resume. A Y (or no-TTY) relaunch
|
|
139
|
+
* of an earlier implicit launch also resumes the CC session that launch pinned (`--resume <id>`),
|
|
140
|
+
* when the caller passed no session flag of their own: the gateway gives the conversation's main
|
|
141
|
+
* goal to ONE CC session id, and a fresh `--session-id` cannot reuse it (CC refuses an id already
|
|
142
|
+
* in use), so without the resume the relaunch would run on a new, empty thread. An n, a new
|
|
143
|
+
* conversation, or the caller's own `--resume` / `--session-id` / `--continue` resume nothing.
|
|
144
|
+
*/
|
|
145
|
+
export declare function resolveLaunchContinuation(input: LaunchContinuationInput): Promise<{
|
|
146
|
+
conversationId: string;
|
|
147
|
+
resumeCcSessionId: string | null;
|
|
148
|
+
}>;
|
|
149
|
+
interface LaunchContinuationInput {
|
|
124
150
|
session: string;
|
|
125
151
|
orgId: string;
|
|
126
152
|
agent: string;
|
|
@@ -131,7 +157,9 @@ export declare function resolveLaunchConversationId(input: {
|
|
|
131
157
|
interactive: boolean;
|
|
132
158
|
records: () => SessionRecord[];
|
|
133
159
|
askYesNo: (question: string, defaultYes: boolean) => Promise<boolean>;
|
|
134
|
-
|
|
160
|
+
/** Whether Claude Code still has that session's transcript (ccSessionTranscriptExists). */
|
|
161
|
+
ccSessionExists: (ccSessionId: string) => boolean;
|
|
162
|
+
}
|
|
135
163
|
/**
|
|
136
164
|
* Record this launch in ~/.mnemom/sessions.jsonl. The agent record id is
|
|
137
165
|
* resolved best-effort (a lookup failure or an unknown slug records null); a
|
|
@@ -141,6 +169,11 @@ export declare function recordSessionLaunch(ctx: {
|
|
|
141
169
|
session: string;
|
|
142
170
|
conversationId: string;
|
|
143
171
|
agent: string;
|
|
172
|
+
/**
|
|
173
|
+
* The id the gateway serves, as setup learned it (null = asked, unknown). Undefined = setup
|
|
174
|
+
* never asked, so fall back to the org's name lookup.
|
|
175
|
+
*/
|
|
176
|
+
agentId?: string | null;
|
|
144
177
|
account: {
|
|
145
178
|
orgId: string;
|
|
146
179
|
orgLabel: string;
|
|
@@ -313,3 +346,4 @@ export declare function printPlan(ctx: {
|
|
|
313
346
|
/** The experimental features on for this launch (the header's `experimental` array). */
|
|
314
347
|
experimental?: readonly string[];
|
|
315
348
|
}): void;
|
|
349
|
+
export {};
|
package/dist/commands/agent.js
CHANGED
|
@@ -43,12 +43,14 @@ import { askInput, askSelect, askYesNo, isInteractive } from "../lib/prompt.js";
|
|
|
43
43
|
import { authConfigured, chooseAuth, detectClaudeLogin, readClaudeLoginState, } from "../lib/login-detect.js";
|
|
44
44
|
import { goalAlignmentLine, resolveSessionSetup } from "./agent-session.js";
|
|
45
45
|
import { AgentIdentityMismatchError, defaultAgentSlug, ensureGovernedAgent, ensureSessionAndBalance, isQuietPreambleExit, resolveDefaultSlug, resolvePreflightedAccount, } from "./agent-setup.js";
|
|
46
|
-
import { assembleContract, buildGoalModeHeader, buildCustomHeaders, buildHandoffMcpConfigJson, buildSessionConfig, buildSessionConfigB64, resolveContextHandoff, resolveContextProfile, resolveHandoffGatewayHost, HANDOFF_RC_KEY_ENV_NAME, isProdGateway, launchPreferenceExplicit, launchPreflighted, remoteControlAllowed, applyToolSearchDefault, DISABLE_1M_ENV, readCcSettings, resolveWindowModel, resolveAnthropicDoor, resolveLaunchModel, resolveConversationId, resolveGatewayHost, singleCellError, resolveLaunchShape, resolveRemoteControlShape, passthroughName, sanitizeAgentName, sessionDisplayName,
|
|
46
|
+
import { assembleContract, buildGoalModeHeader, buildCustomHeaders, buildHandoffMcpConfigJson, buildSessionConfig, buildSessionConfigB64, applyGatewayHintHeaders, resolveCompactionIntercept, resolveContextHandoff, resolveContextProfile, resolveHandoffGatewayHost, HANDOFF_RC_KEY_ENV_NAME, isProdGateway, launchPreferenceExplicit, launchPreflighted, remoteControlAllowed, applyToolSearchDefault, DISABLE_1M_ENV, readCcSettings, resolveWindowModel, resolveAnthropicDoor, resolveLaunchModel, resolveConversationId, resolveGatewayHost, singleCellError, resolveLaunchShape, resolveRemoteControlShape, passthroughName, sanitizeAgentName, sessionDisplayName, SESSION_NAME_ENV, SESSION_CONFIG_HEADER, } from "../lib/agent.js";
|
|
47
47
|
import { LAUNCHER_STORE_PATH, mergeStore } from "../lib/launcher-store.js";
|
|
48
48
|
import { slackChannelArgs } from "../lib/slack-launch.js";
|
|
49
49
|
/** The CLI's own provider-key store (zero 1Password): ~/.mnemom/launcher.json, 0600. */
|
|
50
50
|
const CODE_STORE = LAUNCHER_STORE_PATH;
|
|
51
51
|
const say = (line = "") => console.error(line);
|
|
52
|
+
/** The one-line launch note when the compaction intercept is ON. */
|
|
53
|
+
const COMPACTION_INTERCEPT_NOTE = "mnemom agent: compaction intercept ON (experimental) — requested from the gateway (GATEWAY_USAGE_REAL_SIZE, GATEWAY_COMPACTION_INTERCEPT); a gateway without the companion support drops both keys and the session runs as before.";
|
|
52
54
|
/**
|
|
53
55
|
* The one-line launch note for a non-default context profile (`balanced`, the default, is
|
|
54
56
|
* silent — nothing is sent). `off` keeps the wording `--no-context` has always printed.
|
|
@@ -149,11 +151,20 @@ export async function agentCommand(scenarioArg, options = {}) {
|
|
|
149
151
|
say("mnemom agent: context handoff is not available with subscription login yet — launching without it.");
|
|
150
152
|
contextHandoff = false;
|
|
151
153
|
}
|
|
154
|
+
const compactionIntercept = resolveCompactionIntercept({
|
|
155
|
+
compactionIntercept: options.compactionIntercept,
|
|
156
|
+
env,
|
|
157
|
+
});
|
|
152
158
|
const experimental = options.experimental ?? [];
|
|
153
|
-
const sessionConfig = buildSessionConfig({
|
|
159
|
+
const sessionConfig = buildSessionConfig({
|
|
160
|
+
profile: contextProfile,
|
|
161
|
+
contextHandoff,
|
|
162
|
+
compactionIntercept,
|
|
163
|
+
});
|
|
154
164
|
const sessionConfigB64 = buildSessionConfigB64({
|
|
155
165
|
profile: contextProfile,
|
|
156
166
|
contextHandoff,
|
|
167
|
+
compactionIntercept,
|
|
157
168
|
experimental,
|
|
158
169
|
});
|
|
159
170
|
const expNotice = experimentalNotice(experimental);
|
|
@@ -212,6 +223,8 @@ export async function agentCommand(scenarioArg, options = {}) {
|
|
|
212
223
|
if (contextHandoff) {
|
|
213
224
|
say("mnemom agent: context handoff ON (experimental) — the `handoff` MCP tool would be registered.");
|
|
214
225
|
}
|
|
226
|
+
if (compactionIntercept)
|
|
227
|
+
say(COMPACTION_INTERCEPT_NOTE);
|
|
215
228
|
return;
|
|
216
229
|
}
|
|
217
230
|
// Real launch: make sure we actually have a CLI to run. If we couldn't find it,
|
|
@@ -297,19 +310,24 @@ export async function agentCommand(scenarioArg, options = {}) {
|
|
|
297
310
|
// provisions-with-confirm when interactive; warns and continues otherwise.
|
|
298
311
|
// Skipped entirely by `--no-setup`. A provisioning failure never blocks the
|
|
299
312
|
// launch — it degrades to the unclaimed (fail-open) identity with a warning.
|
|
313
|
+
// The agent id the gateway serves, when setup asked it (undefined = never asked, so the ledger
|
|
314
|
+
// falls back to the org's name lookup).
|
|
315
|
+
let servedAgentId;
|
|
300
316
|
if (options.setup !== false && !anthropicKey) {
|
|
301
317
|
// Subscription login: the gateway creates the agent in the Mnemom key's org on the first
|
|
302
318
|
// turn, so there is nothing to birth or claim here.
|
|
303
319
|
say(`mnemom agent: subscription login — '${agent}' is created in your org on the first turn (identity: your Mnemom API key).`);
|
|
304
320
|
}
|
|
305
321
|
else if (options.setup !== false && anthropicKey) {
|
|
322
|
+
servedAgentId = null; // asked below; stays null if setup can't tell
|
|
306
323
|
try {
|
|
307
|
-
await ensureGovernedAgent({
|
|
324
|
+
const ensured = await ensureGovernedAgent({
|
|
308
325
|
slug: agent,
|
|
309
326
|
anthropicKey,
|
|
310
327
|
setup: options.setup,
|
|
311
328
|
gatewayHost,
|
|
312
329
|
});
|
|
330
|
+
servedAgentId = ensured.agentId ?? null;
|
|
313
331
|
}
|
|
314
332
|
catch (err) {
|
|
315
333
|
// An identity mismatch is the one setup failure that must NOT degrade: the
|
|
@@ -325,7 +343,7 @@ export async function agentCommand(scenarioArg, options = {}) {
|
|
|
325
343
|
// so the goal the gateway inferred there is still the one it steers by. Resolved here, after
|
|
326
344
|
// sign-in and the agent refresh, because a resume needs the org, agent and gateway host to
|
|
327
345
|
// match the earlier launch.
|
|
328
|
-
const
|
|
346
|
+
const continuation = await resolveLaunchContinuation({
|
|
329
347
|
session: scenario,
|
|
330
348
|
orgId: account.orgId,
|
|
331
349
|
agent,
|
|
@@ -336,7 +354,19 @@ export async function agentCommand(scenarioArg, options = {}) {
|
|
|
336
354
|
interactive: isInteractive(),
|
|
337
355
|
records: () => readSessionRecords({ dir: MNEMOM_DIR }),
|
|
338
356
|
askYesNo: (q, d) => askYesNo(q, d),
|
|
357
|
+
ccSessionExists: (id) => ccSessionTranscriptExists(id, process.env),
|
|
339
358
|
});
|
|
359
|
+
const conversationId = continuation.conversationId;
|
|
360
|
+
// A relaunch that continues an earlier conversation resumes that Claude Code session, so the
|
|
361
|
+
// same CC session id keeps owning the conversation's main goal on the gateway (a fresh
|
|
362
|
+
// --session-id would land on a new, empty m.<sha16> thread). The server shape hosts many
|
|
363
|
+
// sessions and pins none.
|
|
364
|
+
if (continuation.resumeCcSessionId && shape !== "server") {
|
|
365
|
+
options = {
|
|
366
|
+
...options,
|
|
367
|
+
passthrough: [...(options.passthrough ?? []), "--resume", continuation.resumeCcSessionId],
|
|
368
|
+
};
|
|
369
|
+
}
|
|
340
370
|
// ── local session ledger (~/.mnemom/sessions.jsonl) ── one line per real
|
|
341
371
|
// launch so `mnemom agent sessions` / `show` can find this conversation later.
|
|
342
372
|
// Written after provisioning so a freshly claimed agent's id resolves. Holds
|
|
@@ -368,6 +398,7 @@ export async function agentCommand(scenarioArg, options = {}) {
|
|
|
368
398
|
session: scenario,
|
|
369
399
|
conversationId,
|
|
370
400
|
agent,
|
|
401
|
+
agentId: servedAgentId,
|
|
371
402
|
account,
|
|
372
403
|
gatewayHost,
|
|
373
404
|
shape,
|
|
@@ -383,6 +414,8 @@ export async function agentCommand(scenarioArg, options = {}) {
|
|
|
383
414
|
say(expNotice);
|
|
384
415
|
say(goalAlignmentLine(goalAlignmentMode(envelope, goalMode)));
|
|
385
416
|
sayContextProfile(contextProfile);
|
|
417
|
+
if (compactionIntercept)
|
|
418
|
+
say(COMPACTION_INTERCEPT_NOTE);
|
|
386
419
|
// ── handoff MCP tool (MNE-7564) ── OFF by default: absent/false ⇒ handoffMcpConfigPath stays
|
|
387
420
|
// undefined and buildCliArgs/spawnRcCli are byte-identical to a build without this flag. ON:
|
|
388
421
|
// the config is written to a 0600 temp file and referenced by PATH only — never inlined into
|
|
@@ -483,21 +516,62 @@ export function passthroughResumeId(passthrough) {
|
|
|
483
516
|
* - `-- --resume <cc-session-id>` → the matching row that pinned that Claude Code session
|
|
484
517
|
* (else the newest matching row), reused without asking;
|
|
485
518
|
* - no `--resume` → "Resume previous session <name>? [Y/n]", default Y; Y reuses that row's
|
|
486
|
-
* conversation, n mints a new one.
|
|
487
|
-
* resumes inside it with /resume). No TTY: Y.
|
|
519
|
+
* conversation, n mints a new one. No TTY: Y.
|
|
488
520
|
* The ledger read is best-effort: a failure means a new id.
|
|
489
521
|
*/
|
|
490
522
|
export async function resolveLaunchConversationId(input) {
|
|
491
|
-
|
|
492
|
-
|
|
523
|
+
return (await resolveLaunchContinuation(input)).conversationId;
|
|
524
|
+
}
|
|
525
|
+
/**
|
|
526
|
+
* Whether the caller's passthrough already chooses the Claude Code session (`--session-id`,
|
|
527
|
+
* `--resume` / `-r`, `--continue` / `-c`, `--fork-session`): exactly when pickCcSessionId would
|
|
528
|
+
* NOT mint one, so the two never disagree about which flags count.
|
|
529
|
+
*/
|
|
530
|
+
function choosesCcSession(passthrough) {
|
|
531
|
+
return !pickCcSessionId([...passthrough], () => "").inject;
|
|
532
|
+
}
|
|
533
|
+
const CC_SESSION_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
534
|
+
/**
|
|
535
|
+
* Whether Claude Code still has the transcript of session `id`
|
|
536
|
+
* (`${CLAUDE_CONFIG_DIR:-~/.claude}/projects/<any project>/<id>.jsonl`; `--resume` finds it in any
|
|
537
|
+
* project). Claude Code exits on `--resume` of an id it has no transcript for, so a relaunch only
|
|
538
|
+
* resumes one that is there. Best-effort: an unreadable directory or a non-UUID id → false.
|
|
539
|
+
*/
|
|
540
|
+
export function ccSessionTranscriptExists(id, env) {
|
|
541
|
+
if (!CC_SESSION_ID_RE.test(id))
|
|
542
|
+
return false;
|
|
543
|
+
const projects = join((env.CLAUDE_CONFIG_DIR ?? "").trim() || join(homedir(), ".claude"), "projects");
|
|
544
|
+
try {
|
|
545
|
+
return readdirSync(projects).some((d) => existsSync(join(projects, d, `${id}.jsonl`)));
|
|
546
|
+
}
|
|
547
|
+
catch {
|
|
548
|
+
return false;
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
/**
|
|
552
|
+
* resolveLaunchConversationId, plus the Claude Code session to resume. A Y (or no-TTY) relaunch
|
|
553
|
+
* of an earlier implicit launch also resumes the CC session that launch pinned (`--resume <id>`),
|
|
554
|
+
* when the caller passed no session flag of their own: the gateway gives the conversation's main
|
|
555
|
+
* goal to ONE CC session id, and a fresh `--session-id` cannot reuse it (CC refuses an id already
|
|
556
|
+
* in use), so without the resume the relaunch would run on a new, empty thread. An n, a new
|
|
557
|
+
* conversation, or the caller's own `--resume` / `--session-id` / `--continue` resume nothing.
|
|
558
|
+
*/
|
|
559
|
+
export async function resolveLaunchContinuation(input) {
|
|
560
|
+
const fresh = () => ({
|
|
561
|
+
conversationId: resolveConversationId(undefined),
|
|
562
|
+
resumeCcSessionId: null,
|
|
563
|
+
});
|
|
564
|
+
if ((input.explicit ?? "").trim()) {
|
|
565
|
+
return { conversationId: resolveConversationId(input.explicit), resumeCcSessionId: null };
|
|
566
|
+
}
|
|
493
567
|
if (!input.implicit)
|
|
494
|
-
return
|
|
568
|
+
return fresh();
|
|
495
569
|
let records;
|
|
496
570
|
try {
|
|
497
571
|
records = input.records();
|
|
498
572
|
}
|
|
499
573
|
catch {
|
|
500
|
-
return
|
|
574
|
+
return fresh();
|
|
501
575
|
}
|
|
502
576
|
const found = findResumeCandidate(records, {
|
|
503
577
|
session: input.session,
|
|
@@ -507,15 +581,24 @@ export async function resolveLaunchConversationId(input) {
|
|
|
507
581
|
resumeId: passthroughResumeId(input.passthrough),
|
|
508
582
|
});
|
|
509
583
|
if (!found)
|
|
510
|
-
return
|
|
584
|
+
return fresh();
|
|
511
585
|
const conv = found.record.conversation_id;
|
|
512
586
|
if (found.source === "session_name" && input.interactive) {
|
|
513
587
|
const yes = await input.askYesNo(`Resume previous session ${input.session}?`, true);
|
|
514
588
|
if (!yes)
|
|
515
|
-
return
|
|
589
|
+
return fresh();
|
|
516
590
|
}
|
|
517
591
|
say(`mnemom agent: continuing conversation ${conv} from the earlier '${found.record.session}' launch (${found.record.started_at}), so its goal carries over.`);
|
|
518
|
-
|
|
592
|
+
const cc = found.record.cc_session_id?.trim() ?? "";
|
|
593
|
+
let resumeCcSessionId = null;
|
|
594
|
+
if (found.source === "session_name" && cc && !choosesCcSession(input.passthrough)) {
|
|
595
|
+
if (input.ccSessionExists(cc))
|
|
596
|
+
resumeCcSessionId = cc;
|
|
597
|
+
else {
|
|
598
|
+
say(`mnemom agent: Claude Code no longer has session ${cc}, so this launch starts a new one (its goal starts empty on the gateway).`);
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
return { conversationId: resolveConversationId(conv), resumeCcSessionId };
|
|
519
602
|
}
|
|
520
603
|
/**
|
|
521
604
|
* Record this launch in ~/.mnemom/sessions.jsonl. The agent record id is
|
|
@@ -523,12 +606,19 @@ export async function resolveLaunchConversationId(input) {
|
|
|
523
606
|
* ledger write failure warns to stderr and the launch proceeds regardless.
|
|
524
607
|
*/
|
|
525
608
|
export async function recordSessionLaunch(ctx) {
|
|
609
|
+
// Never re-derive a known id from the name: the name can match an org agent the gateway does
|
|
610
|
+
// not serve for this key (MNE-7650), which is exactly what the ledger must not record.
|
|
526
611
|
let agentId;
|
|
527
|
-
|
|
528
|
-
agentId =
|
|
612
|
+
if (ctx.agentId !== undefined) {
|
|
613
|
+
agentId = ctx.agentId;
|
|
529
614
|
}
|
|
530
|
-
|
|
531
|
-
|
|
615
|
+
else {
|
|
616
|
+
try {
|
|
617
|
+
agentId = (await getAgentByName(ctx.agent))?.id ?? null;
|
|
618
|
+
}
|
|
619
|
+
catch {
|
|
620
|
+
agentId = null; // unresolvable right now — the ledger still records the launch
|
|
621
|
+
}
|
|
532
622
|
}
|
|
533
623
|
const result = appendSessionRecord({
|
|
534
624
|
session: ctx.session,
|
|
@@ -855,7 +945,11 @@ function launchTerminal(ctx) {
|
|
|
855
945
|
applyToolSearchDefault(childEnv);
|
|
856
946
|
const sessionName = passthroughName(ctx.options.passthrough ?? []) ?? sessionDisplayName(ctx.agent, ctx.scenario);
|
|
857
947
|
childEnv[SESSION_NAME_ENV] = sessionName;
|
|
858
|
-
|
|
948
|
+
// Exact compaction signal for the gateway (x-claude-code-request-class: compaction), on every
|
|
949
|
+
// terminal launch — interactive and `-p` passthrough alike; a user's own value wins. The
|
|
950
|
+
// user's CLAUDE_AUTOCOMPACT_PCT_OVERRIDE / CLAUDE_CODE_AUTO_COMPACT_WINDOW ride through
|
|
951
|
+
// untouched in the copied env (USER_AUTOCOMPACT_ENV).
|
|
952
|
+
applyGatewayHintHeaders(childEnv);
|
|
859
953
|
if (ctx.options.slack)
|
|
860
954
|
args.push(...slackChannelArgs(sessionName));
|
|
861
955
|
if (ctx.contextHint) {
|
|
@@ -1099,6 +1193,11 @@ function spawnRcCli(ctx) {
|
|
|
1099
1193
|
childEnv.https_proxy = `http://127.0.0.1:${ctx.port}`;
|
|
1100
1194
|
childEnv.NODE_EXTRA_CA_CERTS = ctx.ca;
|
|
1101
1195
|
childEnv[SESSION_NAME_ENV] = rcName;
|
|
1196
|
+
// Same exact compaction signal as the terminal shape. Not an ANTHROPIC_* var, so the RC gate
|
|
1197
|
+
// ignores it (like HTTPS_PROXY above); the proxy forwards every non-hop request header, so the
|
|
1198
|
+
// x-claude-code-* hints reach the gateway. The user's own autocompact env is not on the strip
|
|
1199
|
+
// list and rides through untouched.
|
|
1200
|
+
applyGatewayHintHeaders(childEnv);
|
|
1102
1201
|
// Under RC Claude Code sees a first-party host (the proxy, not ANTHROPIC_BASE_URL, routes to the
|
|
1103
1202
|
// gateway), so tool search is already on by default; setting it anyway keeps every launch shape
|
|
1104
1203
|
// on the same switch. A user value is kept.
|
package/dist/commands/wrap.d.ts
CHANGED
|
@@ -24,5 +24,7 @@ export interface WrapOptions {
|
|
|
24
24
|
providerKey?: string;
|
|
25
25
|
}
|
|
26
26
|
export declare function wrapCommand(options?: WrapOptions): Promise<void>;
|
|
27
|
-
export declare function birthThroughGateway(gatewayUrl: string, provider: Provider, agentName: string, providerKey: string
|
|
27
|
+
export declare function birthThroughGateway(gatewayUrl: string, provider: Provider, agentName: string, providerKey: string, opts?: {
|
|
28
|
+
timeoutMs?: number;
|
|
29
|
+
}): Promise<string>;
|
|
28
30
|
export {};
|
package/dist/commands/wrap.js
CHANGED
|
@@ -199,7 +199,7 @@ async function resolveProviderKey(options, prov, nonInteractive, say) {
|
|
|
199
199
|
return key;
|
|
200
200
|
}
|
|
201
201
|
// ── Birth call ────────────────────────────────────────────────────────────────
|
|
202
|
-
export async function birthThroughGateway(gatewayUrl, provider, agentName, providerKey) {
|
|
202
|
+
export async function birthThroughGateway(gatewayUrl, provider, agentName, providerKey, opts = {}) {
|
|
203
203
|
const prov = PROVIDERS[provider];
|
|
204
204
|
const authValue = provider === "openai" ? `Bearer ${providerKey}` : providerKey;
|
|
205
205
|
const headers = {
|
|
@@ -235,7 +235,12 @@ export async function birthThroughGateway(gatewayUrl, provider, agentName, provi
|
|
|
235
235
|
messages: [{ role: "user", content: "hello" }],
|
|
236
236
|
});
|
|
237
237
|
}
|
|
238
|
-
const response = await fetch(url, {
|
|
238
|
+
const response = await fetch(url, {
|
|
239
|
+
method: "POST",
|
|
240
|
+
headers,
|
|
241
|
+
body,
|
|
242
|
+
signal: opts.timeoutMs ? AbortSignal.timeout(opts.timeoutMs) : undefined,
|
|
243
|
+
});
|
|
239
244
|
const agentId = response.headers.get(AGENT_HEADER);
|
|
240
245
|
if (!response.ok && !agentId) {
|
|
241
246
|
const text = await response.text().catch(() => "");
|
package/dist/index.js
CHANGED
|
@@ -173,6 +173,8 @@ const agentCmd = program
|
|
|
173
173
|
}))
|
|
174
174
|
.addOption(new Option("--context-handoff", "EXPERIMENTAL (`mnemom experimental enable context-handoff`): register the `handoff(note)` MCP tool and tell the gateway to turn on the context-handoff capability for this session").hideHelp(hiddenUnless("context-handoff")))
|
|
175
175
|
.option("--no-context-handoff", "Keep context handoff off for this launch even when launcher.toml (context_handoff = true) or MNEMOM_AGENT_CONTEXT_HANDOFF turns it on")
|
|
176
|
+
.addOption(new Option("--compaction-intercept", "EXPERIMENTAL (`mnemom experimental enable compaction-intercept`): tell the gateway to report the real context size (Claude Code compacts at its own threshold) and answer the compaction at once from its fold, for this session").hideHelp(hiddenUnless("compaction-intercept")))
|
|
177
|
+
.option("--no-compaction-intercept", "Keep the compaction intercept off for this launch even when launcher.toml (compaction_intercept = true) or MNEMOM_AGENT_COMPACTION_INTERCEPT turns it on")
|
|
176
178
|
.option("--dry-run", "Print the resolved launch plan and exit — no key read, no spawn")
|
|
177
179
|
.option("--first-run", "Show the first-run look again: what Mnemom would have saved on your last 30 days of Claude Code (runs on this machine, read-only, nothing uploaded) — then exit")
|
|
178
180
|
.action(async (scenario, passthrough, opts) => {
|
|
@@ -216,6 +218,11 @@ const agentCmd = program
|
|
|
216
218
|
used: opts.contextHandoff === true,
|
|
217
219
|
},
|
|
218
220
|
{ what: "--implicit", id: "implicit", used: opts.implicit === true },
|
|
221
|
+
{
|
|
222
|
+
what: "--compaction-intercept",
|
|
223
|
+
id: "compaction-intercept",
|
|
224
|
+
used: opts.compactionIntercept === true,
|
|
225
|
+
},
|
|
219
226
|
], experimental);
|
|
220
227
|
if (opts.gateway)
|
|
221
228
|
process.env.MNEMOM_AGENT_GATEWAY = opts.gateway;
|
|
@@ -301,6 +308,10 @@ const agentCmd = program
|
|
|
301
308
|
contextProfile: opts.contextProfile,
|
|
302
309
|
// Off ⇒ an explicit false, so MNEMOM_AGENT_CONTEXT_HANDOFF cannot turn it on.
|
|
303
310
|
contextHandoff: experimental.includes("context-handoff") ? opts.contextHandoff : false,
|
|
311
|
+
// Same gate: off ⇒ an explicit false, so MNEMOM_AGENT_COMPACTION_INTERCEPT cannot turn it on.
|
|
312
|
+
compactionIntercept: experimental.includes("compaction-intercept")
|
|
313
|
+
? opts.compactionIntercept
|
|
314
|
+
: false,
|
|
304
315
|
experimental,
|
|
305
316
|
setup: opts.setup,
|
|
306
317
|
keySource: cfg.keySource,
|
package/dist/lib/agent.d.ts
CHANGED
|
@@ -228,15 +228,6 @@ export declare function resolveWindowModel(input: {
|
|
|
228
228
|
env: Record<string, string | undefined>;
|
|
229
229
|
settings: () => CcSettingsView[];
|
|
230
230
|
}): WindowModel;
|
|
231
|
-
/**
|
|
232
|
-
* Claude Code's opt-in for its gateway hint headers (`x-claude-code-request-class`,
|
|
233
|
-
* `x-claude-code-agent-type`, `x-claude-code-prompt-id`). CC sends them to api.anthropic.com on
|
|
234
|
-
* its own, so a Remote Control launch (rc-proxy in front of api.anthropic.com) already carries
|
|
235
|
-
* them; with `ANTHROPIC_BASE_URL` pointing at the gateway door (the terminal shape) CC sends them
|
|
236
|
-
* only when this is "1". The gateway reads them to keep away summaries, titles, compaction and
|
|
237
|
-
* teammates off the main implicit goal.
|
|
238
|
-
*/
|
|
239
|
-
export declare const GATEWAY_HINT_HEADERS_ENV = "CLAUDE_CODE_GATEWAY_HINT_HEADERS";
|
|
240
231
|
/** The session display name: "<agent>-<scenario>" (sanitised), else the agent slug. */
|
|
241
232
|
export declare function sessionDisplayName(agent: string, scenario: string): string;
|
|
242
233
|
/** A caller's own `--name <x>` in the passthrough args, if any (last one wins). */
|
|
@@ -322,6 +313,42 @@ export type ContextProfile = (typeof CONTEXT_PROFILES)[number];
|
|
|
322
313
|
* header without the tool.
|
|
323
314
|
*/
|
|
324
315
|
export declare const CONTEXT_HANDOFF_KEY = "CONTEXT_HANDOFF_ENABLED";
|
|
316
|
+
/**
|
|
317
|
+
* The two gateway env keys the `compaction_intercept` setting turns on for a single session
|
|
318
|
+
* (payload-bound fix; both default OFF on the gateway, both read from the per-session overlay):
|
|
319
|
+
* - `GATEWAY_USAGE_REAL_SIZE` — report the size of the body the client actually sent (the
|
|
320
|
+
* REAL, unfolded context) in the last `usage.iterations` entry, so Claude Code's own
|
|
321
|
+
* auto-compact threshold (its 1M default, or the user's `CLAUDE_AUTOCOMPACT_PCT_OVERRIDE` /
|
|
322
|
+
* `CLAUDE_CODE_AUTO_COMPACT_WINDOW` / `autoCompactWindow`) fires as it would without the fold;
|
|
323
|
+
* - `GATEWAY_COMPACTION_INTERCEPT` — answer the client's compaction request at once from the
|
|
324
|
+
* gateway's own fold digest (no model call), so the compaction never blocks the user.
|
|
325
|
+
* Sent together, value `"on"`, ONLY when the setting is on (see `resolveCompactionIntercept`).
|
|
326
|
+
*/
|
|
327
|
+
export declare const USAGE_REAL_SIZE_KEY = "GATEWAY_USAGE_REAL_SIZE";
|
|
328
|
+
export declare const COMPACTION_INTERCEPT_KEY = "GATEWAY_COMPACTION_INTERCEPT";
|
|
329
|
+
/**
|
|
330
|
+
* Claude Code's opt-in for its gateway hint headers (`x-claude-code-request-class: main |
|
|
331
|
+
* compaction`, `x-claude-code-compaction: auto | manual | reactive`,
|
|
332
|
+
* `x-claude-code-context-compacted` on the next turn, `x-claude-code-prompt-id`). Off by default
|
|
333
|
+
* in the client; the launcher turns it on for EVERY launch shape (terminal, `-p` passthrough,
|
|
334
|
+
* Remote Control interactive and server) so the gateway gets an exact compaction signal instead
|
|
335
|
+
* of relying on the body rule alone. Headers only — no behaviour change in the client. The
|
|
336
|
+
* gateway also reads them to keep summaries, titles, compaction and teammates off the main
|
|
337
|
+
* implicit goal.
|
|
338
|
+
*/
|
|
339
|
+
export declare const GATEWAY_HINT_HEADERS_ENV = "CLAUDE_CODE_GATEWAY_HINT_HEADERS";
|
|
340
|
+
/**
|
|
341
|
+
* Claude Code's own auto-compaction knobs (env). The launcher NEVER sets, changes or strips
|
|
342
|
+
* them: whatever the user configured is what the client honours (the `autoCompactWindow`
|
|
343
|
+
* settings.json key is likewise never touched — the launcher writes no settings file).
|
|
344
|
+
*/
|
|
345
|
+
export declare const USER_AUTOCOMPACT_ENV: readonly ["CLAUDE_AUTOCOMPACT_PCT_OVERRIDE", "CLAUDE_CODE_AUTO_COMPACT_WINDOW"];
|
|
346
|
+
/**
|
|
347
|
+
* Turn Claude Code's gateway hint headers on in a child env, unless the user already set
|
|
348
|
+
* `CLAUDE_CODE_GATEWAY_HINT_HEADERS` themselves (any non-blank value, `0` included, wins).
|
|
349
|
+
* Mutates and returns `childEnv`. Touches no other key.
|
|
350
|
+
*/
|
|
351
|
+
export declare function applyGatewayHintHeaders(childEnv: NodeJS.ProcessEnv): NodeJS.ProcessEnv;
|
|
325
352
|
/**
|
|
326
353
|
* The `x-mnemom-goal-mode` header value for `--implicit`, or null when the flag
|
|
327
354
|
* is absent: `implicit[; max_turns=N][; budget_usd=X][; stall_turns=N]` — the
|
|
@@ -456,13 +483,15 @@ export declare function resolveContextProfile(opts: {
|
|
|
456
483
|
* The decoded per-session env overlay for this launch — the `config` object of the
|
|
457
484
|
* `x-mnemom-session-config` payload, in wire order: the profile's keys first
|
|
458
485
|
* (`CONTEXT_RUNTIME_ENABLED` for `off`; the three fold-tuning keys for `aggressive`; nothing
|
|
459
|
-
* for `balanced`), then `CONTEXT_HANDOFF_ENABLED: "true"` when context handoff is ON
|
|
460
|
-
*
|
|
486
|
+
* for `balanced`), then `CONTEXT_HANDOFF_ENABLED: "true"` when context handoff is ON, then
|
|
487
|
+
* `GATEWAY_USAGE_REAL_SIZE: "on"` + `GATEWAY_COMPACTION_INTERCEPT: "on"` when the compaction
|
|
488
|
+
* intercept is ON. Empty for `balanced` with both off — the common case, where no header is sent at all.
|
|
461
489
|
* Exported so `--dry-run` can list exactly what the header carries.
|
|
462
490
|
*/
|
|
463
491
|
export declare function buildSessionConfig(opts: {
|
|
464
492
|
profile: ContextProfile;
|
|
465
493
|
contextHandoff: boolean;
|
|
494
|
+
compactionIntercept?: boolean;
|
|
466
495
|
}): Record<string, string>;
|
|
467
496
|
/**
|
|
468
497
|
* The `x-mnemom-session-config` payload: `{ v: 1, config, experimental? }`. The active
|
|
@@ -472,6 +501,7 @@ export declare function buildSessionConfig(opts: {
|
|
|
472
501
|
export declare function buildSessionConfigPayload(opts: {
|
|
473
502
|
profile: ContextProfile;
|
|
474
503
|
contextHandoff: boolean;
|
|
504
|
+
compactionIntercept?: boolean;
|
|
475
505
|
experimental?: readonly string[];
|
|
476
506
|
}): {
|
|
477
507
|
v: 1;
|
|
@@ -484,6 +514,8 @@ export declare function buildSessionConfigPayload(opts: {
|
|
|
484
514
|
* byte-identical to a launch before profiles existed). Two independent overlays:
|
|
485
515
|
* - the context profile (`buildSessionConfig`): `off` → `CONTEXT_RUNTIME_ENABLED: "false"`,
|
|
486
516
|
* `aggressive` → the three fold-tuning keys, `balanced` → nothing;
|
|
517
|
+
* - `compactionIntercept: true` → `GATEWAY_USAGE_REAL_SIZE` + `GATEWAY_COMPACTION_INTERCEPT`
|
|
518
|
+
* `"on"` (the `compaction_intercept` setting);
|
|
487
519
|
* - `contextHandoff: true` → `CONTEXT_HANDOFF_ENABLED: "true"` (the `context_handoff`
|
|
488
520
|
* setting) — the SAME header the `handoff` MCP tool's own call to
|
|
489
521
|
* `/mnemom/context/handoff` must also send, since the gateway resolves the flag
|
|
@@ -493,6 +525,7 @@ export declare function buildSessionConfigPayload(opts: {
|
|
|
493
525
|
export declare function buildSessionConfigB64(opts: {
|
|
494
526
|
profile: ContextProfile;
|
|
495
527
|
contextHandoff: boolean;
|
|
528
|
+
compactionIntercept?: boolean;
|
|
496
529
|
experimental?: readonly string[];
|
|
497
530
|
}): string | undefined;
|
|
498
531
|
/**
|
|
@@ -507,6 +540,17 @@ export declare function resolveContextHandoff(opts: {
|
|
|
507
540
|
contextHandoff?: boolean;
|
|
508
541
|
env?: NodeJS.ProcessEnv;
|
|
509
542
|
}): boolean;
|
|
543
|
+
/**
|
|
544
|
+
* Is the compaction intercept (`GATEWAY_USAGE_REAL_SIZE` + `GATEWAY_COMPACTION_INTERCEPT` in the
|
|
545
|
+
* session-config header) ON for this launch? OFF by default, same shape as
|
|
546
|
+
* `resolveContextHandoff`: an explicit `--compaction-intercept` / `--no-compaction-intercept`
|
|
547
|
+
* wins, else `MNEMOM_AGENT_COMPACTION_INTERCEPT` (which `applyConfigToEnv` sets from the
|
|
548
|
+
* `compaction_intercept` config key), else OFF.
|
|
549
|
+
*/
|
|
550
|
+
export declare function resolveCompactionIntercept(opts: {
|
|
551
|
+
compactionIntercept?: boolean;
|
|
552
|
+
env?: NodeJS.ProcessEnv;
|
|
553
|
+
}): boolean;
|
|
510
554
|
/** The name the `handoff` MCP tool server is registered under in `--mcp-config`. */
|
|
511
555
|
export declare const HANDOFF_MCP_SERVER_NAME = "mnemom-handoff";
|
|
512
556
|
/**
|
package/dist/lib/agent.js
CHANGED
|
@@ -301,15 +301,6 @@ export function resolveWindowModel(input) {
|
|
|
301
301
|
return { model: explicit, reason: "unsupported" };
|
|
302
302
|
return { model: `${base}${ONE_M_SUFFIX}`, reason: "one-m", from: base };
|
|
303
303
|
}
|
|
304
|
-
/**
|
|
305
|
-
* Claude Code's opt-in for its gateway hint headers (`x-claude-code-request-class`,
|
|
306
|
-
* `x-claude-code-agent-type`, `x-claude-code-prompt-id`). CC sends them to api.anthropic.com on
|
|
307
|
-
* its own, so a Remote Control launch (rc-proxy in front of api.anthropic.com) already carries
|
|
308
|
-
* them; with `ANTHROPIC_BASE_URL` pointing at the gateway door (the terminal shape) CC sends them
|
|
309
|
-
* only when this is "1". The gateway reads them to keep away summaries, titles, compaction and
|
|
310
|
-
* teammates off the main implicit goal.
|
|
311
|
-
*/
|
|
312
|
-
export const GATEWAY_HINT_HEADERS_ENV = "CLAUDE_CODE_GATEWAY_HINT_HEADERS";
|
|
313
304
|
/** The session display name: "<agent>-<scenario>" (sanitised), else the agent slug. */
|
|
314
305
|
export function sessionDisplayName(agent, scenario) {
|
|
315
306
|
return sanitizeAgentName(`${agent}-${scenario}`) || agent;
|
|
@@ -489,6 +480,50 @@ const CONTEXT_PROFILE_CONFIG = {
|
|
|
489
480
|
* header without the tool.
|
|
490
481
|
*/
|
|
491
482
|
export const CONTEXT_HANDOFF_KEY = "CONTEXT_HANDOFF_ENABLED";
|
|
483
|
+
/**
|
|
484
|
+
* The two gateway env keys the `compaction_intercept` setting turns on for a single session
|
|
485
|
+
* (payload-bound fix; both default OFF on the gateway, both read from the per-session overlay):
|
|
486
|
+
* - `GATEWAY_USAGE_REAL_SIZE` — report the size of the body the client actually sent (the
|
|
487
|
+
* REAL, unfolded context) in the last `usage.iterations` entry, so Claude Code's own
|
|
488
|
+
* auto-compact threshold (its 1M default, or the user's `CLAUDE_AUTOCOMPACT_PCT_OVERRIDE` /
|
|
489
|
+
* `CLAUDE_CODE_AUTO_COMPACT_WINDOW` / `autoCompactWindow`) fires as it would without the fold;
|
|
490
|
+
* - `GATEWAY_COMPACTION_INTERCEPT` — answer the client's compaction request at once from the
|
|
491
|
+
* gateway's own fold digest (no model call), so the compaction never blocks the user.
|
|
492
|
+
* Sent together, value `"on"`, ONLY when the setting is on (see `resolveCompactionIntercept`).
|
|
493
|
+
*/
|
|
494
|
+
export const USAGE_REAL_SIZE_KEY = "GATEWAY_USAGE_REAL_SIZE";
|
|
495
|
+
export const COMPACTION_INTERCEPT_KEY = "GATEWAY_COMPACTION_INTERCEPT";
|
|
496
|
+
/**
|
|
497
|
+
* Claude Code's opt-in for its gateway hint headers (`x-claude-code-request-class: main |
|
|
498
|
+
* compaction`, `x-claude-code-compaction: auto | manual | reactive`,
|
|
499
|
+
* `x-claude-code-context-compacted` on the next turn, `x-claude-code-prompt-id`). Off by default
|
|
500
|
+
* in the client; the launcher turns it on for EVERY launch shape (terminal, `-p` passthrough,
|
|
501
|
+
* Remote Control interactive and server) so the gateway gets an exact compaction signal instead
|
|
502
|
+
* of relying on the body rule alone. Headers only — no behaviour change in the client. The
|
|
503
|
+
* gateway also reads them to keep summaries, titles, compaction and teammates off the main
|
|
504
|
+
* implicit goal.
|
|
505
|
+
*/
|
|
506
|
+
export const GATEWAY_HINT_HEADERS_ENV = "CLAUDE_CODE_GATEWAY_HINT_HEADERS";
|
|
507
|
+
/**
|
|
508
|
+
* Claude Code's own auto-compaction knobs (env). The launcher NEVER sets, changes or strips
|
|
509
|
+
* them: whatever the user configured is what the client honours (the `autoCompactWindow`
|
|
510
|
+
* settings.json key is likewise never touched — the launcher writes no settings file).
|
|
511
|
+
*/
|
|
512
|
+
export const USER_AUTOCOMPACT_ENV = [
|
|
513
|
+
"CLAUDE_AUTOCOMPACT_PCT_OVERRIDE",
|
|
514
|
+
"CLAUDE_CODE_AUTO_COMPACT_WINDOW",
|
|
515
|
+
];
|
|
516
|
+
/**
|
|
517
|
+
* Turn Claude Code's gateway hint headers on in a child env, unless the user already set
|
|
518
|
+
* `CLAUDE_CODE_GATEWAY_HINT_HEADERS` themselves (any non-blank value, `0` included, wins).
|
|
519
|
+
* Mutates and returns `childEnv`. Touches no other key.
|
|
520
|
+
*/
|
|
521
|
+
export function applyGatewayHintHeaders(childEnv) {
|
|
522
|
+
if ((childEnv[GATEWAY_HINT_HEADERS_ENV] ?? "").trim() === "") {
|
|
523
|
+
childEnv[GATEWAY_HINT_HEADERS_ENV] = "1";
|
|
524
|
+
}
|
|
525
|
+
return childEnv;
|
|
526
|
+
}
|
|
492
527
|
const SEALED_ONLY_FLAGS = "--goal/--requirement/--allow/--forbid/--goal-id";
|
|
493
528
|
/**
|
|
494
529
|
* `--implicit` and a sealed contract are mutually exclusive: on the gateway a
|
|
@@ -732,8 +767,9 @@ export function resolveContextProfile(opts) {
|
|
|
732
767
|
* The decoded per-session env overlay for this launch — the `config` object of the
|
|
733
768
|
* `x-mnemom-session-config` payload, in wire order: the profile's keys first
|
|
734
769
|
* (`CONTEXT_RUNTIME_ENABLED` for `off`; the three fold-tuning keys for `aggressive`; nothing
|
|
735
|
-
* for `balanced`), then `CONTEXT_HANDOFF_ENABLED: "true"` when context handoff is ON
|
|
736
|
-
*
|
|
770
|
+
* for `balanced`), then `CONTEXT_HANDOFF_ENABLED: "true"` when context handoff is ON, then
|
|
771
|
+
* `GATEWAY_USAGE_REAL_SIZE: "on"` + `GATEWAY_COMPACTION_INTERCEPT: "on"` when the compaction
|
|
772
|
+
* intercept is ON. Empty for `balanced` with both off — the common case, where no header is sent at all.
|
|
737
773
|
* Exported so `--dry-run` can list exactly what the header carries.
|
|
738
774
|
*/
|
|
739
775
|
export function buildSessionConfig(opts) {
|
|
@@ -742,6 +778,10 @@ export function buildSessionConfig(opts) {
|
|
|
742
778
|
config[k] = v;
|
|
743
779
|
if (opts.contextHandoff)
|
|
744
780
|
config[CONTEXT_HANDOFF_KEY] = "true";
|
|
781
|
+
if (opts.compactionIntercept) {
|
|
782
|
+
config[USAGE_REAL_SIZE_KEY] = "on";
|
|
783
|
+
config[COMPACTION_INTERCEPT_KEY] = "on";
|
|
784
|
+
}
|
|
745
785
|
return config;
|
|
746
786
|
}
|
|
747
787
|
/**
|
|
@@ -764,6 +804,8 @@ export function buildSessionConfigPayload(opts) {
|
|
|
764
804
|
* byte-identical to a launch before profiles existed). Two independent overlays:
|
|
765
805
|
* - the context profile (`buildSessionConfig`): `off` → `CONTEXT_RUNTIME_ENABLED: "false"`,
|
|
766
806
|
* `aggressive` → the three fold-tuning keys, `balanced` → nothing;
|
|
807
|
+
* - `compactionIntercept: true` → `GATEWAY_USAGE_REAL_SIZE` + `GATEWAY_COMPACTION_INTERCEPT`
|
|
808
|
+
* `"on"` (the `compaction_intercept` setting);
|
|
767
809
|
* - `contextHandoff: true` → `CONTEXT_HANDOFF_ENABLED: "true"` (the `context_handoff`
|
|
768
810
|
* setting) — the SAME header the `handoff` MCP tool's own call to
|
|
769
811
|
* `/mnemom/context/handoff` must also send, since the gateway resolves the flag
|
|
@@ -792,6 +834,21 @@ export function resolveContextHandoff(opts) {
|
|
|
792
834
|
return false;
|
|
793
835
|
return raw === "1" || raw === "true" || raw === "on" || raw === "yes";
|
|
794
836
|
}
|
|
837
|
+
/**
|
|
838
|
+
* Is the compaction intercept (`GATEWAY_USAGE_REAL_SIZE` + `GATEWAY_COMPACTION_INTERCEPT` in the
|
|
839
|
+
* session-config header) ON for this launch? OFF by default, same shape as
|
|
840
|
+
* `resolveContextHandoff`: an explicit `--compaction-intercept` / `--no-compaction-intercept`
|
|
841
|
+
* wins, else `MNEMOM_AGENT_COMPACTION_INTERCEPT` (which `applyConfigToEnv` sets from the
|
|
842
|
+
* `compaction_intercept` config key), else OFF.
|
|
843
|
+
*/
|
|
844
|
+
export function resolveCompactionIntercept(opts) {
|
|
845
|
+
if (opts.compactionIntercept !== undefined)
|
|
846
|
+
return opts.compactionIntercept;
|
|
847
|
+
const raw = (opts.env?.MNEMOM_AGENT_COMPACTION_INTERCEPT ?? "").trim().toLowerCase();
|
|
848
|
+
if (raw === "")
|
|
849
|
+
return false;
|
|
850
|
+
return raw === "1" || raw === "true" || raw === "on" || raw === "yes";
|
|
851
|
+
}
|
|
795
852
|
/** The name the `handoff` MCP tool server is registered under in `--mcp-config`. */
|
|
796
853
|
export const HANDOFF_MCP_SERVER_NAME = "mnemom-handoff";
|
|
797
854
|
/**
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
* Everything here is pure or dependency-injected; index.ts and the command
|
|
13
13
|
* modules are the thin drivers.
|
|
14
14
|
*/
|
|
15
|
-
export type ExperimentalId = "context-handoff" | "slack" | "implicit";
|
|
15
|
+
export type ExperimentalId = "context-handoff" | "slack" | "implicit" | "compaction-intercept";
|
|
16
16
|
export interface ExperimentalFeature {
|
|
17
17
|
id: ExperimentalId;
|
|
18
18
|
summary: string;
|
|
@@ -73,7 +73,8 @@ export declare function assertEnabled(uses: ReadonlyArray<{
|
|
|
73
73
|
export declare function experimentalIdForConfigKey(key: string, value: string): ExperimentalId | undefined;
|
|
74
74
|
/**
|
|
75
75
|
* The stderr lines for experimental settings a launch ignores because their feature
|
|
76
|
-
* is off: launcher.toml keys (`entries` as `[key, value]`) and
|
|
76
|
+
* is off: launcher.toml keys (`entries` as `[key, value]`), MNEMOM_AGENT_CONTEXT_HANDOFF and
|
|
77
|
+
* MNEMOM_AGENT_COMPACTION_INTERCEPT.
|
|
77
78
|
*/
|
|
78
79
|
export declare function ignoredSettingLines(opts: {
|
|
79
80
|
entries: ReadonlyArray<[string, string]>;
|
package/dist/lib/experimental.js
CHANGED
|
@@ -46,6 +46,17 @@ export const EXPERIMENTAL_FEATURES = [
|
|
|
46
46
|
env: [],
|
|
47
47
|
},
|
|
48
48
|
},
|
|
49
|
+
{
|
|
50
|
+
id: "compaction-intercept",
|
|
51
|
+
summary: "Have the gateway report the real context size (so Claude Code compacts at its own threshold) and answer the compaction at once from its fold",
|
|
52
|
+
since: "0.17.4",
|
|
53
|
+
gates: {
|
|
54
|
+
flags: ["--compaction-intercept"],
|
|
55
|
+
configKeys: ["compaction_intercept"],
|
|
56
|
+
commands: [],
|
|
57
|
+
env: ["MNEMOM_AGENT_COMPACTION_INTERCEPT"],
|
|
58
|
+
},
|
|
59
|
+
},
|
|
49
60
|
];
|
|
50
61
|
export const EXPERIMENTAL_IDS = EXPERIMENTAL_FEATURES.map((f) => f.id);
|
|
51
62
|
export const EXPERIMENTAL_ENV = "MNEMOM_EXPERIMENTAL";
|
|
@@ -127,19 +138,22 @@ export function experimentalIdForConfigKey(key, value) {
|
|
|
127
138
|
return value === "true" ? "slack" : undefined;
|
|
128
139
|
case "context_handoff":
|
|
129
140
|
return value === "true" ? "context-handoff" : undefined;
|
|
141
|
+
case "compaction_intercept":
|
|
142
|
+
return value === "true" ? "compaction-intercept" : undefined;
|
|
130
143
|
case "goal_mode":
|
|
131
144
|
return value === "implicit" ? "implicit" : undefined;
|
|
132
145
|
default:
|
|
133
146
|
return undefined;
|
|
134
147
|
}
|
|
135
148
|
}
|
|
136
|
-
/** Is an env value one of the truthy spellings resolveContextHandoff
|
|
149
|
+
/** Is an env value one of the truthy spellings resolveContextHandoff / resolveCompactionIntercept accept? */
|
|
137
150
|
function truthy(raw) {
|
|
138
151
|
return ["1", "true", "on", "yes"].includes((raw ?? "").trim().toLowerCase());
|
|
139
152
|
}
|
|
140
153
|
/**
|
|
141
154
|
* The stderr lines for experimental settings a launch ignores because their feature
|
|
142
|
-
* is off: launcher.toml keys (`entries` as `[key, value]`) and
|
|
155
|
+
* is off: launcher.toml keys (`entries` as `[key, value]`), MNEMOM_AGENT_CONTEXT_HANDOFF and
|
|
156
|
+
* MNEMOM_AGENT_COMPACTION_INTERCEPT.
|
|
143
157
|
*/
|
|
144
158
|
export function ignoredSettingLines(opts) {
|
|
145
159
|
const lines = [];
|
|
@@ -152,6 +166,10 @@ export function ignoredSettingLines(opts) {
|
|
|
152
166
|
if (truthy(opts.env.MNEMOM_AGENT_CONTEXT_HANDOFF) && !opts.enabled.includes("context-handoff")) {
|
|
153
167
|
lines.push(`mnemom agent: ignoring ${gatedMessage("MNEMOM_AGENT_CONTEXT_HANDOFF", "context-handoff")}`);
|
|
154
168
|
}
|
|
169
|
+
if (truthy(opts.env.MNEMOM_AGENT_COMPACTION_INTERCEPT) &&
|
|
170
|
+
!opts.enabled.includes("compaction-intercept")) {
|
|
171
|
+
lines.push(`mnemom agent: ignoring ${gatedMessage("MNEMOM_AGENT_COMPACTION_INTERCEPT", "compaction-intercept")}`);
|
|
172
|
+
}
|
|
155
173
|
return lines;
|
|
156
174
|
}
|
|
157
175
|
/** The features an existing launcher.toml already uses (the one-time migration). */
|
|
@@ -199,6 +217,8 @@ export function configKeyFeature(key) {
|
|
|
199
217
|
return "slack";
|
|
200
218
|
if (key === "context_handoff")
|
|
201
219
|
return "context-handoff";
|
|
220
|
+
if (key === "compaction_intercept")
|
|
221
|
+
return "compaction-intercept";
|
|
202
222
|
if (key === "goal_mode")
|
|
203
223
|
return "implicit";
|
|
204
224
|
return undefined;
|
|
@@ -49,6 +49,12 @@ export interface LauncherConfig {
|
|
|
49
49
|
* every launch. Off means byte-identical to a launch with no MNE-7564 change at all.
|
|
50
50
|
*/
|
|
51
51
|
contextHandoff?: boolean;
|
|
52
|
+
/**
|
|
53
|
+
* Compaction-intercept default (EXPERIMENTAL, off when unset): `true` sends
|
|
54
|
+
* `GATEWAY_USAGE_REAL_SIZE: "on"` + `GATEWAY_COMPACTION_INTERCEPT: "on"` in the per-session
|
|
55
|
+
* config header for every launch. Off means no header key at all.
|
|
56
|
+
*/
|
|
57
|
+
compactionIntercept?: boolean;
|
|
52
58
|
/**
|
|
53
59
|
* How the gateway folds each session's context (`balanced` when unset): `off` leaves
|
|
54
60
|
* compaction to the coding agent, `balanced` keeps more earlier work verbatim, `aggressive`
|
|
@@ -107,6 +113,6 @@ export declare function launcherEntries(cfg: LauncherConfig): Array<[string, str
|
|
|
107
113
|
/**
|
|
108
114
|
* `cfg` without the settings of experimental features that are off (a copy; `cfg` is
|
|
109
115
|
* untouched): `slack_*` while `slack` is off, `context_handoff` while `context-handoff` is
|
|
110
|
-
* off, and `goal_mode = implicit` while `implicit` is off (`goal_mode = off` is kept).
|
|
116
|
+
* off, `compaction_intercept` while `compaction-intercept` is off, and `goal_mode = implicit` while `implicit` is off (`goal_mode = off` is kept).
|
|
111
117
|
*/
|
|
112
118
|
export declare function withoutDisabledExperimental(cfg: LauncherConfig, enabled: readonly ExperimentalId[]): LauncherConfig;
|
|
@@ -197,6 +197,14 @@ export const CONFIG_KEYS = [
|
|
|
197
197
|
c.contextHandoff = coerceBool("context_handoff", v);
|
|
198
198
|
},
|
|
199
199
|
},
|
|
200
|
+
{
|
|
201
|
+
key: "compaction_intercept",
|
|
202
|
+
describe: "EXPERIMENTAL: tell the gateway GATEWAY_USAGE_REAL_SIZE=on + GATEWAY_COMPACTION_INTERCEPT=on (off by default)",
|
|
203
|
+
get: (c) => c.compactionIntercept,
|
|
204
|
+
set: (c, v) => {
|
|
205
|
+
c.compactionIntercept = coerceBool("compaction_intercept", v);
|
|
206
|
+
},
|
|
207
|
+
},
|
|
200
208
|
{
|
|
201
209
|
key: "context_profile",
|
|
202
210
|
describe: `How the gateway folds each session's context (${CONTEXT_PROFILES.join("|")}; balanced by default)`,
|
|
@@ -285,6 +293,8 @@ export function parseLauncherConfig(text) {
|
|
|
285
293
|
cfg.slackChannel = coerceNonEmpty("slack_channel", str(raw.slack_channel) ?? "");
|
|
286
294
|
if (raw.context_handoff !== undefined)
|
|
287
295
|
cfg.contextHandoff = coerceBool("context_handoff", str(raw.context_handoff) ?? "");
|
|
296
|
+
if (raw.compaction_intercept !== undefined)
|
|
297
|
+
cfg.compactionIntercept = coerceBool("compaction_intercept", str(raw.compaction_intercept) ?? "");
|
|
288
298
|
if (raw.context_profile !== undefined)
|
|
289
299
|
cfg.contextProfile = parseContextProfile(str(raw.context_profile) ?? "", "context_profile");
|
|
290
300
|
const g = raw.guardrails;
|
|
@@ -357,6 +367,8 @@ export function serializeLauncherConfig(cfg) {
|
|
|
357
367
|
doc.slack_allow = cfg.slackAllow;
|
|
358
368
|
if (cfg.contextHandoff !== undefined)
|
|
359
369
|
doc.context_handoff = cfg.contextHandoff;
|
|
370
|
+
if (cfg.compactionIntercept !== undefined)
|
|
371
|
+
doc.compaction_intercept = cfg.compactionIntercept;
|
|
360
372
|
if (cfg.contextProfile !== undefined)
|
|
361
373
|
doc.context_profile = cfg.contextProfile;
|
|
362
374
|
const g = cfg.guardrails;
|
|
@@ -414,6 +426,10 @@ export function applyConfigToEnv(cfg, env = process.env) {
|
|
|
414
426
|
if (cfg.contextHandoff !== undefined && !(env.MNEMOM_AGENT_CONTEXT_HANDOFF ?? "").trim()) {
|
|
415
427
|
env.MNEMOM_AGENT_CONTEXT_HANDOFF = cfg.contextHandoff ? "1" : "0";
|
|
416
428
|
}
|
|
429
|
+
if (cfg.compactionIntercept !== undefined &&
|
|
430
|
+
!(env.MNEMOM_AGENT_COMPACTION_INTERCEPT ?? "").trim()) {
|
|
431
|
+
env.MNEMOM_AGENT_COMPACTION_INTERCEPT = cfg.compactionIntercept ? "1" : "0";
|
|
432
|
+
}
|
|
417
433
|
if (cfg.contextProfile !== undefined && !(env.MNEMOM_AGENT_CONTEXT_PROFILE ?? "").trim()) {
|
|
418
434
|
env.MNEMOM_AGENT_CONTEXT_PROFILE = cfg.contextProfile;
|
|
419
435
|
}
|
|
@@ -428,7 +444,7 @@ export function launcherEntries(cfg) {
|
|
|
428
444
|
/**
|
|
429
445
|
* `cfg` without the settings of experimental features that are off (a copy; `cfg` is
|
|
430
446
|
* untouched): `slack_*` while `slack` is off, `context_handoff` while `context-handoff` is
|
|
431
|
-
* off, and `goal_mode = implicit` while `implicit` is off (`goal_mode = off` is kept).
|
|
447
|
+
* off, `compaction_intercept` while `compaction-intercept` is off, and `goal_mode = implicit` while `implicit` is off (`goal_mode = off` is kept).
|
|
432
448
|
*/
|
|
433
449
|
export function withoutDisabledExperimental(cfg, enabled) {
|
|
434
450
|
const out = { ...cfg };
|
|
@@ -439,6 +455,8 @@ export function withoutDisabledExperimental(cfg, enabled) {
|
|
|
439
455
|
}
|
|
440
456
|
if (!enabled.includes("context-handoff"))
|
|
441
457
|
delete out.contextHandoff;
|
|
458
|
+
if (!enabled.includes("compaction-intercept"))
|
|
459
|
+
delete out.compactionIntercept;
|
|
442
460
|
if (!enabled.includes("implicit") && out.goalMode === "implicit")
|
|
443
461
|
delete out.goalMode;
|
|
444
462
|
return out;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which mnm- agent the gateway serves for (Anthropic key, agent name, gateway).
|
|
3
|
+
*
|
|
4
|
+
* The gateway identity is hash(key, name), so the answer for one (key, name, gateway) is stable:
|
|
5
|
+
* once a launch has asked, later launches reuse it instead of paying for another probe
|
|
6
|
+
* completion. Entries are keyed by a one-way fingerprint and never hold the key itself (nor the
|
|
7
|
+
* sha256(key|name) claim proof). `--setup` always re-asks and overwrites.
|
|
8
|
+
*/
|
|
9
|
+
/**
|
|
10
|
+
* A slow KDF (scrypt), not a fast hash: the fingerprint is derived from a credential and lands on
|
|
11
|
+
* disk, so recovering the key from it must cost what guessing a password would. Name and
|
|
12
|
+
* gateway are the salt — the lookup has to be deterministic, and they make each entry unique.
|
|
13
|
+
*/
|
|
14
|
+
export declare function servedFingerprint(anthropicKey: string, slug: string, gateway: string): string;
|
|
15
|
+
/** The cached served id, or null (missing, unreadable, or not an mnm- id). */
|
|
16
|
+
export declare function readServedAgent(dir: string, fingerprint: string): string | null;
|
|
17
|
+
/** Best effort: a failed write only means the next launch asks again. */
|
|
18
|
+
export declare function writeServedAgent(dir: string, fingerprint: string, agentId: string): void;
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which mnm- agent the gateway serves for (Anthropic key, agent name, gateway).
|
|
3
|
+
*
|
|
4
|
+
* The gateway identity is hash(key, name), so the answer for one (key, name, gateway) is stable:
|
|
5
|
+
* once a launch has asked, later launches reuse it instead of paying for another probe
|
|
6
|
+
* completion. Entries are keyed by a one-way fingerprint and never hold the key itself (nor the
|
|
7
|
+
* sha256(key|name) claim proof). `--setup` always re-asks and overwrites.
|
|
8
|
+
*/
|
|
9
|
+
import { scryptSync } from "node:crypto";
|
|
10
|
+
import * as fs from "node:fs";
|
|
11
|
+
import * as path from "node:path";
|
|
12
|
+
const FILE = "served-agents.json";
|
|
13
|
+
/**
|
|
14
|
+
* A slow KDF (scrypt), not a fast hash: the fingerprint is derived from a credential and lands on
|
|
15
|
+
* disk, so recovering the key from it must cost what guessing a password would. Name and
|
|
16
|
+
* gateway are the salt — the lookup has to be deterministic, and they make each entry unique.
|
|
17
|
+
*/
|
|
18
|
+
export function servedFingerprint(anthropicKey, slug, gateway) {
|
|
19
|
+
return scryptSync(anthropicKey, `mnemom-served-agent-v1\n${slug}\n${gateway}`, 32).toString("hex");
|
|
20
|
+
}
|
|
21
|
+
function readAll(dir) {
|
|
22
|
+
try {
|
|
23
|
+
const parsed = JSON.parse(fs.readFileSync(path.join(dir, FILE), "utf8"));
|
|
24
|
+
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
|
|
25
|
+
? parsed
|
|
26
|
+
: {};
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
return {};
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
/** The cached served id, or null (missing, unreadable, or not an mnm- id). */
|
|
33
|
+
export function readServedAgent(dir, fingerprint) {
|
|
34
|
+
const id = readAll(dir)[fingerprint];
|
|
35
|
+
return typeof id === "string" && /^mnm-/.test(id) ? id : null;
|
|
36
|
+
}
|
|
37
|
+
/** Best effort: a failed write only means the next launch asks again. */
|
|
38
|
+
export function writeServedAgent(dir, fingerprint, agentId) {
|
|
39
|
+
try {
|
|
40
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
41
|
+
const tmp = path.join(dir, `${FILE}.${process.pid}.tmp`);
|
|
42
|
+
fs.writeFileSync(tmp, JSON.stringify({ ...readAll(dir), [fingerprint]: agentId }, null, 2), {
|
|
43
|
+
mode: 0o600,
|
|
44
|
+
});
|
|
45
|
+
fs.renameSync(tmp, path.join(dir, FILE));
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
/* best effort */
|
|
49
|
+
}
|
|
50
|
+
}
|