Declare a remember tool that writes to the account's cloud memory

9f3ad4947fba · AtlantisPleb · · parent 510a2bbbd6b0

Declare a remember tool that writes to the account's cloud memory

The write half of the CLI client for OpenAgentsInc/openagents#51. A coder
session declares `remember`, and a call posts to `POST /api/v1/memories`
in the openagents.com database with `bucket: "user"`. The frozen local
engram ledger under ~/.openagents/memory is neither read nor written; this
is the cloud plane.

Explicit only, never inferred. The declaration says so to the model,
because the description is the only thing that governs when a tool fires:
it names the reader asking to remember something as the trigger and rules
out writing because a conversation revealed a preference.

There is no matching read, and no recall tool. The server retrieves the
account's relevant memories inside `POST /api/v1/responses` and attaches
them before the provider call, so what is remembered already reaches the
model without a round spent asking. What is left is the one genuine model
action: writing down what the reader asked for. A correction passes
`supersedes` rather than editing, matching the store.

Every failing path returns a refusal as text. A model handed a silent
success for a write that never landed tells the reader their preference is
saved when it is not, so no credential, a rejected token, a full account,
and an unreachable server each come back naming what happened and, where
the server said one, its own code and sentence. The tool is declared even
on a session holding no credential: one that simply lacked it would answer
that it cannot remember anything at all.

Ten tests over a faked transport pin the API call, the bucket, the
supersedes correction, all four refusals, and the absence of a read.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SoZMfWRSGnf6FZX2Ar9rQ2
Co-Authored-By
Claude Fable 5 <noreply@anthropic.com>

Deploy story

What this commit did to the running system — joined from the forge receipt chain, the part a commit page elsewhere cannot show.

Not deployed through the forge lane

No push, promotion, build, or deploy receipt references this commit (receipts are scanned over a bounded recent window). Changes shipped by full node replacement carry their proof in the release gate receipt instead.

Changed files

  • modified packages/openagents-cli/src/cli.ts
  • added packages/openagents-cli/src/coder-remember.ts
  • added packages/openagents-cli/test/coder-remember.test.ts

Diff

3 files changed, +417 -0

packages/openagents-cli/src/cli.ts modified +17

@@ -115,6 +115,7 @@ import { loadSkillSelection, standingContext } from "./coder-skills.js";

115 115
import { startDevServer } from "./coder-dev-server.js";
116 116
import { ResponsesReplySource } from "./coder-responses.js";
117 117
import { TIER_MODELS, tierForModel, tierUnavailable, type CoderTierId } from "./coder-tiers.js";
118
import { rememberTool } from "./coder-remember.js";
118 119
import { ZenReplySource, zenCredential } from "./coder-zen.js";
119 120
import { describeWorkspace } from "./coder-workspace.js";
120 121
import { makeBoxCommand } from "./box-command.js";

@@ -154,6 +155,7 @@ import { GitRunner } from "./git-runner.js";

154 155
import { makeIdentityCommand } from "./identity-command.js";
155 156
import { IssueClient } from "./issue-client.js";
156 157
import { runGitCredentialHelper } from "./git-credential-helper.js";
158
import { makeMemoryCommand } from "./memory-command.js";
157 159
import { Output, type OutputMode } from "./output.js";
158 160
import { ProjectClient } from "./project-client.js";
159 161
import { makeProviderCommand } from "./provider-command.js";

