Add the openagents memory command group over the cloud memories API

510a2bbbd6b0 · AtlantisPleb · · parent c455106528fa

Add the openagents memory command group over the cloud memories API

The read/write half of the CLI client for OpenAgentsInc/openagents#51. A
typed `MemoryClient` over the three deployed routes — `POST`, `GET`, and
`DELETE /api/v1/memories` on a `chat:account` bearer — plus `openagents
memory list|add|delete` on top of it, following the `box` command pair as
the house shape for a CLI-and-client pair.

Three commands, because the store has three operations. There is no `edit`
and the server has no `PATCH`: a correction is `add --supersedes <id>`,
which leaves the memory that was wrong readable behind the one that
replaced it, so a bad memory can be traced rather than silently rewritten.

Nothing here retrieves. Recall runs server-side inside `POST
/api/v1/responses`, so a memory written by `add` reaches the next turn
with no client plumbing.

Refusals stay refusals. No credential fails before the request is spent, a
rejected token carries the server's `unauthenticated` code, and a full
account carries `memory_quota_reached` with the sentence naming the limit
and what to do about it. A bad `--bucket` is caught locally so a typo costs
a sentence rather than a round trip.

Twelve tests over a faked transport pin the three happy paths, the
supersedes correction, the 401/422/429/404 refusals, and the two local
refusals.

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/constants.ts
  • added packages/openagents-cli/src/memory-client.ts
  • added packages/openagents-cli/src/memory-command.ts
  • modified packages/openagents-cli/src/runtime.ts
  • added packages/openagents-cli/test/memory-command.test.ts

Diff

5 files changed, +627 -0

packages/openagents-cli/src/constants.ts modified +9

@@ -21,3 +21,12 @@ export const THREADS_PATH = `${API_VERSION_PATH}/threads`;

21 21
 * The endpoint path for operator fleet targets.
22 22
 */
23 23
export const FLEET_TARGETS_PATH = `${API_VERSION_PATH}/admin/forge/targets`;
24
25
/**
26
 * The endpoint path for the account's cloud memories.
27
 *
28
 * Named once because two callers share it: the Effect-based `MemoryClient`
29
 * behind `openagents memory`, and the `remember` tool a coder session declares,
30
 * which posts here directly rather than through the CLI runtime.
31
 */
32
export const MEMORIES_PATH = `${API_VERSION_PATH}/memories`;
packages/openagents-cli/src/memory-client.ts added +134

@@ -0,0 +1,134 @@

1
/**
2
 * The client for the account's cloud memories.
3
 *
4
 * Memories live in the openagents.com database, account-scoped, not in a file
5
 * on this machine (OpenAgentsInc/openagents#51). That decision is what makes
6
 * this a client at all: the CLI, the web app, and a direct API caller write to
7
 * one store, and recall happens server-side inside `POST /api/v1/responses`,
8
 * so nothing here retrieves anything. These three calls — write one, read them
9
 * back, remove one — are the whole client surface.
10
 *
11
 * There is no update. A correction is a new memory carrying `supersedes`, so
12
 * the store keeps the chain a wrong memory was corrected through rather than
13
 * overwriting the row that was wrong.
14
 */
