@cursor/july 0.1.21 → 0.1.23

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.
Files changed (119) hide show
  1. package/README.md +5 -5
  2. package/dist/bin/agent-serve.d.ts +1 -0
  3. package/dist/bin/agent-serve.d.ts.map +1 -1
  4. package/dist/bin/agent-serve.js +21 -3
  5. package/dist/channels/slack/bot-mentions.d.ts +2 -0
  6. package/dist/channels/slack/bot-mentions.d.ts.map +1 -1
  7. package/dist/channels/slack/bot-mentions.js +29 -8
  8. package/dist/channels/slack/types.d.ts +13 -2
  9. package/dist/channels/slack/types.d.ts.map +1 -1
  10. package/dist/docs/404.html +2 -2
  11. package/dist/docs/ab.html +3 -3
  12. package/dist/docs/assets/{app.jDxLzWv4.js → app.DYcC9FY-.js} +1 -1
  13. package/dist/docs/assets/chunks/@localSearchIndexroot.BQTzJjR_.js +1 -0
  14. package/dist/docs/assets/chunks/{VPLocalSearchBox.Y6bDR1-a.js → VPLocalSearchBox.o4N_knTV.js} +1 -1
  15. package/dist/docs/assets/chunks/{theme.CLazCWlJ.js → theme.DQ-njyo0.js} +2 -2
  16. package/dist/docs/assets/index.md.Dfv5ic9t.js +20 -0
  17. package/dist/docs/assets/{index.md.t0TM2Qzz.lean.js → index.md.Dfv5ic9t.lean.js} +1 -1
  18. package/dist/docs/assets/{reference_cli.md.DnYfr5V2.js → reference_cli.md.ccoKOoXt.js} +4 -3
  19. package/dist/docs/assets/{reference_cli.md.DnYfr5V2.lean.js → reference_cli.md.ccoKOoXt.lean.js} +1 -1
  20. package/dist/docs/building-with-agents.html +3 -3
  21. package/dist/docs/concepts.html +3 -3
  22. package/dist/docs/deployment.html +3 -3
  23. package/dist/docs/evals.html +3 -3
  24. package/dist/docs/example-agents/approval-buddy.html +3 -3
  25. package/dist/docs/example-agents/benny.html +3 -3
  26. package/dist/docs/example-agents/bugbot.html +3 -3
  27. package/dist/docs/example-agents/codebase-wiki.html +3 -3
  28. package/dist/docs/example-agents/codeowners-review.html +3 -3
  29. package/dist/docs/example-agents/concierge.html +3 -3
  30. package/dist/docs/example-agents/fsd.html +3 -3
  31. package/dist/docs/example-agents/index.html +3 -3
  32. package/dist/docs/example-agents/knowledge-base.html +3 -3
  33. package/dist/docs/example-agents/oncall.html +3 -3
  34. package/dist/docs/example-agents/security-reviewer.html +3 -3
  35. package/dist/docs/example-agents/slack-agent.html +3 -3
  36. package/dist/docs/example-agents/weather-agent.html +3 -3
  37. package/dist/docs/guides/agent-to-agent.html +3 -3
  38. package/dist/docs/guides/cloud-runtime.html +3 -3
  39. package/dist/docs/guides/github.html +3 -3
  40. package/dist/docs/guides/human-in-the-loop.html +3 -3
  41. package/dist/docs/guides/mcp-oauth.html +3 -3
  42. package/dist/docs/guides/slack.html +3 -3
  43. package/dist/docs/guides/webhooks.html +3 -3
  44. package/dist/docs/hashmap.json +1 -1
  45. package/dist/docs/hillclimbing.html +3 -3
  46. package/dist/docs/index.html +6 -6
  47. package/dist/docs/quickstart.html +3 -3
  48. package/dist/docs/reference/agent-config.html +3 -3
  49. package/dist/docs/reference/channels.html +3 -3
  50. package/dist/docs/reference/cli.html +7 -6
  51. package/dist/docs/reference/connections.html +3 -3
  52. package/dist/docs/reference/hooks.html +3 -3
  53. package/dist/docs/reference/http-api.html +3 -3
  54. package/dist/docs/reference/instructions.html +3 -3
  55. package/dist/docs/reference/playground.html +3 -3
  56. package/dist/docs/reference/project-layout.html +3 -3
  57. package/dist/docs/reference/prompt.html +3 -3
  58. package/dist/docs/reference/schedules.html +3 -3
  59. package/dist/docs/reference/sessions.html +3 -3
  60. package/dist/docs/reference/skills.html +3 -3
  61. package/dist/docs/reference/subagents.html +3 -3
  62. package/dist/docs/reference/tools.html +3 -3
  63. package/dist/docs/scaffolding-agents.html +3 -3
  64. package/dist/docs/storage.html +3 -3
  65. package/dist/docs/troubleshooting.html +3 -3
  66. package/dist/index.d.ts +2 -0
  67. package/dist/index.d.ts.map +1 -1
  68. package/dist/index.js +1 -0
  69. package/dist/internal/cli-docs.d.ts +34 -0
  70. package/dist/internal/cli-docs.d.ts.map +1 -0
  71. package/dist/internal/cli-docs.js +162 -0
  72. package/dist/internal/distribution.d.ts +2 -1
  73. package/dist/internal/distribution.d.ts.map +1 -1
  74. package/dist/internal/distribution.js +3 -1
  75. package/dist/internal/docs-site.d.ts +4 -2
  76. package/dist/internal/docs-site.d.ts.map +1 -1
  77. package/dist/internal/docs-site.js +15 -11
  78. package/dist/internal/init-project.d.ts.map +1 -1
  79. package/dist/internal/init-project.js +19 -0
  80. package/dist/internal/local-env.d.ts +6 -4
  81. package/dist/internal/local-env.d.ts.map +1 -1
  82. package/dist/internal/local-env.js +14 -6
  83. package/dist/internal/session-engine.d.ts.map +1 -1
  84. package/dist/internal/session-engine.js +5 -0
  85. package/dist/internal/workspace.d.ts +9 -0
  86. package/dist/internal/workspace.d.ts.map +1 -1
  87. package/dist/internal/workspace.js +52 -5
  88. package/dist/memory.d.ts +79 -0
  89. package/dist/memory.d.ts.map +1 -0
  90. package/dist/memory.js +164 -0
  91. package/dist/playground/assets/index-DqXdAFGa.js +85 -0
  92. package/dist/playground/index.html +1 -1
  93. package/dist/types.d.ts +11 -0
  94. package/dist/types.d.ts.map +1 -1
  95. package/docs/README.md +8 -1
  96. package/docs/reference/cli.md +20 -4
  97. package/package.json +9 -1
  98. package/src/bin/agent-serve.ts +23 -3
  99. package/src/bin/agent-serve.version.test.ts +2 -0
  100. package/src/channels/slack/bot-mentions.test.ts +50 -0
  101. package/src/channels/slack/bot-mentions.ts +26 -2
  102. package/src/channels/slack/types.ts +13 -2
  103. package/src/index.ts +2 -0
  104. package/src/internal/cli-docs.test.ts +161 -0
  105. package/src/internal/cli-docs.ts +191 -0
  106. package/src/internal/distribution.ts +3 -1
  107. package/src/internal/docs-site.ts +19 -11
  108. package/src/internal/init-project.test.ts +1 -0
  109. package/src/internal/init-project.ts +22 -0
  110. package/src/internal/local-env.test.ts +20 -0
  111. package/src/internal/local-env.ts +14 -6
  112. package/src/internal/session-engine.ts +5 -0
  113. package/src/internal/workspace.test.ts +127 -1
  114. package/src/internal/workspace.ts +81 -5
  115. package/src/memory.ts +215 -0
  116. package/src/types.ts +11 -0
  117. package/dist/docs/assets/chunks/@localSearchIndexroot.DoJHJjqF.js +0 -1
  118. package/dist/docs/assets/index.md.t0TM2Qzz.js +0 -20
  119. package/dist/playground/assets/index-dshZQJCp.js +0 -85