@@ -2616,6 +2618,18 @@ const coderCommand = Command.make(

2616 2618
          // being asked for. Re-declaration per turn is what makes the tool
2617 2619
          // appear the turn after `/goal` sets one.
2618 2620
          ...(goalStore.getGoal() === undefined ? [] : [goalTool(goalStore)]),
2621
          // The write half of the cloud memory rail
2622
          // (OpenAgentsInc/openagents#51). Declared on every session,
2623
          // including one with no credential: the tool refuses in a sentence
2624
          // the model can repeat, which is what the reader needs to hear, and
2625
          // a session that simply lacked the tool would answer that it cannot
2626
          // remember anything. Recall needs nothing here — the server attaches
2627
          // the account's memories inside `POST /api/v1/responses`.
2628
          rememberTool({
2629
            origin: endpoint.origin,
2630
            ...(Option.isNone(stored) ? {} : { token: Redacted.value(stored.value.token) }),
2631
            ...(transcriptThreadId === undefined ? {} : { sourceRef: transcriptThreadId }),
2632
          }),
2619 2633
          ...(setup === undefined ? [] : [delegateTool(setup.delegation)]),
2620 2634
          capability,
2621 2635
          ...visiblePlugins().map((plugin) => {

@@ -4243,6 +4257,8 @@ const providerCommand = makeProviderCommand(rootCommand);

4243 4257
4244 4258
const boxCommand = makeBoxCommand(rootCommand);
4245 4259
4260
const memoryCommand = makeMemoryCommand(rootCommand);
4261
4246 4262
// The deploy command group: named operator deployment commands over the
4247 4263
// operator-only fleet promotion API from OpenAgentsInc/openagents.com#57.
4248 4264
// It consumes only that API — never `/admin/forge`, SSH, or an internal RPC —

@@ -4630,6 +4646,7 @@ export const openagentsCommand = rootCommand.pipe(

4630 4646
    forumCommand,
4631 4647
    identityCommand,
4632 4648
    issueCommand,
4649
    memoryCommand,
4633 4650
    projectCommand,
4634 4651
    providerCommand,
4635 4652
    repoCommand,
packages/openagents-cli/src/coder-remember.ts added +169

@@ -0,0 +1,169 @@

1
/**
2
 * The `remember` tool: store one memory for the account.
3
 *
4
 * The write half of OpenAgentsInc/openagents#51. A memory goes to
5
 * `POST /api/v1/memories` in the openagents.com database, account-scoped —
6
 * not to a file on this machine. The local engram ledger under
7
 * `~/.openagents/memory` is frozen and nothing here reads or writes it.
8
 *
9
 * There is deliberately no recall tool, and no `get`. Recall runs server-side
10
 * inside `POST /api/v1/responses`: the server retrieves the account's relevant
11
 * memories against the incoming input and attaches them to the model context
12
 * before the provider call. The model does not go looking, and the CLI does no
13
 * retrieval at all. So the one genuine model action left is the one this tool
14
 * carries — writing down something the reader asked to have remembered.
15
 *
16
 * Explicit only, never inferred. This fires when the reader asks for something
17
 * to be remembered, not when a conversation happens to reveal a preference.
18
 * The description below says so to the model, because the description is the
19
 * only thing that governs when a tool is called.
20
 *
21
 * A refusal is returned as text rather than thrown. A model that is told the
22
 * memory could not be stored can say so; a model handed a silent success for a
23
 * write that never landed will tell the reader their preference is saved when
24
 * it is not. So every path out of this tool — no credential, a rejected token,
25
 * a full account, an unreachable server — names what happened.
26
 */
27
28
import type { CoderTool } from "./coder-tools.js";
29
import { MEMORIES_PATH } from "./constants.js";
30
import { trackerErrorDetails } from "./tracker-request.js";
31
32
/** The subset of `fetch` this tool uses. An injection seam for tests. */
33
export type RememberTransport = (
34
  input: URL,
35
  init: {
36
    readonly method: string;
37
    readonly headers: Readonly<Record<string, string>>;
38
    readonly body: string;
39
  },
40
) => Promise<{ readonly status: number; json: () => Promise<unknown> }>;
41
42
export interface RememberOptions {
43
  readonly origin: string;
44
  /**
45
   * The account token.
46
   *
47
   * `undefined` where the session holds no credential — an offline session, or
48
   * one started before `openagents auth login`. The tool is still declared in
49
   * that case and refuses honestly when called, rather than disappearing and
50
   * leaving the model to answer that it cannot remember things at all.
51
   */
52
  readonly token?: string | undefined;
53
  /**
54
   * The thread this session records to, carried onto every memory it writes.
55
   *
56
   * `undefined` on a lane that keeps no server record, which is honest: a
57
   * source reference naming no readable thread would be worse than none.
58
   */
59
  readonly sourceRef?: string | undefined;
60
  /** Injection seam for tests. Defaults to the global `fetch`. */
61
  readonly fetch?: RememberTransport | undefined;
62
}
63
64
/** Reads a JSON object, or an empty one when the value is not an object. */
65
const record = (value: unknown): Record<string, unknown> =>
66
  value !== null && typeof value === "object" && !Array.isArray(value)
67
    ? (value as Record<string, unknown>)
68
    : {};
69
70
export function rememberTool(options: RememberOptions): CoderTool {
71
  const send: RememberTransport =
72
    options.fetch ?? (globalThis.fetch.bind(globalThis) as unknown as RememberTransport);
73
74
  return {
75
    name: "remember",
76
    description:
77
      "Store one thing the reader has explicitly asked you to remember, in their account's " +
78
      "memory. Call it when they say to remember, note, or keep something — a preference, a " +
79
      "constraint, a fact about how they work.\n\n" +
80
      "Explicit requests only. Do not call this because a conversation revealed a preference, " +
81
      "because something seemed worth keeping, or to summarize a session. A memory exists " +
82
      "because somebody asked for it.\n\n" +
83
      "There is no matching read: the account's relevant memories are attached to your context " +
84
      "by the server before you see the turn, so what is remembered already reaches you without " +
85
      "a tool call. To correct a memory you were shown, call this with the corrected sentence " +
86
      "and pass the old memory's id as `supersedes`; memories are never edited in place.",
87
    parameters: {
88
      type: "object",
89
      properties: {
90
        body: {
91
          type: "string",
92
          description:
93
            "What to remember, as one self-contained sentence that will still make sense in a " +
94
            'later session with none of this conversation around it. For example: "Uses pnpm, ' +
95
            'not npm, in every repository."',
96
        },
97
        supersedes: {
98
          type: "string",
99
          description:
100
            "Optional. The id of an existing memory this one corrects and replaces. Use it " +
101
            "instead of writing a second, contradictory memory.",
102
        },
103
      },
104
      required: ["body"],
105
      additionalProperties: false,
106
    },
107
    run: async (args) => {
108
      const body = typeof args["body"] === "string" ? args["body"].trim() : "";
109
      if (body.length === 0) {
110
        return "Nothing was stored: `body` is required and must say what to remember.";
111
      }
112
      const supersedes = typeof args["supersedes"] === "string" ? args["supersedes"] : undefined;
113
114
      if (options.token === undefined) {
115
        return (
116
          "Refusal: nothing was stored. This session holds no OpenAgents credential, so it " +
117
          "cannot reach the account's memory. Run `openagents auth login` and ask again."
118
        );
119
      }
120
121
      const sourceRef = options.sourceRef;
122
123
      let response: { readonly status: number; json: () => Promise<unknown> };
124
      try {
125
        response = await send(new URL(MEMORIES_PATH, options.origin), {
126
          method: "POST",
127
          headers: {
128
            authorization: `Bearer ${options.token}`,
129
            "content-type": "application/json",
130
            accept: "application/json",
131
          },
132
          body: JSON.stringify({
133
            body,
134
            bucket: "user",
135
            ...(sourceRef === undefined ? {} : { source_ref: sourceRef }),
136
            ...(supersedes === undefined ? {} : { supersedes }),
137
          }),
138
        });
139
      } catch (cause) {
140
        return (
141
          `Refusal: nothing was stored. The API at ${options.origin} could not be reached ` +
142
          `(${String(cause)}).`
143
        );
144
      }
145
146
      const envelope = await response.json().catch(() => ({}));
147
148
      if (response.status === 201) {
149
        const memory = record(record(envelope)["memory"]);
150
        const id = typeof memory["id"] === "string" ? memory["id"] : "";
151
        return supersedes === undefined
152
          ? `Stored. Memory ${id} now holds: ${body}`
153
          : `Stored. Memory ${id} now holds: ${body} (it supersedes ${supersedes}.)`;
154
      }
155
156
      // The server's own code and sentence carry through. A full account is
157
      // told which limit it met and what to do about it, and a rejected field
158
      // is named, so the model can act rather than retry the same write.
159
      const details = trackerErrorDetails(envelope, response.status);
160
      if (response.status === 401 || response.status === 403) {
161
        return (
162
          "Refusal: nothing was stored. This session's credential cannot write the account's " +
163
          "memory. Run `openagents auth login` to sign in again."
164
        );
165
      }
166
      return `Refusal: nothing was stored. ${details.message}`;
167
    },
168
  };
169
}
packages/openagents-cli/test/coder-remember.test.ts added +231

@@ -0,0 +1,231 @@

1
import { describe, expect, it } from "vitest";
2
3
import { rememberTool, type RememberTransport } from "../src/coder-remember.js";
4
5
interface Sent {
6
  readonly url: string;
7
  readonly method: string;
8
  readonly headers: Readonly<Record<string, string>>;
9
  readonly body: unknown;
10
}
11
12
/**
13
 * A faked transport, recording what the tool sent and answering what the
14
 * server would. Nothing here touches the network or the disk, which is the
15
 * point: the tool must reach the API and must never write locally.
16
 */
17
const transport = (reply: (sent: Sent) => { readonly status: number; readonly body: unknown }) => {
18
  const sent: Array<Sent> = [];
19
  const send: RememberTransport = (url, init) => {
20
    const call: Sent = {
21
      url: url.toString(),
22
      method: init.method,
23
      headers: init.headers,
24
      body: JSON.parse(init.body) as unknown,
25
    };
26
    sent.push(call);
27
    const answer = reply(call);
28
    return Promise.resolve({
29
      status: answer.status,
30
      json: () => Promise.resolve(answer.body),
31
    });
32
  };
33
  return { sent, send };
34
};
35
36
const signal = new AbortController().signal;
37
38
/** A transport that cannot reach the server at all. */
39
const unreachable: RememberTransport = () => Promise.reject(new Error("ECONNREFUSED"));
40
41
describe("the remember tool", () => {
42
  it("posts to the memories API rather than writing a local ledger", async () => {
43
    const { send, sent } = transport(() => ({
44
      status: 201,
45
      body: {
46
        memory: {
47
          id: "mem_42",
48
          bucket: "user",
49
          body: "Uses pnpm, not npm.",
50
          source_ref: "thread-7",
51
          superseded_by: null,
52
          created_at: "2026-08-25T12:00:00Z",
53
        },
54
      },
55
    }));
56
57
    const tool = rememberTool({
58
      origin: "https://openagents.com",
59
      token: "account-token",
60
      sourceRef: "thread-7",
61
      fetch: send,
62
    });
63
    const answer = await tool.run({ body: "Uses pnpm, not npm." }, signal);
64
65
    expect(sent.length).toBe(1);
66
    expect(sent[0]!.url).toBe("https://openagents.com/api/v1/memories");
67
    expect(sent[0]!.method).toBe("POST");
68
    expect(sent[0]!.headers["authorization"]).toBe("Bearer account-token");
69
    // The bucket is `user` because the reader asked. Nothing this tool writes
70
    // is inferred, so nothing it writes is `learned`.
71
    expect(sent[0]!.body).toMatchObject({
72
      body: "Uses pnpm, not npm.",
73
      bucket: "user",
74
      source_ref: "thread-7",
75
    });
76
    expect(answer).toContain("Stored");
77
    expect(answer).toContain("mem_42");
78
  });
79
80
  it("sends a correction as a new memory carrying supersedes", async () => {
81
    const { send, sent } = transport(() => ({
82
      status: 201,
83
      body: { memory: { id: "mem_43", bucket: "user", body: "Uses bun." } },
84
    }));
85
86
    const tool = rememberTool({
87
      origin: "https://openagents.com",
88
      token: "account-token",
89
      fetch: send,
90
    });
91
    const answer = await tool.run({ body: "Uses bun.", supersedes: "mem_42" }, signal);
92
93
    expect(sent[0]!.body).toMatchObject({ supersedes: "mem_42" });
94
    expect(answer).toContain("supersedes mem_42");
95
  });
96
97
  it("omits the source reference on a lane that keeps no server record", async () => {
98
    const { send, sent } = transport(() => ({
99
      status: 201,
100
      body: { memory: { id: "mem_44" } },
101
    }));
102
103
    const tool = rememberTool({
104
      origin: "https://openagents.com",
105
      token: "account-token",
106
      fetch: send,
107
    });
108
    await tool.run({ body: "Prefers tabs." }, signal);
109
110
    expect(Object.keys(sent[0]!.body as Record<string, unknown>)).not.toContain("source_ref");
111
  });
112
113
  // Every failing path has to come back as a refusal the model can repeat. A
114
  // silent success would tell the reader their preference is stored when it is
115
  // not, which is the one outcome worse than not storing it.
116
  it("refuses honestly when the session holds no credential", async () => {
117
    const { send, sent } = transport(() => ({ status: 201, body: {} }));
118
119
    const tool = rememberTool({ origin: "https://openagents.com", fetch: send });
120
    const answer = await tool.run({ body: "Uses pnpm." }, signal);
121
122
    expect(sent.length).toBe(0);
123
    expect(answer).toContain("Refusal");
124
    expect(answer).toContain("openagents auth login");
125
  });
126
127
  it("refuses honestly when the server rejects the credential", async () => {
128
    const { send } = transport(() => ({
129
      status: 401,
130
      body: {
131
        code: "unauthenticated",
132
        message: "Requires an API token with the scope this route needs",
133
        errors: {},
134
      },
135
    }));
136
137
    const tool = rememberTool({
138
      origin: "https://openagents.com",
139
      token: "stale-token",
140
      fetch: send,
141
    });
142
    const answer = await tool.run({ body: "Uses pnpm." }, signal);
143
144
    expect(answer).toContain("Refusal");
145
    expect(answer).toContain("nothing was stored");
146
    expect(answer).toContain("openagents auth login");
147
  });
148
149
  it("surfaces the quota refusal with the reason the server gave", async () => {
150
    const { send } = transport(() => ({
151
      status: 429,
152
      body: {
153
        code: "memory_quota_reached",
154
        message:
155
          "This account already holds 200 memories. Remove one, or supersede one, before writing another.",
156
        errors: {},
157
      },
158
    }));
159
160
    const tool = rememberTool({
161
      origin: "https://openagents.com",
162
      token: "account-token",
163
      fetch: send,
164
    });
165
    const answer = await tool.run({ body: "One more thing." }, signal);
166
167
    expect(answer).toContain("Refusal");
168
    expect(answer).toContain("already holds 200 memories");
169
    expect(answer).toContain("supersede one");
170
  });
171
172
  it("names the rejected field on a validation refusal", async () => {
173
    const { send } = transport(() => ({
174
      status: 422,
175
      body: {
176
        code: "validation_failed",
177
        message: "The request could not be processed",
178
        errors: { supersedes: ["names no live memory of this account"] },
179
      },
180
    }));
181
182
    const tool = rememberTool({
183
      origin: "https://openagents.com",
184
      token: "account-token",
185
      fetch: send,
186
    });
187
    const answer = await tool.run({ body: "Something.", supersedes: "mem_absent" }, signal);
188
189
    expect(answer).toContain("Refusal");
190
    expect(answer).toContain("supersedes: names no live memory of this account");
191
  });
192
193
  it("refuses honestly when the API cannot be reached", async () => {
194
    const tool = rememberTool({
195
      origin: "https://openagents.com",
196
      token: "account-token",
197
      fetch: unreachable,
198
    });
199
    const answer = await tool.run({ body: "Uses pnpm." }, signal);
200
201
    expect(answer).toContain("Refusal");
202
    expect(answer).toContain("could not be reached");
203
  });
204
205
  it("refuses an empty body without spending a request", async () => {
206
    const { send, sent } = transport(() => ({ status: 201, body: {} }));
207
208
    const tool = rememberTool({
209
      origin: "https://openagents.com",
210
      token: "account-token",
211
      fetch: send,
212
    });
213
    const answer = await tool.run({ body: "   " }, signal);
214
215
    expect(sent.length).toBe(0);
216
    expect(answer).toContain("`body` is required");
217
  });
218
219
  // Recall is server-side inside `POST /api/v1/responses`, so the declaration
220
  // carries a write and nothing else. A read tool here would be the model
221
  // spending a round on context the server already attached.
222
  it("declares one write action and no read", async () => {
223
    const tool = rememberTool({ origin: "https://openagents.com", token: "t" });
224
225
    expect(tool.name).toBe("remember");
226
    expect(tool.description).toContain("Explicit requests only");
227
    expect(tool.description).toContain("There is no matching read");
228
    const properties = (tool.parameters as { properties: Record<string, unknown> }).properties;
229
    expect(Object.keys(properties)).toEqual(["body", "supersedes"]);
230
  });
231
});

This page updates live while a promote is in flight · changelog