15
16
import { Effect, Layer } from "effect";
17
import * as Context from "effect/Context";
18
19
import { ApiTransport } from "./api-transport.js";
20
import { MEMORIES_PATH } from "./constants.js";
21
import type { CliError } from "./errors.js";
22
import type { AuthenticatedApi } from "./repository-client.js";
23
import { asRecord, asRows, asText, makeTrackerRequest } from "./tracker-request.js";
24
25
/** The two buckets the server accepts. `user` is what a reader asks for. */
26
export type MemoryBucket = "learned" | "user";
27
28
export interface MemoryRecord {
29
  readonly id: string;
30
  readonly bucket: string;
31
  readonly body: string;
32
  /** The thread or session the request came out of, when one was named. */
33
  readonly source_ref: string | null;
34
  /** The id of the memory that replaced this one, once one has. */
35
  readonly superseded_by: string | null;
36
  readonly created_at: string;
37
}
38
39
export interface MemoryListInput extends AuthenticatedApi {
40
  readonly bucket?: MemoryBucket;
41
  readonly limit?: number;
42
  /** Reads the corrections behind the live rows as well. */
43
  readonly includeSuperseded?: boolean;
44
}
45
46
export interface MemoryCreateInput extends AuthenticatedApi {
47
  readonly body: string;
48
  readonly bucket?: MemoryBucket;
49
  readonly sourceRef?: string;
50
  /** The id of a live memory of this account that this one replaces. */
51
  readonly supersedes?: string;
52
}
53
54
export interface MemoryDeleteInput extends AuthenticatedApi {
55
  readonly memoryId: string;
56
}
57
58
export interface MemoryClientInterface {
59
  readonly list: (input: MemoryListInput) => Effect.Effect<ReadonlyArray<MemoryRecord>, CliError>;
60
  readonly create: (input: MemoryCreateInput) => Effect.Effect<MemoryRecord, CliError>;
61
  readonly remove: (input: MemoryDeleteInput) => Effect.Effect<MemoryRecord, CliError>;
62
}
63
64
export class MemoryClient extends Context.Service<MemoryClient, MemoryClientInterface>()(
65
  "@openagentsinc/cli/MemoryClient",
66
) {}
67
68
/** Reads one row of the server's memory view. */
69
const parseMemory = (row: Record<string, unknown>): MemoryRecord => ({
70
  id: asText(row["id"]) ?? "",
71
  bucket: asText(row["bucket"]) ?? "user",
72
  body: asText(row["body"]) ?? "",
73
  source_ref: asText(row["source_ref"]) ?? null,
74
  superseded_by: asText(row["superseded_by"]) ?? null,
75
  created_at: asText(row["created_at"]) ?? "",
76
});
77
78
export const memoryClientLayer = Layer.effect(
79
  MemoryClient,
80
  Effect.gen(function* () {
81
    const transport = yield* ApiTransport;
82
    const request = makeTrackerRequest(transport);
83
84
    const list = Effect.fn("MemoryClient.list")(function* (input: MemoryListInput) {
85
      const query = new URLSearchParams();
86
      if (input.bucket !== undefined) query.set("bucket", input.bucket);
87
      if (input.limit !== undefined) query.set("limit", String(input.limit));
88
      // The flag is only ever sent as `true`. Its absence is the default, and
89
      // sending `false` would read as a narrowing the server does not define.
90
      if (input.includeSuperseded === true) query.set("include_superseded", "true");
91
      const suffix = query.size === 0 ? "" : `?${query.toString()}`;
92
      const body = yield* request("list memories", {
93
        origin: input.origin,
94
        token: input.token,
95
        method: "GET",
96
        path: `${MEMORIES_PATH}${suffix}`,
97
        acceptedStatuses: [200],
98
      });
99
      return asRows(body, "memories").map(parseMemory);
100
    });
101
102
    const create = Effect.fn("MemoryClient.create")(function* (input: MemoryCreateInput) {
103
      const body = yield* request("write memory", {
104
        origin: input.origin,
105
        token: input.token,
106
        method: "POST",
107
        path: MEMORIES_PATH,
108
        body: {
109
          body: input.body,
110
          // The server defaults an absent bucket to `user`, but a write path
111
          // that names its bucket keeps working if that default ever moves.
112
          bucket: input.bucket ?? "user",
113
          ...(input.sourceRef === undefined ? {} : { source_ref: input.sourceRef }),
114
          ...(input.supersedes === undefined ? {} : { supersedes: input.supersedes }),
115
        },
116
        acceptedStatuses: [201],
117
      });
118
      return parseMemory(asRecord(asRecord(body)["memory"]));
119
    });
120
121
    const remove = Effect.fn("MemoryClient.remove")(function* (input: MemoryDeleteInput) {
122
      const body = yield* request("remove memory", {
123
        origin: input.origin,
124
        token: input.token,
125
        method: "DELETE",
126
        path: `${MEMORIES_PATH}/${encodeURIComponent(input.memoryId)}`,
127
        acceptedStatuses: [200],
128
      });
129
      return parseMemory(asRecord(asRecord(body)["memory"]));
130
    });
131
132
    return MemoryClient.of({ list, create, remove });
133
  }),
134
);
packages/openagents-cli/src/memory-command.ts added +205

@@ -0,0 +1,205 @@

