@cursor/july 0.1.23 → 0.1.25

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cursor/july",
3
- "version": "0.1.23",
3
+ "version": "0.1.25",
4
4
  "description": "(early alpha) Filesystem-first framework for defining Cursor agents as markdown and TypeScript and serving them over channels with the Cursor SDK.",
5
5
  "license": "SEE LICENSE IN LICENSE.md",
6
6
  "repository": {
@@ -147,6 +147,13 @@
147
147
  "import": "./dist/ab.js",
148
148
  "default": "./dist/ab.js"
149
149
  },
150
+ "./artifacts": {
151
+ "anysphere-source": "./src/artifacts.ts",
152
+ "bun": "./src/artifacts.ts",
153
+ "types": "./dist/artifacts.d.ts",
154
+ "import": "./dist/artifacts.js",
155
+ "default": "./dist/artifacts.js"
156
+ },
150
157
  "./storage": {
151
158
  "anysphere-source": "./src/storage.ts",
152
159
  "bun": "./src/storage.ts",
@@ -0,0 +1,78 @@
1
+ /**
2
+ * `defineArtifacts` — authored at `agent/artifacts.ts`.
3
+ *
4
+ * Artifacts mark durable outputs the agent produced (reviewed PR URLs,
5
+ * reports, …) so they can be listed across sessions and rendered in the
6
+ * playground. Declare artifact *kinds* here; host code tags via
7
+ * `ctx.artifacts.tag(...)` (tools, hooks, and channel handlers all carry
8
+ * an {@link ArtifactsApi}), and `agentTool: true` exposes the model-facing
9
+ * `tag_artifact` built-in tool generated from the same kinds registry.
10
+ *
11
+ * ```ts
12
+ * import { z } from "zod";
13
+ * import { defineArtifacts } from "@anysphere/agent-serve/artifacts";
14
+ *
15
+ * export default defineArtifacts({
16
+ * kinds: {
17
+ * "reviewed-pr": {
18
+ * description: "A pull request this agent reviewed.",
19
+ * schema: z.object({ url: z.string(), verdict: z.string() }),
20
+ * },
21
+ * report: { description: "A generated report." },
22
+ * },
23
+ * agentTool: true,
24
+ * });
25
+ * ```
26
+ */
27
+
28
+ // TODO(artifacts-files): file/blob payloads land on the Agent Stores mount
29
+ // (any files, fuse-mounted everywhere, survives deploys) once it ships —
30
+ // same migration as TODO(agent-stores). Serve via GET
31
+ // /v1/artifacts/:id/content; cloud-runtime tags keep the VM-native
32
+ // /opt/cursor/artifacts + cursor.com artifact URLs.
33
+
34
+ import { brandDefinition } from "./internal/brand.js";
35
+ import type { ArtifactsConfig, ArtifactsDefinition } from "./types.js";
36
+
37
+ export type {
38
+ ArtifactKindConfig,
39
+ ArtifactListFilter,
40
+ ArtifactRecord,
41
+ ArtifactSource,
42
+ ArtifactsApi,
43
+ ArtifactsConfig,
44
+ ArtifactsDefinition,
45
+ ArtifactTagInput,
46
+ } from "./types.js";
47
+
48
+ /** Default {@link ArtifactsConfig.max} retention cap. */
49
+ export const ARTIFACTS_DEFAULT_MAX = 1_000;
50
+
51
+ export function defineArtifacts(config: ArtifactsConfig): ArtifactsDefinition {
52
+ for (const [kind, kindConfig] of Object.entries(config.kinds ?? {})) {
53
+ if (kind.trim() === "") {
54
+ throw new Error("defineArtifacts: kind names must be non-empty");
55
+ }
56
+ if (
57
+ typeof kindConfig.description !== "string" ||
58
+ kindConfig.description.trim() === ""
59
+ ) {
60
+ throw new Error(
61
+ `defineArtifacts: kinds.${kind}.description must be a non-empty string`
62
+ );
63
+ }
64
+ }
65
+ if (config.max !== undefined) {
66
+ if (!Number.isInteger(config.max) || config.max < 1) {
67
+ throw new Error("defineArtifacts: max must be a positive integer");
68
+ }
69
+ }
70
+ return brandDefinition("artifacts", config);
71
+ }
72
+
73
+ /** Effective retention cap for a config (default {@link ARTIFACTS_DEFAULT_MAX}). */
74
+ export function resolveArtifactsMax(
75
+ config: ArtifactsConfig | undefined
76
+ ): number {
77
+ return config?.max ?? ARTIFACTS_DEFAULT_MAX;
78
+ }
@@ -787,6 +787,12 @@ function handlerArgs(send: SendMessageFn): ChannelHandlerArgs {
787
787
  delete: async () => {},
788
788
  },
789
789
  },