@@ -1,11 +1,21 @@
1
- import { mkdir, mkdtemp, rm } from "node:fs/promises";
1
+ import {
2
+ lstat,
3
+ mkdir,
4
+ mkdtemp,
5
+ readlink,
6
+ rm,
7
+ writeFile,
8
+ } from "node:fs/promises";
2
9
  import { tmpdir } from "node:os";
3
10
  import { join } from "node:path";
4
11
  import { afterEach, describe, expect, it } from "vitest";
12
+ import type { ResolvedAgent } from "../types.js";
5
13
  import {
6
14
  buildIdentityPreamble,
7
15
  isNestedInGitRepo,
16
+ materializeWorkspace,
8
17
  withIdentityPreamble,
18
+ writeWorkspaceFiles,
9
19
  } from "./workspace.js";
10
20
 
11
21
  describe("buildIdentityPreamble", () => {
@@ -79,3 +89,119 @@ describe("isNestedInGitRepo", () => {
79
89
  expect(await isNestedInGitRepo(dir)).toBe(false);
80
90
  });
81
91
  });
92
+
93
+ describe("materializeWorkspace memory link", () => {
94
+ const cleanups: Array<() => Promise<void>> = [];
95
+
96
+ afterEach(async () => {
97
+ while (cleanups.length > 0) {
98
+ await cleanups.pop()?.();
99
+ }
100
+ });
101
+
102
+ async function makeTempDir(): Promise<string> {
103
+ const dir = await mkdtemp(join(tmpdir(), "agent-serve-workspace-"));
104
+ cleanups.push(() => rm(dir, { recursive: true, force: true }));
105
+ return dir;
106
+ }
107
+
108
+ const agent: ResolvedAgent = {
109
+ name: "test-agent",
110
+ runtime: "local",
111
+ tools: [],
112
+ skills: [],
113
+ connections: [],
114
+ subagents: [],
115
+ seedFiles: [],
116
+ };
117
+
118
+ it("symlinks <stateRoot>/memory into the workspace", async () => {
119
+ const root = await makeTempDir();
120
+ const workspaceDir = join(root, "workspace");
121
+ const stateRoot = join(root, "state");
122
+ await materializeWorkspace({ agent, workspaceDir, seed: true, stateRoot });
123
+
124
+ const link = join(workspaceDir, "memory");
125
+ expect((await lstat(link)).isSymbolicLink()).toBe(true);
126
+ expect(await readlink(link)).toBe(join(stateRoot, "memory"));
127
+ // The target directory is created eagerly so first sessions can read it.
128
+ expect((await lstat(join(stateRoot, "memory"))).isDirectory()).toBe(true);
129
+ });
130
+
131
+ it("is idempotent and leaves an existing memory entry alone", async () => {
132
+ const root = await makeTempDir();
133
+ const workspaceDir = join(root, "workspace");
134
+ const stateRoot = join(root, "state");
135
+ await mkdir(workspaceDir, { recursive: true });
136
+ await writeFile(join(workspaceDir, "memory"), "not a link", "utf8");
137
+
138
+ await materializeWorkspace({ agent, workspaceDir, seed: true, stateRoot });
139
+ await materializeWorkspace({ agent, workspaceDir, seed: false, stateRoot });
140
+
141
+ expect((await lstat(join(workspaceDir, "memory"))).isFile()).toBe(true);
142
+ });
143
+
144
+ it("re-points a stale link when the state root moved", async () => {
145
+ const root = await makeTempDir();
146
+ const workspaceDir = join(root, "workspace");
147
+ await materializeWorkspace({
148
+ agent,
149
+ workspaceDir,
150
+ seed: true,
151
+ stateRoot: join(root, "old-state"),
152
+ });
153
+ const stateRoot = join(root, "new-state");
154
+ await materializeWorkspace({ agent, workspaceDir, seed: true, stateRoot });
155
+
156
+ expect(await readlink(join(workspaceDir, "memory"))).toBe(
157
+ join(stateRoot, "memory")
158
+ );
159
+ });
160
+
161
+ it("refuses workspace writes into the reserved memory path", async () => {
162
+ const root = await makeTempDir();
163
+ const workspaceDir = join(root, "workspace");
164
+ const stateRoot = join(root, "state");
165
+ await materializeWorkspace({ agent, workspaceDir, seed: true, stateRoot });
166
+
167
+ await expect(
168
+ writeWorkspaceFiles(workspaceDir, {
169
+ "memory/journal.jsonl": "poisoned",
170
+ })
171
+ ).rejects.toThrow(/reserved/);
172
+ await expect(
173
+ writeWorkspaceFiles(workspaceDir, { memory: "clobbered" })
174
+ ).rejects.toThrow(/reserved/);
175
+ // Normalized relatives must not slip past the reservation.
176
+ await expect(
177
+ writeWorkspaceFiles(workspaceDir, { "./memory/x": "poisoned" })
178
+ ).rejects.toThrow(/reserved/);
179
+ await expect(
180
+ writeWorkspaceFiles(workspaceDir, { "foo/../memory/x": "poisoned" })
181
+ ).rejects.toThrow(/reserved/);
182
+
183
+ await expect(
184
+ materializeWorkspace({
185
+ agent: {
186
+ ...agent,
187
+ seedFiles: [
188
+ { relativePath: "memory/seed.txt", sourcePath: "/dev/null" },
189
+ ],
190
+ },
191
+ workspaceDir,
192
+ seed: true,
193
+ stateRoot,
194
+ })
195
+ ).rejects.toThrow(/reserved/);
196
+ });
197
+
198
+ it("only links on seed turns", async () => {
199
+ const root = await makeTempDir();
200
+ const workspaceDir = join(root, "workspace");
201
+ const stateRoot = join(root, "state");
202
+ await materializeWorkspace({ agent, workspaceDir, seed: false, stateRoot });
203
+ await expect(lstat(join(workspaceDir, "memory"))).rejects.toMatchObject({
204
+ code: "ENOENT",
205
+ });
206
+ });
207
+ });
@@ -7,8 +7,19 @@
7
7
  * all of this up natively through its project setting source.