1
/**
2
 * CLI command definitions for `openagents memory`.
3
 *
4
 * Three commands, because the store has three operations. There is no `edit`:
5
 * a correction is `add --supersedes <id>`, which leaves the memory that was
6
 * wrong readable behind the one that replaced it.
7
 *
8
 * Nothing here recalls anything. Recall runs server-side inside
9
 * `POST /api/v1/responses`, so a memory written by `add` reaches the next turn
10
 * with no client plumbing at all (OpenAgentsInc/openagents#51).
11
 */
12
13
import { Effect, Option } from "effect";
14
import { Argument, Command, Flag } from "effect/unstable/cli";
15
16
import { type EndpointOverrides, type Profile } from "./endpoint.js";
17
import { InputError } from "./errors.js";
18
import { type MemoryBucket, MemoryClient, type MemoryRecord } from "./memory-client.js";
19
import { Output, type OutputMode } from "./output.js";
20
import { resolveApiSession } from "./session.js";
21
22
interface SharedFlags {
23
  readonly profile: Option.Option<Profile>;
24
  readonly apiUrl: Option.Option<string>;
25
  readonly json: boolean;
26
  readonly noColor: boolean;
27
}
28
29
const endpointOverrides = (flags: {
30
  readonly profile: Option.Option<Profile>;
31
  readonly apiUrl: Option.Option<string>;
32
}): EndpointOverrides => ({ profile: flags.profile, apiUrl: flags.apiUrl });
33
34
const outputMode = (json: boolean): OutputMode => (json ? "json" : "human");
35
36
const bucketFlag = Flag.string("bucket").pipe(
37
  Flag.optional,
38
  Flag.withDescription("Narrow to one bucket: user or learned"),
39
);
40
41
const limitFlag = Flag.integer("limit").pipe(
42
  Flag.optional,
43
  Flag.withDescription("Maximum number of memories to read"),
44
);
45
46
const includeSupersededFlag = Flag.boolean("include-superseded").pipe(
47
  Flag.withDescription("Also read the corrections behind the live memories"),
48
);
49
50
const supersedesFlag = Flag.string("supersedes").pipe(
51
  Flag.optional,
52
  Flag.withDescription("ID of the memory this one corrects and replaces"),
53
);
54
55
const sourceRefFlag = Flag.string("source-ref").pipe(
56
  Flag.optional,
57
  Flag.withDescription("Thread or session this memory came out of"),
58
);
59
60
const memoryIdArgument = Argument.string("memory_id").pipe(Argument.withDescription("Memory ID"));
61
62
/**
63
 * Reads a bucket name, or `undefined` for one the server would reject.
64
 *
65
 * Checked here rather than left to the API so a typo costs a sentence instead
66
 * of a round trip that comes back as a validation envelope.
67
 */
68
const readBucket = (raw: string): MemoryBucket | undefined => {
69
  const value = raw.trim().toLowerCase();
70
  return value === "learned" || value === "user" ? value : undefined;
71
};
72
73
/** The refusal a rejected `--bucket` earns, named for the value that was given. */
74
const badBucket = (raw: string): InputError =>
75
  new InputError({ message: `--bucket must be "user" or "learned", not "${raw}".` });