790
+ artifacts: {
791
+ tag: async () => {
792
+ throw new Error("artifacts not configured in test");
793
+ },
794
+ list: async () => [],
795
+ },
790
796
  send,
791
797
  getSession: async () => null,
792
798
  receive: async () => fakeSession(),
@@ -210,6 +210,43 @@ describe("run-level assertions", () => {
210
210
  );
211
211
  });
212
212
 
213
+ it("taggedArtifact matches artifact.tagged events by kind and predicate", () => {
214
+ const { recorder, t } = setup([
215
+ ...weatherRun(),
216
+ event("artifact.tagged", {
217
+ id: "art_1",
218
+ kind: "reviewed-pr",
219
+ key: "pr-1",
220
+ title: "PR #1",
221
+ data: { url: "https://github.com/org/repo/pull/1" },
222
+ source: "model",
223
+ }),
224
+ ]);
225
+ t.taggedArtifact();
226
+ t.taggedArtifact("reviewed-pr");
227
+ t.taggedArtifact("report");
228
+ t.taggedArtifact("reviewed-pr", (record) => record.source === "model");
229
+ t.taggedArtifact("reviewed-pr", (record) => record.key === "other");
230
+ const results = recorder.results();
231
+ expect(results.map((r) => r.passed)).toEqual([
232
+ true,
233
+ true,
234
+ false,
235
+ true,
236
+ false,
237
+ ]);
238
+ expect(results[1]?.name).toBe("taggedArtifact(reviewed-pr)");
239
+ expect(results[2]?.detail).toBe("tagged kinds: [reviewed-pr]");
240
+ });
241
+
242
+ it("taggedArtifact fails when nothing was tagged", () => {
243
+ const { recorder, t } = setup();
244
+ t.taggedArtifact();
245
+ const [result] = recorder.results();
246
+ expect(result?.passed).toBe(false);
247
+ expect(result?.detail).toBe("tagged kinds: []");
248
+ });
249
+
213
250
  it("notCalledTool matches any lifecycle state", () => {
214
251
  const { recorder, t } = setup();
215
252
  t.notCalledTool("echo");
@@ -15,7 +15,7 @@
15
15
  * by `t.send(...)`, which narrows the events under inspection to that turn.
16
16
  */
17
17
 
18
- import type { SessionEvent } from "../types.js";
18
+ import type { SessionEvent, SessionEventPayload } from "../types.js";
19
19
  import type { Expectation } from "./expect.js";
20
20
  import { evaluateExpectation, expectationSeverity } from "./expect.js";
21
21
  import type {
@@ -37,6 +37,12 @@ import { deriveRunFacts } from "./run-facts.js";
37
37
  /** Hard gates fail the eval; soft assertions are tracked scores. */
38
38
  export type EvalAssertionSeverity = "gate" | "soft";
39
39
 
40
+ /** One `artifact.tagged` payload, as seen by `t.taggedArtifact(...)`. */
41
+ export type EvalTaggedArtifactFact = Extract<
42
+ SessionEventPayload,
43
+ { type: "artifact.tagged" }
44
+ >["data"];
45
+
40
46
  /**
41
47
  * Overall grade for one eval case.
42
48
  *
@@ -314,6 +320,14 @@ export interface EvalAssertions {
314
320
  name: string,
315
321
  matcher?: EvalSubagentMatcher
316
322
  ): EvalAssertionHandle;
323
+ /**
324
+ * Gate: at least one artifact was tagged (`ctx.artifacts.tag` or the
325
+ * `tag_artifact` built-in), optionally of `kind` and matching `predicate`.
326
+ */
327
+ taggedArtifact(
328
+ kind?: string,
329
+ predicate?: (record: EvalTaggedArtifactFact) => boolean
330
+ ): EvalAssertionHandle;
317
331
  /** Gate: at least one matching event of `type` occurred. */
318
332
  event(type: string, matcher?: EvalEventMatcher): EvalAssertionHandle;
319
333
  /** Gate: no matching event of `type` occurred. */
@@ -567,6 +581,28 @@ export function createAssertions(
567
581
  });
568
582
  },
569
583
 
584
+ taggedArtifact(
585
+ kind?: string,
586
+ predicate?: (record: EvalTaggedArtifactFact) => boolean
587
+ ) {
588
+ const tagged = events().flatMap((event) =>
589
+ event.type === "artifact.tagged" ? [event.data] : []
590
+ );
591
+ const matched = tagged.filter(
592
+ (record) =>
593
+ (kind === undefined || record.kind === kind) &&
594
+ (predicate === undefined || predicate(record) === true)
595
+ );
596
+ return record({
597
+ name: `taggedArtifact(${kind ?? ""})`,
598
+ passed: matched.length > 0,
599
+ detail:
600
+ matched.length > 0
601
+ ? undefined
602
+ : `tagged kinds: [${tagged.map((r) => r.kind).join(", ")}]`,
603
+ });
604
+ },
605
+
570
606
  event(type: string, matcher?: EvalEventMatcher) {
571
607
  const matched = matchingEvents(type, matcher);
572
608
  const passed = matchCount(matched.length, matcher?.count);
package/src/evals.ts CHANGED
@@ -84,6 +84,7 @@ export type {
84
84
  EvalAssertionResult,
85
85
  EvalAssertionSeverity,
86
86
  EvalAssertions,
87
+ EvalTaggedArtifactFact,
87
88
  EvalVerdict,
88
89
  } from "./evals/assertions.js";
89
90
  export { computeVerdict, verdictFailsRun } from "./evals/assertions.js";
package/src/index.ts CHANGED
@@ -32,6 +32,7 @@ import type {
32
32
  } from "./types.js";
33
33
 
34
34
  export { defineAB } from "./ab.js";
35
+ export { defineArtifacts } from "./artifacts.js";
35
36
  // Authoring helpers (also available on their focused subpath exports).
36
37
  export {
37
38
  allowAll,
@@ -0,0 +1,283 @@
1
+ import { mkdtemp, rm, writeFile } from "node:fs/promises";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { afterEach, describe, expect, it } from "vitest";
5
+ import { z } from "zod";
6
+ import { defineArtifacts } from "../artifacts.js";
7
+ import { defineStorage, storageKeys } from "../storage.js";
8
+ import type { JsonValue } from "../types.js";
9
+ import { ArtifactsStore, artifactIdForKey } from "./artifacts-store.js";
10
+ import { StorageCoordinator } from "./storage-coordinator.js";
11
+
12
+ const AGENT = "artifacts-test";
13
+
14
+ const cleanups: Array<() => Promise<void>> = [];
15
+
16
+ afterEach(async () => {
17
+ while (cleanups.length > 0) {
18
+ await cleanups.pop()?.();
19
+ }
20
+ });
21
+
22
+ async function tempStateRoot(): Promise<string> {
23
+ const dir = await mkdtemp(join(tmpdir(), "agent-serve-artifacts-"));
24
+ cleanups.push(() => rm(dir, { recursive: true, force: true }));
25
+ return dir;
26
+ }
27
+
28
+ /** Distinct-millisecond updatedAt stamps for eviction/order assertions. */
29
+ function tick(): Promise<void> {
30
+ return new Promise((resolve) => setTimeout(resolve, 3));
31
+ }
32
+
33
+ describe("ArtifactsStore", () => {
34
+ it("upserts by key: same key ⇒ same id, replaced row, preserved createdAt", async () => {
35
+ const store = new ArtifactsStore({
36
+ stateRoot: await tempStateRoot(),
37
+ });
38
+
39
+ const first = await store.tag({
40
+ kind: "report",
41
+ key: "weekly",
42
+ data: { rev: 1 },
43
+ });
44
+ await tick();
45
+ const second = await store.tag({
46
+ kind: "report",
47
+ key: "weekly",
48
+ data: { rev: 2 },
49
+ title: "Weekly report",
50
+ });
51
+
52
+ expect(second.id).toBe(first.id);
53
+ expect(second.id).toBe(artifactIdForKey("weekly"));
54
+ expect(second.createdAt).toBe(first.createdAt);
55
+ expect(second.updatedAt > first.updatedAt).toBe(true);
56
+
57
+ const rows = await store.list();
58
+ expect(rows).toHaveLength(1);
59
+ expect(rows[0]?.data).toEqual({ rev: 2 });
60
+ expect(rows[0]?.title).toBe("Weekly report");
61
+ });
62
+
63
+ it("gives keyless tags fresh random ids", async () => {
64
+ const store = new ArtifactsStore({
65
+ stateRoot: await tempStateRoot(),
66
+ });
67
+ const a = await store.tag({ data: 1 });
68
+ const b = await store.tag({ data: 2 });
69
+ expect(a.id).not.toBe(b.id);
70
+ expect(await store.list()).toHaveLength(2);
71
+ });
72
+
73
+ it("validates data against the declared kind schema", async () => {
74
+ const store = new ArtifactsStore({
75
+ stateRoot: await tempStateRoot(),
76
+ config: defineArtifacts({
77
+ kinds: {
78
+ "reviewed-pr": {
79
+ description: "A reviewed PR.",
80
+ schema: z.object({ url: z.string() }),
81
+ },
82
+ note: { description: "Schemaless note." },
83
+ },
84
+ }),
85
+ });
86
+
87
+ const ok = await store.tag({
88
+ kind: "reviewed-pr",
89
+ data: { url: "https://github.com/org/repo/pull/1" },
90
+ });
91
+ expect(ok.kind).toBe("reviewed-pr");
92
+
93
+ await expect(
94
+ store.tag({ kind: "reviewed-pr", data: { nope: true } })
95
+ ).rejects.toThrow(/does not match the "reviewed-pr" schema/);
96
+
97
+ // A kind without a schema accepts any payload.
98
+ await expect(
99
+ store.tag({ kind: "note", data: "free text" })
100
+ ).resolves.toMatchObject({ kind: "note" });
101
+ });
102
+
103
+ it("persists the schema's parsed output (defaults applied)", async () => {
104
+ const store = new ArtifactsStore({
105
+ stateRoot: await tempStateRoot(),
106
+ config: defineArtifacts({
107
+ kinds: {
108
+ report: {
109
+ description: "Report.",
110
+ schema: z.object({
111
+ name: z.string(),
112
+ status: z.string().default("draft"),
113
+ }),
114
+ },
115
+ },
116
+ }),
117
+ });
118
+ const record = await store.tag({ kind: "report", data: { name: "q3" } });
119
+ expect(record.data).toEqual({ name: "q3", status: "draft" });
120
+ expect((await store.list())[0]?.data).toEqual({
121
+ name: "q3",
122
+ status: "draft",
123
+ });
124
+ });
125
+
126
+ it("rejects unknown kinds when kinds are declared", async () => {
127
+ const store = new ArtifactsStore({
128
+ stateRoot: await tempStateRoot(),
129
+ config: defineArtifacts({
130
+ kinds: { note: { description: "Note." } },
131
+ }),
132
+ });
133
+ await expect(store.tag({ kind: "mystery", data: 1 })).rejects.toThrow(
134
+ /unknown kind "mystery" \(declared: note\)/
135
+ );
136
+ });
137
+
138
+ it('accepts freeform kinds (default "artifact") with zero config', async () => {
139
+ const store = new ArtifactsStore({
140
+ stateRoot: await tempStateRoot(),
141
+ });
142
+ const defaulted = await store.tag({ data: { anything: true } });
143
+ expect(defaulted.kind).toBe("artifact");
144
+ expect(defaulted.source).toBe("host");
145
+ const custom = await store.tag({ kind: "whatever", data: null });
146
+ expect(custom.kind).toBe("whatever");
147
+ });
148
+
149
+ it("evicts the oldest-updated row past the max cap", async () => {
150
+ const store = new ArtifactsStore({
151
+ stateRoot: await tempStateRoot(),
152
+ config: defineArtifacts({ max: 2 }),
153
+ });
154
+
155
+ await store.tag({ key: "a", data: "a" });
156
+ await tick();
157
+ await store.tag({ key: "b", data: "b" });
158
+ await tick();
159
+ // Refreshing "a" makes "b" the oldest-updated row.
160
+ await store.tag({ key: "a", data: "a2" });
161
+ await tick();
162
+ await store.tag({ key: "c", data: "c" });
163
+
164
+ const keys = (await store.list()).map((row) => row.key);
165
+ expect(keys.sort()).toEqual(["a", "c"]);
166
+ });
167
+
168
+ it("keeps exactly max rows under concurrent inserts", async () => {
169
+ const store = new ArtifactsStore({
170
+ stateRoot: await tempStateRoot(),
171
+ config: defineArtifacts({ max: 3 }),
172
+ });
173
+ await Promise.all(
174
+ Array.from({ length: 8 }, (_, i) => store.tag({ data: i }))
175
+ );
176
+ expect(await store.list()).toHaveLength(3);
177
+ });
178
+
179
+ it("skips corrupt row files instead of failing list", async () => {
180
+ const stateRoot = await tempStateRoot();
181
+ const store = new ArtifactsStore({ stateRoot });
182
+ await store.tag({ key: "good", data: 1 });
183
+ await writeFile(
184
+ join(stateRoot, "artifacts", "corrupt.json"),
185
+ "{not json",
186
+ "utf8"
187
+ );
188
+
189
+ const rows = await store.list();
190
+ expect(rows).toHaveLength(1);
191
+ expect(rows[0]?.key).toBe("good");
192
+ // Inserts (and their eviction scan) keep working too.
193
+ await expect(store.tag({ key: "more", data: 2 })).resolves.toBeDefined();
194
+ });
195
+
196
+ it("filters list by kind and sessionId, newest-updated first", async () => {
197
+ const store = new ArtifactsStore({
198
+ stateRoot: await tempStateRoot(),
199
+ });
200
+ await store.tag({ kind: "report", data: 1, sessionId: "ses_1" });
201
+ await tick();
202
+ await store.tag({ kind: "report", data: 2, sessionId: "ses_2" });
203
+ await tick();
204
+ await store.tag({ kind: "note", data: 3, sessionId: "ses_1" });
205
+
206
+ expect(await store.list({ kind: "report" })).toHaveLength(2);
207
+ expect(await store.list({ sessionId: "ses_1" })).toHaveLength(2);
208
+ expect(
209
+ await store.list({ kind: "report", sessionId: "ses_1" })
210
+ ).toHaveLength(1);
211
+
212
+ const all = await store.list();
213
+ expect(all.map((row) => row.data)).toEqual([3, 2, 1]);
214
+ });
215
+
216
+ it("persists rows through defineStorage under storageKeys.artifact", async () => {
217
+ const backing = new Map<string, JsonValue>();
218
+ const coordinator = new StorageCoordinator({
219
+ definition: defineStorage({
220
+ put: (key, value) => {
221
+ backing.set(key, structuredClone(value));
222
+ },
223
+ get: (key) => backing.get(key),
224
+ delete: (key) => {
225
+ backing.delete(key);
226
+ },
227
+ list: (prefix) =>
228
+ [...backing.entries()]
229
+ .filter(([key]) => key.startsWith(prefix))
230
+ .sort(([a], [b]) => (a < b ? -1 : 1))
231
+ .map(([key, value]) => ({ key, value })),
232
+ }),
233
+ agentName: AGENT,
234
+ projectRoot: "/tmp",
235
+ logger: () => {},
236
+ });
237
+ const store = new ArtifactsStore({
238
+ stateRoot: await tempStateRoot(),
239
+ storage: coordinator,
240
+ });
241
+
242
+ const record = await store.tag({ key: "pr-1", data: { url: "u" } });
243
+ expect(backing.get(storageKeys.artifact(AGENT, record.id))).toMatchObject({
244
+ id: record.id,
245
+ key: "pr-1",
246
+ });
247
+
248
+ await tick();
249
+ await store.tag({ key: "pr-1", data: { url: "u2" } });
250
+ expect([...backing.keys()]).toHaveLength(1);
251
+ expect((await store.list())[0]?.data).toEqual({ url: "u2" });
252
+ });
253
+
254
+ it("falls back to filesystem rows when the sink cannot delete", async () => {
255
+ const backing = new Map<string, JsonValue>();
256
+ // No `delete`: the sink could never evict past the cap, so artifact
257
+ // rows must not land there.
258
+ const coordinator = new StorageCoordinator({
259
+ definition: defineStorage({
260
+ put: (key, value) => {
261
+ backing.set(key, structuredClone(value));
262
+ },
263
+ get: (key) => backing.get(key),
264
+ list: (prefix) =>
265
+ [...backing.entries()]
266
+ .filter(([key]) => key.startsWith(prefix))
267
+ .map(([key, value]) => ({ key, value })),
268
+ }),
269
+ agentName: AGENT,
270
+ projectRoot: "/tmp",
271
+ logger: () => {},
272
+ });
273
+ expect(coordinator.supportsArtifacts).toBe(false);
274
+
275
+ const store = new ArtifactsStore({
276
+ stateRoot: await tempStateRoot(),
277
+ storage: coordinator,
278
+ });
279
+ await store.tag({ key: "local", data: 1 });
280
+ expect(backing.size).toBe(0);
281
+ expect(await store.list()).toHaveLength(1);
282
+ });
283
+ });