@mnemom/mnemom 0.17.2-next.3 → 0.17.2-next.4
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 +20 -0
- package/dist/commands/agent-config.js +3 -0
- package/dist/commands/agent-goal.d.ts +79 -0
- package/dist/commands/agent-goal.js +276 -0
- package/dist/commands/agent-sessions.d.ts +13 -3
- package/dist/commands/agent-sessions.js +46 -4
- package/dist/commands/agent.d.ts +41 -2
- package/dist/commands/agent.js +190 -8
- package/dist/index.js +67 -0
- package/dist/lib/agent-ledger.d.ts +8 -0
- package/dist/lib/agent.d.ts +107 -4
- package/dist/lib/agent.js +87 -6
- package/dist/lib/goal-api.d.ts +133 -0
- package/dist/lib/goal-api.js +113 -0
- package/dist/lib/launcher-config.d.ts +6 -0
- package/dist/lib/launcher-config.js +15 -0
- package/dist/mcp/handoff-server.mjs +331 -0
- package/dist/rc-proxy.mjs +113 -37
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -84,6 +84,26 @@ An explicit launch shape (`--remote-control`, `--server`, `MNEMOM_AGENT_REMOTE_C
|
|
|
84
84
|
or `launch` in `launcher.toml`) also skips the Remote Control capability probe
|
|
85
85
|
(`claude --help`), which only feeds the automatic upgrade to Remote Control.
|
|
86
86
|
|
|
87
|
+
#### Context handoff (experimental, off by default)
|
|
88
|
+
|
|
89
|
+
**Off by default.** `--context-handoff` (or the standing default `mnemom agent config set
|
|
90
|
+
context_handoff on`) registers one extra MCP tool, `handoff(note)`, that the agent can call
|
|
91
|
+
itself — at a natural stopping point, or as soon as it sees a `[mnemom context warning]` line
|
|
92
|
+
from the gateway — to hand a short note (goal, what's done, next action, open questions) to
|
|
93
|
+
its own successor context ahead of the gateway's next fold. It also tells the gateway to turn
|
|
94
|
+
the context-handoff capability on for that one session. Leaving the setting off (the default)
|
|
95
|
+
sends neither the tool nor the header, and the launch is unaffected.
|
|
96
|
+
|
|
97
|
+
Turn it on for a single launch with `--context-handoff`, or set the standing default:
|
|
98
|
+
|
|
99
|
+
```bash
|
|
100
|
+
mnemom agent config set context_handoff on # persists in ~/.mnemom/launcher.toml
|
|
101
|
+
mnemom agent config set context_handoff off # back to the default
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
This is experimental: it only ever ADDS the one tool (no gateway behavior changes for a
|
|
105
|
+
session that leaves the setting off, and no change to Claude Code's own `/compact`).
|
|
106
|
+
|
|
87
107
|
## How It Works
|
|
88
108
|
|
|
89
109
|
```
|
|
@@ -187,6 +187,9 @@ function deleteKey(cfg, key) {
|
|
|
187
187
|
case "key_source":
|
|
188
188
|
delete cfg.keySource;
|
|
189
189
|
break;
|
|
190
|
+
case "context_handoff":
|
|
191
|
+
delete cfg.contextHandoff;
|
|
192
|
+
break;
|
|
190
193
|
default:
|
|
191
194
|
requireSpec(key); // throws the known-keys message for anything unknown
|
|
192
195
|
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `mnemom agent goal show|pin|reset` (implicit-goal CLI, cli-telemetry §1.4): read and steer the
|
|
3
|
+
* goal the gateway INFERRED for an implicit-mode session, through the gateway's control route
|
|
4
|
+
* (lib/goal-api.ts). The session is picked from the local ledger exactly like `mnemom agent show`
|
|
5
|
+
* (a name, newest match wins, or a unique conversation-id prefix; omitted = the newest session).
|
|
6
|
+
*
|
|
7
|
+
* mnemom agent goal show [ref] [--thread <id>|--all-threads] [--history] [--json]
|
|
8
|
+
* mnemom agent goal pin [ref] [--thread <id>] [--off] [--json]
|
|
9
|
+
* mnemom agent goal reset [ref] [--thread <id>] [--statement <s>] [--requirement <r>...] [--yes] [--json]
|
|
10
|
+
*
|
|
11
|
+
* Exit codes: 0 ok; 1 no such session / auth or agent not found / gateway or network failure /
|
|
12
|
+
* reset declined at the prompt; 2 a usage error (not implicit mode, a bad --thread, --requirement
|
|
13
|
+
* without --statement, reset without --yes off a TTY) or a 409 from the gateway (sealed contract,
|
|
14
|
+
* nothing to pin).
|
|
15
|
+
*
|
|
16
|
+
* Pure apart from the injected deps, so every arm is unit-tested; `liveGoalDeps` is the driver
|
|
17
|
+
* index.ts wires in. Key material is never printed.
|
|
18
|
+
*/
|
|
19
|
+
import { type SessionRecord } from "../lib/agent-ledger.js";
|
|
20
|
+
import { type FetchLike, type GoalControlCreds, type GoalShowResponse } from "../lib/goal-api.js";
|
|
21
|
+
export interface GoalCommandDeps {
|
|
22
|
+
/** The ledger, newest first. */
|
|
23
|
+
readRecords: () => SessionRecord[];
|
|
24
|
+
fetch: FetchLike;
|
|
25
|
+
/** The launch's credential; rejects when no key resolves. */
|
|
26
|
+
resolveCreds: () => Promise<GoalControlCreds>;
|
|
27
|
+
isTTY: () => boolean;
|
|
28
|
+
confirm: (question: string) => Promise<boolean>;
|
|
29
|
+
out: (line: string) => void;
|
|
30
|
+
err: (line: string) => void;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* The Anthropic key the launch would resolve (env, then ~/.mnemom/launcher.json), never prompted
|
|
34
|
+
* for. Lazy import: commands/agent.ts is the launcher and carries a heavy import graph.
|
|
35
|
+
*/
|
|
36
|
+
export declare function resolveLaunchCreds(): Promise<GoalControlCreds>;
|
|
37
|
+
export declare const liveGoalDeps: GoalCommandDeps;
|
|
38
|
+
export interface GoalShowOptions {
|
|
39
|
+
thread?: string;
|
|
40
|
+
allThreads?: boolean;
|
|
41
|
+
history?: boolean;
|
|
42
|
+
json?: boolean;
|
|
43
|
+
}
|
|
44
|
+
export interface GoalPinOptions {
|
|
45
|
+
thread?: string;
|
|
46
|
+
off?: boolean;
|
|
47
|
+
json?: boolean;
|
|
48
|
+
}
|
|
49
|
+
export interface GoalResetOptions {
|
|
50
|
+
thread?: string;
|
|
51
|
+
statement?: string;
|
|
52
|
+
requirement?: string[];
|
|
53
|
+
yes?: boolean;
|
|
54
|
+
json?: boolean;
|
|
55
|
+
}
|
|
56
|
+
/** A failed command: the message and the exit code. */
|
|
57
|
+
declare class Exit extends Error {
|
|
58
|
+
readonly code: 1 | 2;
|
|
59
|
+
constructor(code: 1 | 2, message: string);
|
|
60
|
+
}
|
|
61
|
+
/** Validate `--thread` locally (same regex as the gateway); `all` only where `allowAll`. */
|
|
62
|
+
export declare function parseThreadOption(raw: string | undefined, allowAll: boolean): string;
|
|
63
|
+
/** The ledger record for `ref`, refusing anything that is not an implicit-mode session. */
|
|
64
|
+
export declare function pickImplicitSession(records: SessionRecord[], ref: string | undefined): SessionRecord;
|
|
65
|
+
/** A gateway / transport failure → the exit code and a message that never names the key. */
|
|
66
|
+
export declare function mapGoalError(e: unknown, host: string): Exit;
|
|
67
|
+
/** `rev 3 · pinned@3 · class refine · 2 pending`, or `rev 0 (no goal yet)`. */
|
|
68
|
+
export declare function revisionLine(body: Pick<GoalShowResponse, "intent" | "last_revision">): string;
|
|
69
|
+
/** The human view of one show body. */
|
|
70
|
+
export declare function buildGoalShowView(record: SessionRecord, body: GoalShowResponse, opts: {
|
|
71
|
+
history?: boolean;
|
|
72
|
+
}): string[];
|
|
73
|
+
/** `mnemom agent goal show [ref] [--thread <id>|--all-threads] [--history] [--json]` */
|
|
74
|
+
export declare function agentGoalShowCommand(ref: string | undefined, opts: GoalShowOptions, deps: GoalCommandDeps): Promise<number>;
|
|
75
|
+
/** `mnemom agent goal pin [ref] [--thread <id>] [--off] [--json]` */
|
|
76
|
+
export declare function agentGoalPinCommand(ref: string | undefined, opts: GoalPinOptions, deps: GoalCommandDeps): Promise<number>;
|
|
77
|
+
/** `mnemom agent goal reset [ref] [--thread <id>] [--statement <s>] [--requirement <r>...] [--yes] [--json]` */
|
|
78
|
+
export declare function agentGoalResetCommand(ref: string | undefined, opts: GoalResetOptions, deps: GoalCommandDeps): Promise<number>;
|
|
79
|
+
export {};
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `mnemom agent goal show|pin|reset` (implicit-goal CLI, cli-telemetry §1.4): read and steer the
|
|
3
|
+
* goal the gateway INFERRED for an implicit-mode session, through the gateway's control route
|
|
4
|
+
* (lib/goal-api.ts). The session is picked from the local ledger exactly like `mnemom agent show`
|
|
5
|
+
* (a name, newest match wins, or a unique conversation-id prefix; omitted = the newest session).
|
|
6
|
+
*
|
|
7
|
+
* mnemom agent goal show [ref] [--thread <id>|--all-threads] [--history] [--json]
|
|
8
|
+
* mnemom agent goal pin [ref] [--thread <id>] [--off] [--json]
|
|
9
|
+
* mnemom agent goal reset [ref] [--thread <id>] [--statement <s>] [--requirement <r>...] [--yes] [--json]
|
|
10
|
+
*
|
|
11
|
+
* Exit codes: 0 ok; 1 no such session / auth or agent not found / gateway or network failure /
|
|
12
|
+
* reset declined at the prompt; 2 a usage error (not implicit mode, a bad --thread, --requirement
|
|
13
|
+
* without --statement, reset without --yes off a TTY) or a 409 from the gateway (sealed contract,
|
|
14
|
+
* nothing to pin).
|
|
15
|
+
*
|
|
16
|
+
* Pure apart from the injected deps, so every arm is unit-tested; `liveGoalDeps` is the driver
|
|
17
|
+
* index.ts wires in. Key material is never printed.
|
|
18
|
+
*/
|
|
19
|
+
import { readSessionRecords } from "../lib/agent-ledger.js";
|
|
20
|
+
import { MNEMOM_DIR } from "../lib/config.js";
|
|
21
|
+
import { fmt, formatRelativeAge } from "../lib/format.js";
|
|
22
|
+
import { GoalControlError, goalControlFetch, isValidGoalThread, } from "../lib/goal-api.js";
|
|
23
|
+
import { askYesNo, isInteractive } from "../lib/prompt.js";
|
|
24
|
+
import { localGoalFlag, resolveSessionRef } from "./agent-sessions.js";
|
|
25
|
+
/**
|
|
26
|
+
* The Anthropic key the launch would resolve (env, then ~/.mnemom/launcher.json), never prompted
|
|
27
|
+
* for. Lazy import: commands/agent.ts is the launcher and carries a heavy import graph.
|
|
28
|
+
*/
|
|
29
|
+
export async function resolveLaunchCreds() {
|
|
30
|
+
const { resolveAnthropicKey } = await import("./agent.js");
|
|
31
|
+
try {
|
|
32
|
+
return { anthropicKey: await resolveAnthropicKey(process.env, { allowPrompt: false }) };
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
throw new Error("no Anthropic key in env (MNEMOM_AGENT_ANTHROPIC_KEY / ANTHROPIC_API_KEY) or ~/.mnemom/launcher.json — " +
|
|
36
|
+
"the goal commands authenticate with the same key the session launched with");
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
export const liveGoalDeps = {
|
|
40
|
+
readRecords: () => readSessionRecords({ dir: MNEMOM_DIR }).reverse(),
|
|
41
|
+
fetch: (url, init) => fetch(url, init),
|
|
42
|
+
resolveCreds: resolveLaunchCreds,
|
|
43
|
+
isTTY: () => isInteractive(),
|
|
44
|
+
confirm: (q) => askYesNo(q, false),
|
|
45
|
+
out: (line) => console.log(line),
|
|
46
|
+
err: (line) => console.error(line),
|
|
47
|
+
};
|
|
48
|
+
/** A failed command: the message and the exit code. */
|
|
49
|
+
class Exit extends Error {
|
|
50
|
+
code;
|
|
51
|
+
constructor(code, message) {
|
|
52
|
+
super(message);
|
|
53
|
+
this.code = code;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
/** Validate `--thread` locally (same regex as the gateway); `all` only where `allowAll`. */
|
|
57
|
+
export function parseThreadOption(raw, allowAll) {
|
|
58
|
+
const thread = raw?.trim() || "main";
|
|
59
|
+
if (!isValidGoalThread(thread)) {
|
|
60
|
+
throw new Exit(2, `invalid --thread '${raw}': use main, or a thread id such as a.<agent-id> (see \`mnemom agent goal show --all-threads\`)`);
|
|
61
|
+
}
|
|
62
|
+
if (thread === "all" && !allowAll) {
|
|
63
|
+
throw new Exit(2, "--thread all is read-only; pin and reset take one thread");
|
|
64
|
+
}
|
|
65
|
+
return thread;
|
|
66
|
+
}
|
|
67
|
+
/** The ledger record for `ref`, refusing anything that is not an implicit-mode session. */
|
|
68
|
+
export function pickImplicitSession(records, ref) {
|
|
69
|
+
if (records.length === 0) {
|
|
70
|
+
throw new Exit(1, "No sessions yet — run `mnemom agent` to start one.");
|
|
71
|
+
}
|
|
72
|
+
const resolved = resolveSessionRef(records, ref);
|
|
73
|
+
if (resolved === null) {
|
|
74
|
+
throw new Exit(1, `No session matches '${ref ?? ""}' — try \`mnemom agent sessions\`.`);
|
|
75
|
+
}
|
|
76
|
+
if (resolved.record === null) {
|
|
77
|
+
const ids = resolved.candidates.map((c) => c.conversation_id.slice(0, 12)).join(", ");
|
|
78
|
+
throw new Exit(1, `'${ref ?? ""}' matches ${resolved.candidates.length} sessions (${ids}) — give more of the conversation id.`);
|
|
79
|
+
}
|
|
80
|
+
const mode = localGoalFlag(resolved.record);
|
|
81
|
+
if (mode !== "implicit") {
|
|
82
|
+
throw new Exit(2, `session is not in implicit goal mode (goal_mode=${mode})`);
|
|
83
|
+
}
|
|
84
|
+
return resolved.record;
|
|
85
|
+
}
|
|
86
|
+
/** A gateway / transport failure → the exit code and a message that never names the key. */
|
|
87
|
+
export function mapGoalError(e, host) {
|
|
88
|
+
if (e instanceof Exit)
|
|
89
|
+
return e;
|
|
90
|
+
if (e instanceof GoalControlError) {
|
|
91
|
+
if (e.status === 409) {
|
|
92
|
+
if (e.code === "sealed_contract") {
|
|
93
|
+
return new Exit(2, "this conversation runs a sealed goal contract, not an implicit goal");
|
|
94
|
+
}
|
|
95
|
+
if (e.code === "nothing_to_pin") {
|
|
96
|
+
return new Exit(2, "this thread has no live goal to pin yet (rev 0)");
|
|
97
|
+
}
|
|
98
|
+
return new Exit(2, `the gateway refused: ${e.message}`);
|
|
99
|
+
}
|
|
100
|
+
if (e.status === 401) {
|
|
101
|
+
return new Exit(1, `the gateway on ${host} rejected the credential: ${e.message}`);
|
|
102
|
+
}
|
|
103
|
+
if (e.status === 404) {
|
|
104
|
+
if (e.code === "agent_not_found") {
|
|
105
|
+
return new Exit(1, `agent not found for this key on ${host}: was the session launched with a different key or agent?`);
|
|
106
|
+
}
|
|
107
|
+
if (e.code === "goal_intent_disabled") {
|
|
108
|
+
return new Exit(1, `implicit goal alignment is not enabled on ${host}`);
|
|
109
|
+
}
|
|
110
|
+
return new Exit(1, `${host} has no goal control route yet (HTTP 404)`);
|
|
111
|
+
}
|
|
112
|
+
if (e.status === 400)
|
|
113
|
+
return new Exit(2, `the gateway rejected the request: ${e.message}`);
|
|
114
|
+
if (e.status === 0)
|
|
115
|
+
return new Exit(1, e.message);
|
|
116
|
+
return new Exit(1, `the gateway on ${host} failed (HTTP ${e.status}): ${e.message}`);
|
|
117
|
+
}
|
|
118
|
+
return new Exit(1, e instanceof Error ? e.message : String(e));
|
|
119
|
+
}
|
|
120
|
+
async function run(deps, body) {
|
|
121
|
+
try {
|
|
122
|
+
await body();
|
|
123
|
+
return 0;
|
|
124
|
+
}
|
|
125
|
+
catch (e) {
|
|
126
|
+
const exit = mapGoalError(e, "the gateway");
|
|
127
|
+
deps.err(fmt.error(`mnemom agent goal: ${exit.message}`));
|
|
128
|
+
process.exitCode = exit.code;
|
|
129
|
+
return exit.code;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
async function call(host, fn) {
|
|
133
|
+
try {
|
|
134
|
+
return await fn();
|
|
135
|
+
}
|
|
136
|
+
catch (e) {
|
|
137
|
+
throw mapGoalError(e, host);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
// ─── show ─────────────────────────────────────────────────────────────────────
|
|
141
|
+
function listOrDash(items) {
|
|
142
|
+
return items.length > 0 ? items.join(", ") : "—";
|
|
143
|
+
}
|
|
144
|
+
/** `rev 3 · pinned@3 · class refine · 2 pending`, or `rev 0 (no goal yet)`. */
|
|
145
|
+
export function revisionLine(body) {
|
|
146
|
+
const i = body.intent;
|
|
147
|
+
if (!i) {
|
|
148
|
+
return body.last_revision > 0
|
|
149
|
+
? `rev 0 (no goal yet; last revision ${body.last_revision})`
|
|
150
|
+
: "rev 0 (no goal yet)";
|
|
151
|
+
}
|
|
152
|
+
const bits = [`rev ${i.revision}`];
|
|
153
|
+
if (i.pinned)
|
|
154
|
+
bits.push(`pinned@${i.pinned.revision}`);
|
|
155
|
+
const last = i.history[i.history.length - 1];
|
|
156
|
+
if (last)
|
|
157
|
+
bits.push(`class ${last.class}`);
|
|
158
|
+
bits.push(`${i.pending_count} pending`);
|
|
159
|
+
return bits.join(" · ");
|
|
160
|
+
}
|
|
161
|
+
function intentLines(i, history) {
|
|
162
|
+
const L = [];
|
|
163
|
+
L.push(fmt.label("statement: ", i.statement));
|
|
164
|
+
if (i.requirements.length === 0) {
|
|
165
|
+
L.push(fmt.label("requirements: ", "—"));
|
|
166
|
+
}
|
|
167
|
+
else {
|
|
168
|
+
L.push(fmt.label("requirements:", ""));
|
|
169
|
+
for (const r of i.requirements)
|
|
170
|
+
L.push(` - ${r}`);
|
|
171
|
+
}
|
|
172
|
+
L.push(fmt.label("allowed paths: ", listOrDash(i.allowed_paths)));
|
|
173
|
+
L.push(fmt.label("forbidden paths:", listOrDash(i.forbidden_paths)));
|
|
174
|
+
if (history) {
|
|
175
|
+
L.push(fmt.label("history:", ""));
|
|
176
|
+
for (const h of i.history) {
|
|
177
|
+
L.push(` r${h.revision} ${h.class.padEnd(10)} ${String(h.ops).padStart(2)} ops ${h.at} ${h.source ?? "—"}`);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
return L;
|
|
181
|
+
}
|
|
182
|
+
/** The human view of one show body. */
|
|
183
|
+
export function buildGoalShowView(record, body, opts) {
|
|
184
|
+
const L = [];
|
|
185
|
+
L.push(fmt.label("session: ", `${record.session} (${record.conversation_id.slice(0, 8)}, ${formatRelativeAge(record.started_at)})`));
|
|
186
|
+
L.push(fmt.label("thread: ", body.thread));
|
|
187
|
+
if (body.mode === "sealed") {
|
|
188
|
+
L.push(fmt.dim(` this conversation runs a sealed goal contract (hash ${body.sealed_hash ?? "?"})`));
|
|
189
|
+
return L;
|
|
190
|
+
}
|
|
191
|
+
L.push(revisionLine(body));
|
|
192
|
+
if (body.intent)
|
|
193
|
+
L.push(...intentLines(body.intent, opts.history === true));
|
|
194
|
+
const v = body.verdict;
|
|
195
|
+
L.push(fmt.label("verdict: ", v
|
|
196
|
+
? `${v.verdict.toUpperCase()} at turn ${v.turn}${v.superseded ? " (superseded)" : ""}`
|
|
197
|
+
: fmt.dim("none yet")));
|
|
198
|
+
if (body.threads) {
|
|
199
|
+
L.push(fmt.label("threads:", ""));
|
|
200
|
+
for (const t of body.threads) {
|
|
201
|
+
L.push(` ${t.thread.padEnd(22)} ${t.role.padEnd(9)} rev ${t.revision}${t.pinned ? " · pinned" : ""} ${t.statement ?? fmt.dim("(no goal)")}`);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
return L;
|
|
205
|
+
}
|
|
206
|
+
/** `mnemom agent goal show [ref] [--thread <id>|--all-threads] [--history] [--json]` */
|
|
207
|
+
export function agentGoalShowCommand(ref, opts, deps) {
|
|
208
|
+
return run(deps, async () => {
|
|
209
|
+
if (opts.allThreads && opts.thread && opts.thread.trim() !== "all") {
|
|
210
|
+
throw new Exit(2, "--thread and --all-threads are exclusive");
|
|
211
|
+
}
|
|
212
|
+
const thread = opts.allThreads ? "all" : parseThreadOption(opts.thread, true);
|
|
213
|
+
const record = pickImplicitSession(deps.readRecords(), ref);
|
|
214
|
+
const creds = await deps.resolveCreds();
|
|
215
|
+
const body = await call(record.gateway_host, () => goalControlFetch(record, "show", { thread }, { fetch: deps.fetch, creds }));
|
|
216
|
+
if (opts.json) {
|
|
217
|
+
deps.out(JSON.stringify({ gateway: { ...body, source: "gateway" }, ledger: { ...record, source: "ledger" } }, null, 2));
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
for (const line of buildGoalShowView(record, body, { history: opts.history }))
|
|
221
|
+
deps.out(line);
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
// ─── pin ──────────────────────────────────────────────────────────────────────
|
|
225
|
+
/** `mnemom agent goal pin [ref] [--thread <id>] [--off] [--json]` */
|
|
226
|
+
export function agentGoalPinCommand(ref, opts, deps) {
|
|
227
|
+
return run(deps, async () => {
|
|
228
|
+
const thread = parseThreadOption(opts.thread, false);
|
|
229
|
+
const record = pickImplicitSession(deps.readRecords(), ref);
|
|
230
|
+
const creds = await deps.resolveCreds();
|
|
231
|
+
const pinned = opts.off !== true;
|
|
232
|
+
const body = await call(record.gateway_host, () => goalControlFetch(record, "pin", { thread, pinned }, { fetch: deps.fetch, creds }));
|
|
233
|
+
if (opts.json) {
|
|
234
|
+
deps.out(JSON.stringify({ ...body, source: "gateway" }, null, 2));
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
deps.out(fmt.success(body.pinned
|
|
238
|
+
? `pinned ${thread} at rev ${body.pinned.revision} — the gateway keeps this goal until \`mnemom agent goal pin --off\``
|
|
239
|
+
: `unpinned ${thread} (rev ${body.revision}) — new directions revise the goal again`));
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
// ─── reset ────────────────────────────────────────────────────────────────────
|
|
243
|
+
/** `mnemom agent goal reset [ref] [--thread <id>] [--statement <s>] [--requirement <r>...] [--yes] [--json]` */
|
|
244
|
+
export function agentGoalResetCommand(ref, opts, deps) {
|
|
245
|
+
return run(deps, async () => {
|
|
246
|
+
const thread = parseThreadOption(opts.thread, false);
|
|
247
|
+
const statement = opts.statement?.trim() || undefined;
|
|
248
|
+
const requirements = (opts.requirement ?? []).map((r) => r.trim()).filter((r) => r !== "");
|
|
249
|
+
if (requirements.length > 0 && statement === undefined) {
|
|
250
|
+
throw new Exit(2, "--requirement needs --statement (requirements belong to a stated goal)");
|
|
251
|
+
}
|
|
252
|
+
const record = pickImplicitSession(deps.readRecords(), ref);
|
|
253
|
+
if (!opts.yes) {
|
|
254
|
+
if (!deps.isTTY())
|
|
255
|
+
throw new Exit(2, "refusing to reset without --yes (no TTY to confirm on)");
|
|
256
|
+
const what = statement
|
|
257
|
+
? `replace the ${thread} goal of '${record.session}' with your statement`
|
|
258
|
+
: `clear the ${thread} goal of '${record.session}' (the next message re-infers it)`;
|
|
259
|
+
if (!(await deps.confirm(`Reset: ${what}?`)))
|
|
260
|
+
throw new Exit(1, "reset cancelled");
|
|
261
|
+
}
|
|
262
|
+
const creds = await deps.resolveCreds();
|
|
263
|
+
const body = await call(record.gateway_host, () => goalControlFetch(record, "reset", {
|
|
264
|
+
thread,
|
|
265
|
+
...(statement !== undefined ? { statement } : {}),
|
|
266
|
+
...(requirements.length > 0 ? { requirements } : {}),
|
|
267
|
+
}, { fetch: deps.fetch, creds }));
|
|
268
|
+
if (opts.json) {
|
|
269
|
+
deps.out(JSON.stringify({ ...body, source: "gateway" }, null, 2));
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
deps.out(fmt.success(body.intent
|
|
273
|
+
? `reset ${thread} to your goal at rev ${body.revision} (was rev ${body.prior_revision})`
|
|
274
|
+
: `reset ${thread}: rev 0 (no goal yet) — the next message re-infers the goal as rev ${body.prior_revision + 1}`));
|
|
275
|
+
});
|
|
276
|
+
}
|
|
@@ -15,11 +15,17 @@
|
|
|
15
15
|
*/
|
|
16
16
|
import { type GoalMode, type SessionRecord } from "../lib/agent-ledger.js";
|
|
17
17
|
import { type AgentSessionResponse } from "../lib/api.js";
|
|
18
|
+
import { type GoalShowResponse } from "../lib/goal-api.js";
|
|
18
19
|
export interface SessionCommandDeps {
|
|
19
20
|
/** The ledger, newest first. */
|
|
20
21
|
readRecords: () => SessionRecord[];
|
|
21
22
|
/** The live state from mnemom-api; rejects when unreachable / not found. */
|
|
22
23
|
fetchSession: (conversationId: string, agentId: string, orgId: string) => Promise<AgentSessionResponse>;
|
|
24
|
+
/**
|
|
25
|
+
* The live implicit goal from the gateway's control route (`GET /mnemom/goal/intent`), for the
|
|
26
|
+
* one `live goal` line an implicit session gets. Optional: absent ⇒ no line.
|
|
27
|
+
*/
|
|
28
|
+
fetchGoal?: (record: SessionRecord) => Promise<GoalShowResponse>;
|
|
23
29
|
out: (line: string) => void;
|
|
24
30
|
err: (line: string) => void;
|
|
25
31
|
}
|
|
@@ -35,15 +41,17 @@ export interface SessionRefMatch {
|
|
|
35
41
|
}
|
|
36
42
|
export interface SessionRefAmbiguous {
|
|
37
43
|
record: null;
|
|
38
|
-
/**
|
|
44
|
+
/** The newest record of each distinct conversation whose id starts with the ref. */
|
|
39
45
|
candidates: SessionRecord[];
|
|
40
46
|
}
|
|
41
47
|
/**
|
|
42
48
|
* Resolve `[ref]` against the ledger (newest first):
|
|
43
49
|
* - no ref → the newest record;
|
|
44
50
|
* - an exact session name → the newest record with that name, older ones in `others`;
|
|
45
|
-
* - else a conversation-id prefix → the
|
|
46
|
-
*
|
|
51
|
+
* - else a conversation-id prefix → the newest record of the one conversation it names
|
|
52
|
+
* (a relaunch / `--resume` appends another row for the same conversation id, which is not
|
|
53
|
+
* ambiguity), or `{record: null, candidates}` — newest row per conversation — when several
|
|
54
|
+
* distinct conversations share the prefix (an exact full id among them wins);
|
|
47
55
|
* - nothing matches (or the ledger is empty) → null.
|
|
48
56
|
*/
|
|
49
57
|
export declare function resolveSessionRef(records: SessionRecord[], ref?: string): SessionRefMatch | SessionRefAmbiguous | null;
|
|
@@ -106,5 +114,7 @@ export interface AgentShowOptions {
|
|
|
106
114
|
}
|
|
107
115
|
/** Why the live read could not happen, from the thrown error. */
|
|
108
116
|
export declare function unavailableReason(err: unknown): string;
|
|
117
|
+
/** `rev N[ · pinned]` from a live show body, or `rev 0 (no goal yet)`. */
|
|
118
|
+
export declare function liveGoalLine(body: GoalShowResponse): string;
|
|
109
119
|
/** `mnemom agent show [ref] [--json]` */
|
|
110
120
|
export declare function agentShowCommand(ref: string | undefined, opts: AgentShowOptions, deps: SessionCommandDeps): Promise<void>;
|
|
@@ -17,12 +17,20 @@ import { readSessionRecords } from "../lib/agent-ledger.js";
|
|
|
17
17
|
import { MNEMOM_DIR } from "../lib/config.js";
|
|
18
18
|
import { getAgentSession, MnemomApiError } from "../lib/api.js";
|
|
19
19
|
import { fmt, formatRelativeAge } from "../lib/format.js";
|
|
20
|
+
import { goalControlFetch } from "../lib/goal-api.js";
|
|
20
21
|
/** The production wiring (index.ts). Lazy so a test that mocks lib/api without
|
|
21
22
|
* this export can still import index.ts. */
|
|
22
23
|
export const liveSessionDeps = {
|
|
23
24
|
// The ledger is oldest-first on disk; every consumer here wants newest-first.
|
|
24
25
|
readRecords: () => readSessionRecords({ dir: MNEMOM_DIR }).reverse(),
|
|
25
26
|
fetchSession: (conversationId, agentId, orgId) => getAgentSession(conversationId, agentId, orgId),
|
|
27
|
+
fetchGoal: async (record) => {
|
|
28
|
+
// Lazy: agent-goal.ts imports this module (ref resolution), and the key resolver behind it
|
|
29
|
+
// pulls in the launcher.
|
|
30
|
+
const { resolveLaunchCreds } = await import("./agent-goal.js");
|
|
31
|
+
const creds = await resolveLaunchCreds();
|
|
32
|
+
return goalControlFetch(record, "show", { thread: "main" }, { fetch, creds });
|
|
33
|
+
},
|
|
26
34
|
out: (line) => console.log(line),
|
|
27
35
|
err: (line) => console.error(line),
|
|
28
36
|
};
|
|
@@ -32,8 +40,10 @@ export const DEFAULT_SESSIONS_LIMIT = 20;
|
|
|
32
40
|
* Resolve `[ref]` against the ledger (newest first):
|
|
33
41
|
* - no ref → the newest record;
|
|
34
42
|
* - an exact session name → the newest record with that name, older ones in `others`;
|
|
35
|
-
* - else a conversation-id prefix → the
|
|
36
|
-
*
|
|
43
|
+
* - else a conversation-id prefix → the newest record of the one conversation it names
|
|
44
|
+
* (a relaunch / `--resume` appends another row for the same conversation id, which is not
|
|
45
|
+
* ambiguity), or `{record: null, candidates}` — newest row per conversation — when several
|
|
46
|
+
* distinct conversations share the prefix (an exact full id among them wins);
|
|
37
47
|
* - nothing matches (or the ledger is empty) → null.
|
|
38
48
|
*/
|
|
39
49
|
export function resolveSessionRef(records, ref) {
|
|
@@ -47,11 +57,26 @@ export function resolveSessionRef(records, ref) {
|
|
|
47
57
|
const others = records.filter((r) => r !== newestByName && r.session === needle);
|
|
48
58
|
return { record: newestByName, others };
|
|
49
59
|
}
|
|
50
|
-
|
|
60
|
+
// The ledger appends a row per launch, so a relaunch or `--resume` with the same
|
|
61
|
+
// `--conversation-id` leaves several rows for ONE conversation. Collapse to the newest row
|
|
62
|
+
// per conversation id (records are newest first) before deciding ambiguity: only distinct
|
|
63
|
+
// conversations are ambiguous.
|
|
64
|
+
const seen = new Set();
|
|
65
|
+
const byConv = records.filter((r) => {
|
|
66
|
+
if (!r.conversation_id.startsWith(needle) || seen.has(r.conversation_id))
|
|
67
|
+
return false;
|
|
68
|
+
seen.add(r.conversation_id);
|
|
69
|
+
return true;
|
|
70
|
+
});
|
|
51
71
|
if (byConv.length === 1)
|
|
52
72
|
return { record: byConv[0], others: [] };
|
|
53
|
-
if (byConv.length > 1)
|
|
73
|
+
if (byConv.length > 1) {
|
|
74
|
+
// A full conversation id that also prefixes a longer one still names exactly one.
|
|
75
|
+
const exact = byConv.find((r) => r.conversation_id === needle);
|
|
76
|
+
if (exact)
|
|
77
|
+
return { record: exact, others: [] };
|
|
54
78
|
return { record: null, candidates: byConv };
|
|
79
|
+
}
|
|
55
80
|
return null;
|
|
56
81
|
}
|
|
57
82
|
// ─── sessions table ───────────────────────────────────────────────────────────
|
|
@@ -246,6 +271,12 @@ export function unavailableReason(err) {
|
|
|
246
271
|
return `${err.code}: ${err.message}`;
|
|
247
272
|
return err instanceof Error ? err.message : String(err);
|
|
248
273
|
}
|
|
274
|
+
/** `rev N[ · pinned]` from a live show body, or `rev 0 (no goal yet)`. */
|
|
275
|
+
export function liveGoalLine(body) {
|
|
276
|
+
if (!body.intent)
|
|
277
|
+
return "rev 0 (no goal yet)";
|
|
278
|
+
return `rev ${body.intent.revision}${body.intent.pinned ? " · pinned" : ""}`;
|
|
279
|
+
}
|
|
249
280
|
/** `mnemom agent show [ref] [--json]` */
|
|
250
281
|
export async function agentShowCommand(ref, opts, deps) {
|
|
251
282
|
const records = deps.readRecords();
|
|
@@ -290,6 +321,17 @@ export async function agentShowCommand(ref, opts, deps) {
|
|
|
290
321
|
}
|
|
291
322
|
for (const line of buildShowView(record, remote, reason))
|
|
292
323
|
deps.out(line);
|
|
324
|
+
if (deps.fetchGoal && resolveGoalMode(record, remote) === "implicit") {
|
|
325
|
+
// Best effort: any failure (no key, gateway without the route, agent not found) is one dim line.
|
|
326
|
+
let line;
|
|
327
|
+
try {
|
|
328
|
+
line = liveGoalLine(await deps.fetchGoal(record));
|
|
329
|
+
}
|
|
330
|
+
catch {
|
|
331
|
+
line = fmt.dim("(live goal unavailable)");
|
|
332
|
+
}
|
|
333
|
+
deps.out(fmt.label("live goal: ", line));
|
|
334
|
+
}
|
|
293
335
|
if (others.length > 0) {
|
|
294
336
|
deps.out("");
|
|
295
337
|
deps.out(fmt.dim(` ${others.length} older session(s) share this name — \`mnemom agent show <conv-id prefix>\` picks one:`));
|
package/dist/commands/agent.d.ts
CHANGED
|
@@ -60,6 +60,12 @@ export interface AgentOptions {
|
|
|
60
60
|
context?: boolean;
|
|
61
61
|
/** context-hint seed default: commander `--no-context-hint` sets this false. */
|
|
62
62
|
contextHint?: boolean;
|
|
63
|
+
/**
|
|
64
|
+
* EXPERIMENTAL, off by default (operator directive, MNE-7564): registers the `handoff` MCP
|
|
65
|
+
* tool and tells the gateway `CONTEXT_HANDOFF_ENABLED=true` for this session only. Absent/false
|
|
66
|
+
* ⇒ no header key, no MCP server — the launch is byte-identical to a build without this flag.
|
|
67
|
+
*/
|
|
68
|
+
contextHandoff?: boolean;
|
|
63
69
|
/**
|
|
64
70
|
* Governed-agent provisioning: undefined = AUTO (provision-with-confirm when
|
|
65
71
|
* interactive, warn-and-continue otherwise), true = force (`--setup`), false =
|
|
@@ -98,6 +104,8 @@ export declare function recordSessionLaunch(ctx: {
|
|
|
98
104
|
cli: ResolvedCli;
|
|
99
105
|
envelope: SealedContractEnvelope | null;
|
|
100
106
|
goalMode: string | null;
|
|
107
|
+
/** The Claude Code session id (pickCcSessionId); null/absent when Claude Code picks it. */
|
|
108
|
+
ccSessionId?: string | null;
|
|
101
109
|
}): Promise<void>;
|
|
102
110
|
/**
|
|
103
111
|
* Resolve the Anthropic key with NO 1Password dependency (built for external
|
|
@@ -153,17 +161,48 @@ export interface RcCapability {
|
|
|
153
161
|
* and by the auto-default selection (a bare launch prefers RC when capable).
|
|
154
162
|
*/
|
|
155
163
|
export declare function checkRcCapability(cli: ResolvedCli): RcCapability;
|
|
164
|
+
/**
|
|
165
|
+
* Locates the `handoff` MCP server (src/mcp/handoff-server.mjs in dev, dist/mcp/handoff-server.mjs
|
|
166
|
+
* once built — a raw, uncompiled `.mjs` file that `copy-assets.mjs` copies verbatim, exactly like
|
|
167
|
+
* `resolveProxyScript`'s `rc-proxy.mjs`, so the SAME relative path resolves in both trees).
|
|
168
|
+
*
|
|
169
|
+
* `baseDirOverride` exists ONLY so a test can exercise the missing-file throw deterministically
|
|
170
|
+
* (point it at a directory with no `../mcp/handoff-server.mjs` beside it) without touching the
|
|
171
|
+
* real installed tree; every real caller omits it and gets `import.meta.url`'s own directory.
|
|
172
|
+
*/
|
|
173
|
+
export declare function resolveHandoffServerScript(baseDirOverride?: string): string;
|
|
156
174
|
/**
|
|
157
175
|
* Build the coding-agent CLI args from the model/effort/trim-mcp/yolo knobs plus
|
|
158
176
|
* the caller's passthrough. `--model`/`--effort` are injected only when given (no
|
|
159
|
-
* silent default). yolo appends `--dangerously-skip-permissions`.
|
|
177
|
+
* silent default). yolo appends `--dangerously-skip-permissions`. A `ccSessionId`
|
|
178
|
+
* (pickCcSessionId, inject arm) appends `--session-id <id>`. `--trim-mcp`
|
|
160
179
|
* appends `--strict-mcp-config --mcp-config {}` LAST so it wins over any
|
|
161
|
-
* caller-supplied --mcp-config
|
|
180
|
+
* caller-supplied --mcp-config — UNLESS `handoffMcpConfigPath` is set (context
|
|
181
|
+
* handoff ON, MNE-7564), in which case that file (which already declares only the
|
|
182
|
+
* `mnemom-handoff` server) is what `--strict-mcp-config` points at, so trimming
|
|
183
|
+
* strips every OTHER server but the handoff tool survives. Without `--trim-mcp`,
|
|
184
|
+
* the same path is passed WITHOUT `--strict-mcp-config` so it only ADDS the tool
|
|
185
|
+
* on top of the caller's/CLI's own MCP config rather than replacing it.
|
|
162
186
|
*/
|
|
163
187
|
export declare function buildCliArgs(options: AgentOptions, ctx: {
|
|
164
188
|
yolo: boolean;
|
|
165
189
|
allowCliFlags: boolean;
|
|
190
|
+
ccSessionId?: string;
|
|
191
|
+
handoffMcpConfigPath?: string;
|
|
166
192
|
}): string[];
|
|
193
|
+
/**
|
|
194
|
+
* The Claude Code session id a launch runs under, from the caller's passthrough (implicit-goal
|
|
195
|
+
* CLI, cli-telemetry §1.5):
|
|
196
|
+
* - `--session-id <uuid>` (or `=<uuid>`) → that id; the caller's own flag, nothing injected;
|
|
197
|
+
* - `--fork-session` → null (Claude Code mints the fork's id);
|
|
198
|
+
* - `--resume <id>` / `-r <id>` (or `--resume=<id>`) → that id, nothing injected;
|
|
199
|
+
* - a bare `--resume` / `-r`, `--continue` / `-c` → null (Claude Code picks the session);
|
|
200
|
+
* - otherwise → a fresh UUID the launcher injects as `--session-id`.
|
|
201
|
+
*/
|
|
202
|
+
export declare function pickCcSessionId(passthrough: string[], newId?: () => string): {
|
|
203
|
+
id: string | null;
|
|
204
|
+
inject: boolean;
|
|
205
|
+
};
|
|
167
206
|
/**
|
|
168
207
|
* Which goal-alignment mode the launch status line names: a sealed contract →
|
|
169
208
|
* "on"; `--implicit` (mode header, no contract) → "implicit"; neither → "off".
|