76
77
const memoryListHuman = (memories: ReadonlyArray<MemoryRecord>): ReadonlyArray<string> => {
78
  if (memories.length === 0) return ["No memories stored for this account."];
79
  const lines: string[] = [];
80
  for (const memory of memories) {
81
    // One memory per block rather than one per row: a memory is a sentence a
82
    // person wrote, and a column would cut most of them off.
83
    lines.push(`${memory.id}  [${memory.bucket}]  ${memory.created_at}`);
84
    lines.push(`  ${memory.body}`);
85
    if (memory.source_ref !== null) lines.push(`  source: ${memory.source_ref}`);
86
    if (memory.superseded_by !== null) {
87
      lines.push(`  superseded by: ${memory.superseded_by}`);
88
    }
89
  }
90
  return lines;
91
};
92
93
export const makeMemoryCommand = <R>(root: Effect.Effect<SharedFlags, never, R>) => {
94
  const memoryListCommand = Command.make(
95
    "list",
96
    { bucket: bucketFlag, limit: limitFlag, includeSuperseded: includeSupersededFlag },
97
    ({ bucket, includeSuperseded, limit }) =>
98
      Effect.gen(function* () {
99
        const parsedBucket = Option.isNone(bucket) ? undefined : readBucket(bucket.value);
100
        if (Option.isSome(bucket) && parsedBucket === undefined) {
101
          return yield* badBucket(bucket.value);
102
        }
103
        const flags = yield* root;
104
        const session = yield* resolveApiSession(endpointOverrides(flags));
105
        const client = yield* MemoryClient;
106
        const output = yield* Output;
107
        const memories = yield* client.list({
108
          origin: session.endpoint.origin,
109
          token: session.token,
110
          ...(parsedBucket === undefined ? {} : { bucket: parsedBucket }),
111
          ...(Option.isNone(limit) ? {} : { limit: limit.value }),
112
          ...(includeSuperseded ? { includeSuperseded: true } : {}),
113
        });
114
        yield* output.write(
115
          {
116
            value: { memories },
117
            human: memoryListHuman(memories),
118
          },
119
          outputMode(flags.json),
120
        );
121
      }),
122
  ).pipe(Command.withDescription("List the account's memories, newest first"));
123
124
  const memoryAddCommand = Command.make(
125
    "add",
126
    {
127
      bucket: bucketFlag,
128
      supersedes: supersedesFlag,
129
      sourceRef: sourceRefFlag,
130
      body: Argument.string("body").pipe(
131
        Argument.withDescription("What to remember"),
132
        Argument.variadic({ min: 1 }),
133
      ),
134
    },
135
    ({ body, bucket, sourceRef, supersedes }) =>
136
      Effect.gen(function* () {
137
        const parsedBucket = Option.isNone(bucket) ? undefined : readBucket(bucket.value);
138
        if (Option.isSome(bucket) && parsedBucket === undefined) {
139
          return yield* badBucket(bucket.value);
140
        }
141
        const text = body.join(" ").trim();
142
        if (text.length === 0) {
143
          return yield* new InputError({ message: "A memory needs a body to store." });
144
        }
145
        const flags = yield* root;
146
        const session = yield* resolveApiSession(endpointOverrides(flags));
147
        const client = yield* MemoryClient;
148
        const output = yield* Output;
149
        const memory = yield* client.create({
150
          origin: session.endpoint.origin,
151
          token: session.token,
152
          body: text,
153
          ...(parsedBucket === undefined ? {} : { bucket: parsedBucket }),
154
          ...(Option.isNone(supersedes) ? {} : { supersedes: supersedes.value }),
155
          ...(Option.isNone(sourceRef) ? {} : { sourceRef: sourceRef.value }),
156
        });
157
        yield* output.write(
158
          {
159
            value: { memory },
160
            human: [
161
              `Stored memory ${memory.id} in the ${memory.bucket} bucket.`,
162
              `  ${memory.body}`,
163
              ...(Option.isNone(supersedes) ? [] : [`Supersedes ${supersedes.value}.`]),
164
            ],
165
          },
166
          outputMode(flags.json),
167
        );
168
      }),
169
  ).pipe(
170
    Command.withDescription(
171
      "Store one memory. Pass --supersedes <id> to correct an existing one rather than edit it",
172
    ),
173
  );
174
175
  const memoryDeleteCommand = Command.make(
176
    "delete",
177
    { memoryId: memoryIdArgument },
178
    ({ memoryId }) =>
179
      Effect.gen(function* () {
180
        const flags = yield* root;
181
        const session = yield* resolveApiSession(endpointOverrides(flags));
182
        const client = yield* MemoryClient;
183
        const output = yield* Output;
184
        const memory = yield* client.remove({
185
          origin: session.endpoint.origin,
186
          token: session.token,
187
          memoryId,
188
        });
189
        yield* output.write(
190
          {
191
            value: { memory },
192
            human: [`Removed memory ${memory.id}.`, `  ${memory.body}`],
193
          },
194
          outputMode(flags.json),
195
        );
196
      }),
197
  ).pipe(Command.withDescription("Remove one memory outright"));
198
199
  return Command.make("memory").pipe(
200
    Command.withDescription(
201
      "Read and write the account's cloud memories. Recall is server-side; these commands do not retrieve",
202
    ),
203
    Command.withSubcommands([memoryListCommand, memoryAddCommand, memoryDeleteCommand]),
204
  );
205
};
packages/openagents-cli/src/runtime.ts modified +3