8
8
  */
9
9
 
10
- import { chmod, copyFile, mkdir, stat, writeFile } from "node:fs/promises";
11
- import { dirname, join, parse, relative, resolve } from "node:path";
10
+ import {
11
+ chmod,
12
+ copyFile,
13
+ lstat,
14
+ mkdir,
15
+ readlink,
16
+ rm,
17
+ stat,
18
+ symlink,
19
+ writeFile,
20
+ } from "node:fs/promises";
21
+ import { dirname, join, parse, relative, resolve, sep } from "node:path";
22
+ import { MEMORY_DIR_NAME } from "../memory.js";
12
23
  import type {
13
24
  DiscoveredSkill,
14
25
  DiscoveredTool,
@@ -25,6 +36,15 @@ export interface MaterializeWorkspaceOptions {
25
36
  * skills are always rewritten so authored edits show up on the next turn.
26
37
  */
27
38
  seed: boolean;
39
+ /**
40
+ * Agent state root. `<stateRoot>/memory/` (the durable cross-session
41
+ * directory the memory hook journals into) is created and symlinked into
42
+ * the workspace as `memory` so the agent reads it with plain file tools.
43
+ *
44
+ * TODO(agent-store): replace with the mounted AgentStore path so cloud
45
+ * VMs see the same directory.
46
+ */
47
+ stateRoot: string;
28
48
  }
29
49
 
30
50
  /** Relative path of an agent-side tool script inside the session workspace. */
@@ -172,8 +192,11 @@ export async function isNestedInGitRepo(dir: string): Promise<boolean> {
172
192
  export async function materializeWorkspace(
173
193
  options: MaterializeWorkspaceOptions
174
194
  ): Promise<void> {
175
- const { agent, workspaceDir, seed } = options;
195
+ const { agent, workspaceDir, seed, stateRoot } = options;
176
196
  await mkdir(workspaceDir, { recursive: true });
197
+ if (seed) {
198
+ await linkSharedMemoryDir(workspaceDir, stateRoot);
199
+ }
177
200
 
178
201
  const agentsMd = buildAgentsMdContent(agent, { includeScripts: false });
179
202
  await writeFile(join(workspaceDir, "AGENTS.md"), agentsMd, "utf8");
@@ -191,7 +214,10 @@ export async function materializeWorkspace(
191
214
 
192
215
  if (seed) {
193
216
  for (const seedFile of agent.seedFiles) {
194
- const target = resolveInside(workspaceDir, seedFile.relativePath);
217
+ const target = resolveWorkspaceWritePath(
218
+ workspaceDir,
219
+ seedFile.relativePath
220
+ );
195
221
  if (await exists(target)) {
196
222
  continue;
197
223
  }
@@ -201,6 +227,56 @@ export async function materializeWorkspace(
201
227
  }
202
228
  }
203
229
 
230
+ /**
231
+ * Resolve a workspace-rooted write target, refusing escapes and the
232
+ * reserved `memory` name. `memory` is a symlink into the agent's durable
233
+ * cross-session state, so a caller-supplied `workspaceFiles` entry (or a
234
+ * seed file) under it would write through the link and poison the journal
235
+ * every other session trusts. The check runs on the *resolved* path so
236
+ * `./memory/x` and `foo/../memory/x` cannot slip past.
237
+ */
238
+ function resolveWorkspaceWritePath(
239
+ workspaceDir: string,
240
+ relativePath: string
241
+ ): string {
242
+ const target = resolveInside(workspaceDir, relativePath);
243
+ const first = relative(workspaceDir, target).split(sep, 1)[0];
244
+ if (first === MEMORY_DIR_NAME) {
245
+ throw new Error(
246
+ `"${MEMORY_DIR_NAME}" is reserved for shared session memory; refusing to write ${relativePath}`
247
+ );
248
+ }
249
+ return target;
250
+ }
251
+
252
+ /**
253
+ * Symlink `<stateRoot>/memory` into the workspace as `memory`. Stale links
254
+ * (a moved state root) are re-pointed; seeded files and symlink-refusing
255
+ * platforms (unprivileged Windows) are left alone — memory then stays
256
+ * reachable only via the state path.
257
+ */
258
+ async function linkSharedMemoryDir(
259
+ workspaceDir: string,
260
+ stateRoot: string
261
+ ): Promise<void> {
262
+ const target = join(stateRoot, MEMORY_DIR_NAME);
263
+ const linkPath = join(workspaceDir, MEMORY_DIR_NAME);
264
+ await mkdir(target, { recursive: true });
265
+ try {
266
+ await symlink(target, linkPath, "dir");
267
+ } catch (error) {
268
+ if ((error as NodeJS.ErrnoException).code !== "EEXIST") {
269
+ return;
270
+ }
271
+ const existing = await lstat(linkPath);
272
+ if (!existing.isSymbolicLink() || (await readlink(linkPath)) === target) {
273
+ return;
274
+ }
275
+ await rm(linkPath);
276
+ await symlink(target, linkPath, "dir");
277
+ }
278
+ }
279
+
204
280
  async function materializeAgentTool(
205
281
  workspaceDir: string,
206
282
  tool: DiscoveredTool
@@ -273,7 +349,7 @@ export async function writeWorkspaceFiles(
273
349
  files: Record<string, string>
274
350
  ): Promise<void> {
275
351
  for (const [relativePath, contents] of Object.entries(files)) {
276
- const target = resolveInside(workspaceDir, relativePath);
352
+ const target = resolveWorkspaceWritePath(workspaceDir, relativePath);
277
353
  await mkdir(dirname(target), { recursive: true });
278
354
  await writeFile(target, contents, "utf8");
279
355
  }
package/src/memory.ts ADDED
@@ -0,0 +1,215 @@
1
+ /**
2
+ * Session memory — a journal of what every session of this agent did, so
3
+ * later sessions can recall and build on past work.
4
+ *
5
+ * The write side is {@link memoryHook}, authored as a thin re-export at
6
+ * `agent/hooks/memory.ts` (scaffolded by `agentkit init`): after each turn
7
+ * it appends one record (timestamp, session, user message, final result,
8
+ * usage) to `<stateRoot>/memory/journal.jsonl`.
9
+ *
10
+ * There is no read-side API. The framework symlinks `<stateRoot>/memory`
11
+ * into every session workspace as `memory/`, so agents read the journal
12
+ * with their normal file tools (`instructions.md` points them at it). That
13
+ * filesystem visibility is why memory does not route through
14
+ * `defineStorage` sinks, which are opaque to the model. A custom
15
+ * {@link MemoryBackend} (database, vector store, …) supplies its own read
16
+ * path — typically a server tool under `agent/tools/`, which can resolve
17
+ * state via `ctx.stateRoot`.
18
+ *
19
+ * TODO(agent-store): once agent-serve can mount the agent's AgentStore
20
+ * (durable S3-backed filesystem shared across serve hosts and cloud VMs),
21
+ * ship an AgentStore-backed backend and make it the default. The journal
22
+ * format is designed to survive that move unchanged.
23
+ */
24
+
25
+ import { appendFile, mkdir, rename, stat } from "node:fs/promises";
26
+ import { join } from "node:path";
27
+ import { defineHook } from "./hooks.js";
28
+ import type { HookContext, HookDefinition, TurnUsage } from "./types.js";
29
+
30
+ /** Name of the shared memory directory under the agent state root. */
31
+ export const MEMORY_DIR_NAME = "memory";
32
+
33
+ /** One journal line: what a single turn did. */
34
+ export type TurnMemoryRecord = {
35
+ /** ISO timestamp of when the turn finished. */
36
+ at: string;
37
+ sessionId: string;
38
+ channelId: string;
39
+ status: "completed" | "failed";
40
+ /** Session title, when the channel set one. */
41
+ title?: string;
42
+ /** Cursor SDK agent id (cloud: `bc-…`; local: the session id). */
43
+ sdkAgentId?: string;
44
+ /** The user message that started the turn (truncated). */
45
+ userMessage?: string;
46
+ /** Final assistant text of the turn, or the failure message (truncated). */
47
+ result?: string;
48
+ usage?: TurnUsage;
49
+ };
50
+
51
+ /** Where turn records go. Receives the agent's state root per append. */
52
+ export interface MemoryBackend {
53
+ appendTurn(
54
+ record: TurnMemoryRecord,
55
+ ctx: { stateRoot: string }
56
+ ): Promise<void>;
57
+ }
58
+
59
+ export interface FileMemoryBackendOptions {
60
+ /**
61
+ * Rotate `journal.jsonl` to a timestamped sibling once it exceeds this
62
+ * size, keeping the file agents grep small. Default 5 MiB.
63
+ */
64
+ maxJournalBytes?: number;
65
+ }
66
+
67
+ /**
68
+ * Default backend: append-only JSONL at `<stateRoot>/memory/journal.jsonl`
69
+ * — the directory the framework symlinks into session workspaces. Rotated
70
+ * segments stay alongside as `journal-<epoch-ms>.jsonl`.
71
+ */
72
+ export function fileMemoryBackend(
73
+ options: FileMemoryBackendOptions = {}
74
+ ): MemoryBackend {
75
+ const maxJournalBytes = options.maxJournalBytes ?? 5 * 1024 * 1024;
76
+ // Event dispatch is serialized per session but concurrent across
77
+ // sessions, so appends to the shared journal are chained per path. A
78
+ // failed append must not poison the chain for later turns.
79
+ const appendChains = new Map<string, Promise<void>>();
80
+ // Same-millisecond rotations must not reuse a segment name — POSIX
81
+ // rename would silently replace the earlier archive.
82
+ let rotationSeq = 0;
83
+ return {
84
+ async appendTurn(record, ctx) {
85
+ const dir = join(ctx.stateRoot, MEMORY_DIR_NAME);
86
+ const path = join(dir, "journal.jsonl");
87
+ const prior = appendChains.get(path) ?? Promise.resolve();
88
+ const next = prior
89
+ .catch(() => {})
90
+ .then(async () => {
91
+ await mkdir(dir, { recursive: true });
92
+ const size = (await stat(path).catch(() => undefined))?.size ?? 0;
93
+ if (size >= maxJournalBytes) {
94
+ rotationSeq += 1;
95
+ await rename(
96
+ path,
97
+ join(dir, `journal-${Date.now()}-${rotationSeq}.jsonl`)
98
+ );
99
+ }
100
+ await appendFile(path, `${JSON.stringify(record)}\n`, "utf8");
101
+ });
102
+ appendChains.set(path, next);
103
+ await next;
104
+ },
105
+ };
106
+ }
107
+
108
+ export interface MemoryHookOptions {
109
+ /** Where records go. Defaults to {@link fileMemoryBackend}. */
110
+ backend?: MemoryBackend;
111
+ /** Truncation cap for stored userMessage / result text. Default 2000. */
112
+ maxTextLength?: number;
113
+ }
114
+
115
+ function truncate(text: string, max: number): string {
116
+ return text.length <= max ? text : `${text.slice(0, max)}…`;
117
+ }
118
+
119
+ /**
120
+ * Observe-only hook that journals every turn. Author it as
121
+ * `agent/hooks/memory.ts`:
122
+ *
123
+ * ```ts
124
+ * import { memoryHook } from "@cursor/july/memory";
125
+ * export default memoryHook();
126
+ * ```
127
+ */
128
+ export function memoryHook(options: MemoryHookOptions = {}): HookDefinition {
129
+ const backend = options.backend ?? fileMemoryBackend();
130
+ const maxTextLength = options.maxTextLength ?? 2000;
131
+ // Inbound message per turn (truncated at receipt so large pastes are not
132
+ // retained), keyed `<sessionId>/<turnId>` — `message.received` and the
133
+ // turn-end events share a turnId. Entries drop at turn end and any
134
+ // stragglers (turns that never reached a terminal event) at session end.
135
+ const pendingMessages = new Map<string, string>();
136
+ const pendingKey = (ctx: HookContext, turnId: string | undefined): string =>
137
+ `${ctx.session.id}/${turnId ?? "?"}`;
138
+
139
+ const record = async (
140
+ ctx: HookContext,
141
+ turnId: string | undefined,
142
+ status: TurnMemoryRecord["status"],
143
+ result: string | undefined,
144
+ usage: TurnUsage | undefined
145
+ ): Promise<void> => {
146
+ const key = pendingKey(ctx, turnId);
147
+ const userMessage = pendingMessages.get(key);
148
+ pendingMessages.delete(key);
149
+ const entry: TurnMemoryRecord = {
150
+ at: new Date().toISOString(),
151
+ sessionId: ctx.session.id,
152
+ channelId: ctx.channel.id,
153
+ status,
154
+ };
155
+ if (ctx.session.title !== undefined) {
156
+ entry.title = ctx.session.title;
157
+ }
158
+ if (ctx.session.sdkAgentId !== undefined) {
159
+ entry.sdkAgentId = ctx.session.sdkAgentId;
160
+ }
161
+ if (userMessage !== undefined) {
162
+ entry.userMessage = userMessage;
163
+ }
164
+ if (result !== undefined) {
165
+ entry.result = truncate(result, maxTextLength);
166
+ }
167
+ if (usage !== undefined) {
168
+ entry.usage = usage;
169
+ }
170
+ await backend.appendTurn(entry, { stateRoot: ctx.stateRoot });
171
+ };
172
+
173
+ const sweepSession = (ctx: HookContext): void => {
174
+ for (const key of pendingMessages.keys()) {
175
+ if (key.startsWith(`${ctx.session.id}/`)) {
176
+ pendingMessages.delete(key);
177
+ }
178
+ }
179
+ };
180
+
181
+ return defineHook({
182
+ events: {
183
+ async "message.received"(event, ctx) {
184
+ pendingMessages.set(
185
+ pendingKey(ctx, event.turnId),
186
+ truncate(event.data.text, maxTextLength)
187
+ );
188
+ },
189
+ async "turn.completed"(event, ctx) {
190
+ await record(
191
+ ctx,
192
+ event.turnId,
193
+ "completed",
194
+ event.data.result,
195
+ event.data.usage
196
+ );
197
+ },
198
+ async "turn.failed"(event, ctx) {
199
+ await record(
200
+ ctx,
201
+ event.turnId,
202
+ "failed",
203
+ event.data.message,
204
+ undefined
205
+ );
206
+ },
207
+ async "session.completed"(_event, ctx) {
208
+ sweepSession(ctx);
209
+ },
210
+ async "session.failed"(_event, ctx) {
211
+ sweepSession(ctx);
212
+ },
213
+ },
214
+ });
215
+ }
package/src/types.ts CHANGED
@@ -492,6 +492,11 @@ export interface ToolContext {
492
492
  session: SessionInfo;
493
493
  /** Absolute path of the session's materialized workspace directory. */
494
494
  workspaceDir: string;
495
+ /**
496
+ * Absolute path of the agent's durable state root (shared across every
497
+ * session of this agent).
498
+ */
499
+ stateRoot: string;
495
500
  /** Shared host services (MCP / GitHub / Slack). */
496
501
  host: HostContext;
497
502
  /**
@@ -1564,6 +1569,12 @@ export interface HookContext {
1564
1569
  agent: { name: string };
1565
1570
  channel: { id: string; continuationToken: string | null };
1566
1571
  session: SessionInfo;
1572
+ /**
1573
+ * Absolute path of the agent's durable state root (shared across every
1574
+ * session of this agent). Hooks that maintain derived state (e.g. the
1575
+ * memory journal) write here.
1576
+ */
1577
+ stateRoot: string;
1567
1578
  }
1568
1579
 
1569
1580
  export type HookHandler<TEvent extends SessionEvent = SessionEvent> = (