@@ -20,6 +20,7 @@ import { fleetClientLayer } from "./fleet-client.js";

20 20
import { forumClientLayer } from "./forum-client.js";
21 21
import { gitRunnerLayer } from "./git-runner.js";
22 22
import { issueClientLayer } from "./issue-client.js";
23
import { memoryClientLayer } from "./memory-client.js";
23 24
import { outputLayer } from "./output.js";
24 25
import { persistedConfigurationLayer } from "./persisted-configuration.js";
25 26
import { projectClientLayer } from "./project-client.js";

@@ -38,6 +39,7 @@ const fleetLayer = fleetClientLayer.pipe(Layer.provide(transportLayer));

38 39
const deviceLayer = deviceClientLayer.pipe(Layer.provide(transportLayer));
39 40
const issueLayer = issueClientLayer.pipe(Layer.provide(transportLayer));
40 41
const projectLayer = projectClientLayer.pipe(Layer.provide(transportLayer));
42
const memoryLayer = memoryClientLayer.pipe(Layer.provide(transportLayer));
41 43
const boxClient = boxClientLayer.pipe(Layer.provide(transportLayer));
42 44
const computerClient = computerClientLayer.pipe(Layer.provide(transportLayer));
43 45
const credentialsLayer = credentialStoreOsLayer.pipe(Layer.provide(NodeServices.layer));

@@ -86,6 +88,7 @@ export const runtimeLayer = Layer.mergeAll(

86 88
  fleetLayer,
87 89
  issueLayer,
88 90
  projectLayer,
91
  memoryLayer,
89 92
  deviceLayer,
90 93
  boxClient,
91 94
  computerClient,
packages/openagents-cli/test/memory-command.test.ts added +276

@@ -0,0 +1,276 @@

1
import * as NodeServices from "@effect/platform-node/NodeServices";
2
import { Effect, Layer } from "effect";
3
import { describe, expect, it } from "vitest";
4
5
import { apiTransportTestLayer, type ApiRequest, type ApiResponse } from "../src/api-transport.js";
6
import { runCliWith } from "../src/cli.js";
7
import { credentialStoreUnavailableLayer } from "../src/credential-store.js";
8
import { environmentLayerFromValues } from "../src/environment.js";
9
import { gitRunnerTestLayer } from "../src/git-runner.js";
10
import { memoryClientLayer } from "../src/memory-client.js";
11
import { outputTestLayer, type OutputDocument, type OutputMode } from "../src/output.js";
12
import { persistedConfigurationTestLayer } from "../src/persisted-configuration.js";
13
import { requestBodyInputTestLayer } from "../src/request-body-input.js";
14
import { secretInputTestLayer } from "../src/secret-input.js";
15
import { terminalSessionTestLayer } from "../src/terminal-session.js";
16
17
interface Written {
18
  readonly document: OutputDocument;
19
  readonly mode: OutputMode;
20
}
21
22
const harness = (
23
  handler: (input: ApiRequest) => Effect.Effect<ApiResponse, never>,
24
  environment: Readonly<Record<string, string>> = { token: "test-token" },
25
) => {
26
  const written: Array<Written> = [];
27
  const requests: Array<ApiRequest> = [];
28
  const transport = apiTransportTestLayer((input) => {
29
    requests.push(input);
30
    return handler(input);
31
  });
32
  const layer = Layer.mergeAll(
33
    NodeServices.layer,
34
    environmentLayerFromValues(environment),
35
    persistedConfigurationTestLayer({}),
36
    terminalSessionTestLayer(false),
37
    credentialStoreUnavailableLayer,
38
    gitRunnerTestLayer(() => Effect.void),
39
    secretInputTestLayer("stdin-token"),
40
    requestBodyInputTestLayer({}),
41
    memoryClientLayer.pipe(Layer.provide(transport)),
42
    outputTestLayer((document, mode) =>
43
      Effect.sync(() => {
44
        written.push({ document, mode });
45
      }),
46
    ),
47
  );
48
  return { written, requests, layer };
49
};
50
51
const memoryRow = {
52
  id: "mem_1",
53
  bucket: "user",
54
  body: "Uses pnpm, not npm.",
55
  source_ref: "thread-7",
56
  superseded_by: null,
57
  created_at: "2026-08-25T12:00:00Z",
58
};
59
60
describe("openagents memory CLI commands", () => {
61
  it("lists the account's memories", async () => {
62
    const { layer, written } = harness((req) => {
63
      if (req.path === "/api/v1/memories" && req.method === "GET") {
64
        return Effect.succeed({ status: 200, body: { memories: [memoryRow] } });
65
      }
66
      return Effect.succeed({ status: 404, body: {} });
67
    });
68
69
    await Effect.runPromise(runCliWith(["memory", "list"]).pipe(Effect.provide(layer)));
70
71
    expect(written.length).toBe(1);
72
    const human = written[0]!.document.human;
73
    expect(human.some((line) => line.includes("mem_1"))).toBe(true);
74
    expect(human.some((line) => line.includes("Uses pnpm, not npm."))).toBe(true);
75
  });
76
77
  it("narrows the listing with the bucket, limit, and superseded flags", async () => {
78
    const { layer, requests } = harness((req) => {
79
      if (req.method === "GET" && req.path.startsWith("/api/v1/memories")) {
80
        return Effect.succeed({ status: 200, body: { memories: [] } });
81
      }
82
      return Effect.succeed({ status: 404, body: {} });
83
    });
84
85
    await Effect.runPromise(
86
      runCliWith([
87
        "memory",
88
        "list",
89
        "--bucket",
90
        "learned",
91
        "--limit",
92
        "5",
93
        "--include-superseded",
94
      ]).pipe(Effect.provide(layer)),
95
    );
96
97
    expect(requests[0]!.path).toContain("bucket=learned");
98
    expect(requests[0]!.path).toContain("limit=5");
99
    expect(requests[0]!.path).toContain("include_superseded=true");
100
  });
101
102
  it("says the store is empty rather than printing nothing", async () => {
103
    const { layer, written } = harness(() =>
104
      Effect.succeed({ status: 200, body: { memories: [] } }),
105
    );
106
107
    await Effect.runPromise(runCliWith(["memory", "list"]).pipe(Effect.provide(layer)));
108
109
    expect(written[0]!.document.human.some((line) => line.includes("No memories"))).toBe(true);
110
  });
111
112
  it("stores a memory in the user bucket", async () => {
113
    const { layer, requests, written } = harness((req) => {
114
      if (req.path === "/api/v1/memories" && req.method === "POST") {
115
        return Effect.succeed({ status: 201, body: { memory: memoryRow } });
116
      }
117
      return Effect.succeed({ status: 404, body: {} });
118
    });
119
120
    await Effect.runPromise(
121
      runCliWith(["memory", "add", "Uses", "pnpm,", "not", "npm."]).pipe(Effect.provide(layer)),
122
    );
123
124
    expect(requests[0]!.body).toMatchObject({ body: "Uses pnpm, not npm.", bucket: "user" });
125
    const human = written[0]!.document.human;
126
    expect(human.some((line) => line.includes("Stored memory mem_1"))).toBe(true);
127
  });
128
129
  // A correction is a new row, never an edit: the server has no `PATCH`, and
130
  // the chain a wrong memory was corrected through is what makes it traceable.
131
  it("sends a correction as a new memory carrying supersedes", async () => {
132
    const { layer, requests, written } = harness((req) => {
133
      if (req.path === "/api/v1/memories" && req.method === "POST") {
134
        return Effect.succeed({
135
          status: 201,
136
          body: { memory: { ...memoryRow, id: "mem_2", body: "Uses bun, not pnpm." } },
137
        });
138
      }
139
      return Effect.succeed({ status: 404, body: {} });
140
    });
141
142
    await Effect.runPromise(
143
      runCliWith(["memory", "add", "--supersedes", "mem_1", "Uses", "bun,", "not", "pnpm."]).pipe(
144
        Effect.provide(layer),
145
      ),
146
    );
147
148
    expect(requests[0]!.method).toBe("POST");
149
    expect(requests[0]!.body).toMatchObject({ supersedes: "mem_1" });
150
    expect(written[0]!.document.human.some((line) => line.includes("Supersedes mem_1"))).toBe(true);
151
  });
152
153
  it("removes one memory", async () => {
154
    const { layer, requests, written } = harness((req) => {
155
      if (req.path === "/api/v1/memories/mem_1" && req.method === "DELETE") {
156
        return Effect.succeed({ status: 200, body: { memory: memoryRow } });
157
      }
158
      return Effect.succeed({ status: 404, body: {} });
159
    });
160
161
    await Effect.runPromise(runCliWith(["memory", "delete", "mem_1"]).pipe(Effect.provide(layer)));
162
163
    expect(requests[0]!.method).toBe("DELETE");
164
    expect(written[0]!.document.human.some((line) => line.includes("Removed memory mem_1"))).toBe(
165
      true,
166
    );
167
  });
168
169
  it("reports a memory the server does not hold", async () => {
170
    const { layer } = harness(() =>
171
      Effect.succeed({
172
        status: 404,
173
        body: { code: "not_found", message: "No memory with that id.", errors: {} },
174
      }),
175
    );
176
177
    const failure = await Effect.runPromise(
178
      runCliWith(["memory", "delete", "mem_gone"]).pipe(Effect.provide(layer), Effect.flip),
179
    );
180
181
    expect(JSON.stringify(failure)).toContain("not_found");
182
  });
183
184
  // The refusal has to survive as a refusal. A write that was rejected and
185
  // reported as a success would tell the reader their preference is stored.
186
  it("surfaces a rejected credential rather than reporting a write", async () => {
187
    const { layer, written } = harness(() =>
188
      Effect.succeed({
189
        status: 401,
190
        body: {
191
          code: "unauthenticated",
192
          error: "invalid_api_token",
193
          message: "Requires an API token with the scope this route needs",
194
          errors: {},
195
        },
196
      }),
197
    );
198
199
    const failure = await Effect.runPromise(
200
      runCliWith(["memory", "add", "Uses", "pnpm."]).pipe(Effect.provide(layer), Effect.flip),
201
    );
202
203
    expect(written.length).toBe(0);
204
    expect(JSON.stringify(failure)).toContain("unauthenticated");
205
  });
206
207
  it("surfaces the quota refusal with the reason the server gave", async () => {
208
    const { layer, written } = harness(() =>
209
      Effect.succeed({
210
        status: 429,
211
        body: {
212
          code: "memory_quota_reached",
213
          message:
214
            "This account already holds 200 memories. Remove one, or supersede one, before writing another.",
215
          errors: {},
216
        },
217
      }),
218
    );
219
220
    const failure = await Effect.runPromise(
221
      runCliWith(["memory", "add", "One", "more", "thing."]).pipe(
222
        Effect.provide(layer),
223
        Effect.flip,
224
      ),
225
    );
226
227
    expect(written.length).toBe(0);
228
    const reported = JSON.stringify(failure);
229
    expect(reported).toContain("memory_quota_reached");
230
    expect(reported).toContain("supersede one");
231
  });
232
233
  it("names the rejected field on a validation refusal", async () => {
234
    const { layer } = harness(() =>
235
      Effect.succeed({
236
        status: 422,
237
        body: {
238
          code: "validation_failed",
239
          message: "The request could not be processed",
240
          errors: { supersedes: ["names no live memory of this account"] },
241
        },
242
      }),
243
    );
244
245
    const failure = await Effect.runPromise(
246
      runCliWith(["memory", "add", "--supersedes", "mem_absent", "Something."]).pipe(
247
        Effect.provide(layer),
248
        Effect.flip,
249
      ),
250
    );
251
252
    expect(JSON.stringify(failure)).toContain("names no live memory");
253
  });
254
255
  it("refuses a bucket the server would reject, without a round trip", async () => {
256
    const { layer, requests } = harness(() => Effect.succeed({ status: 200, body: {} }));
257
258
    const failure = await Effect.runPromise(
259
      runCliWith(["memory", "list", "--bucket", "system"]).pipe(Effect.provide(layer), Effect.flip),
260
    );
261
262
    expect(requests.length).toBe(0);
263
    expect(JSON.stringify(failure)).toContain("--bucket");
264
  });
265
266
  it("refuses without a credential rather than writing nowhere", async () => {
267
    const { layer, requests } = harness(() => Effect.succeed({ status: 201, body: {} }), {});
268
269
    const failure = await Effect.runPromise(
270
      runCliWith(["memory", "add", "Uses", "pnpm."]).pipe(Effect.provide(layer), Effect.flip),
271
    );
272
273
    expect(requests.length).toBe(0);
274
    expect(JSON.stringify(failure)).toContain("token");
275
  });
276
});

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