@reticlehq/server 3.4.0 → 3.5.0

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 (69) hide show
  1. package/dist/command/cli/auth/cloud-session.d.ts +4 -4
  2. package/dist/command/cli/auth/device-flow.d.ts +2 -2
  3. package/dist/command/cli/cli-parse.d.ts +1 -1
  4. package/dist/command/cli/cli-parse.js +3 -1
  5. package/dist/command/cli/cloud-cli.js +59 -0
  6. package/dist/command/cli/cloud-login.d.ts +1 -1
  7. package/dist/command/cli/cloud-login.js +8 -7
  8. package/dist/command/cli/setup-mcp-cli.js +9 -1
  9. package/dist/command/cli.js +17 -1
  10. package/dist/command/setup/bringup/dev-server-wait.d.ts +2 -0
  11. package/dist/command/setup/bringup/dev-server-wait.js +1 -1
  12. package/dist/command/setup/init/init-runtime.js +53 -8
  13. package/dist/command/setup/node-effects.js +6 -1
  14. package/dist/command/setup/run-setup.d.ts +2 -0
  15. package/dist/command/setup/run-setup.js +7 -2
  16. package/dist/command/setup/setup-mcp.d.ts +2 -0
  17. package/dist/command/setup/setup-mcp.js +14 -5
  18. package/dist/command/version/peer-announce.d.ts +6 -2
  19. package/dist/command/version/peer-announce.js +8 -6
  20. package/dist/command/version/version-nudge.d.ts +17 -3
  21. package/dist/command/version/version-nudge.js +25 -4
  22. package/dist/features/harness/fill-values.d.ts +4 -0
  23. package/dist/features/harness/fill-values.js +47 -11
  24. package/dist/features/harness/openai-driver.d.ts +12 -0
  25. package/dist/features/harness/openai-driver.js +17 -5
  26. package/dist/features/license/license.d.ts +2 -2
  27. package/dist/index.js +17 -9
  28. package/dist/judgement/capsule/capsule-store.d.ts +4 -4
  29. package/dist/memory/cloud/sync-cycle.d.ts +9 -0
  30. package/dist/memory/cloud/sync-cycle.js +29 -2
  31. package/dist/memory/intent/intent-tools.js +8 -3
  32. package/dist/memory/journal/envelope.d.ts +8 -8
  33. package/dist/memory/journal/journal-query.d.ts +14 -0
  34. package/dist/memory/journal/journal-query.js +15 -0
  35. package/dist/memory/project/artifact-root-resolver.d.ts +10 -0
  36. package/dist/memory/project/artifact-root-resolver.js +46 -19
  37. package/dist/portal/bridge/bridge.d.ts +4 -3
  38. package/dist/portal/bridge/bridge.js +6 -1
  39. package/dist/portal/input/viewport-tools.js +16 -6
  40. package/dist/portal/session/session.d.ts +5 -8
  41. package/dist/portal/session/session.js +9 -13
  42. package/dist/surface/http-server.d.ts +1 -1
  43. package/dist/surface/http-server.js +4 -3
  44. package/dist/surface/mcp/mcp.js +1 -1
  45. package/dist/surface/mcp/proxy/proxy-log.js +3 -5
  46. package/dist/surface/tools/act-tools.js +2 -2
  47. package/dist/surface/tools/dynamic-tools.js +29 -2
  48. package/dist/surface/tools/error-recovery.js +1 -2
  49. package/dist/surface/tools/harness-explore.js +5 -1
  50. package/dist/surface/tools/invoke-tool.js +12 -8
  51. package/dist/surface/tools/tool-kit.d.ts +10 -0
  52. package/dist/telemetry/argument-shape.js +1 -1
  53. package/dist/telemetry/onboarding-firsts.d.ts +2 -2
  54. package/dist/telemetry/onboarding-firsts.js +2 -2
  55. package/docs/actions.mdx +1 -2
  56. package/docs/cli/cloud.mdx +6 -4
  57. package/docs/cli.mdx +13 -7
  58. package/docs/desktop.mdx +1 -1
  59. package/docs/getting-started.md +12 -2
  60. package/docs/install-agentic.mdx +1 -1
  61. package/docs/install-manual.mdx +1 -1
  62. package/docs/packages/next.mdx +3 -0
  63. package/docs/packages/tauri.mdx +1 -1
  64. package/docs/packages/vite-plugin.mdx +2 -0
  65. package/docs/packages.mdx +1 -1
  66. package/docs/quickstart.mdx +11 -9
  67. package/docs/tools/act.mdx +1 -1
  68. package/docs/usage.md +0 -1
  69. package/package.json +5 -5
@@ -11,7 +11,7 @@
11
11
  * result, then goes quiet until the skew CHANGES. Told, not nagged: a banner on every call is one an
12
12
  * agent learns to skip, and it would cost those tokens on every call for the rest of the session.
13
13
  */
14
- import type { SkewPair } from './version-skew.js';
14
+ import { SkewPair } from './version-skew.js';
15
15
  interface VersionSkewNudge {
16
16
  pair: SkewPair;
17
17
  action: string;
@@ -21,8 +21,20 @@ interface VersionSkewNudge {
21
21
  * a second tab on a third version is news; the same tab reconnecting is not.
22
22
  */
23
23
  export declare function noteVersionSkew(pair: SkewPair, action: string): void;
24
- /** The next undelivered skew, once. */
25
- export declare function takeVersionSkew(): VersionSkewNudge | undefined;
24
+ /**
25
+ * A daemon skew bound to the one MCP connection whose server announced it, told once (#1136).
26
+ *
27
+ * The page pair stays in the daemon-wide queue: it is a fact about a tab, true for whoever asks.
28
+ * This pair is a fact about the CALLER, and telling it to a different caller is advice to restart
29
+ * a server that is fine, or to run `reticle stop` and cut every agent on the daemon.
30
+ */
31
+ export declare function connectionSkew(action: string): {
32
+ take(): string | undefined;
33
+ };
34
+ /** The next undelivered skew, once: this connection's own first, then the daemon-wide queue. */
35
+ export declare function takeVersionSkew(connection?: {
36
+ take(): string | undefined;
37
+ }): VersionSkewNudge | undefined;
26
38
  /**
27
39
  * Splice pending skew onto a tool-error payload.
28
40
  *
@@ -36,6 +48,8 @@ export declare function takeVersionSkewOnto(payload: {
36
48
  error: string;
37
49
  recovery?: string;
38
50
  feedback?: string;
51
+ }, connection?: {
52
+ take(): string | undefined;
39
53
  }): Record<string, unknown>;
40
54
  /** Tests only — drop the module state so each case starts clean. */
41
55
  export declare function resetVersionSkew(): void;
@@ -12,6 +12,7 @@
12
12
  * agent learns to skip, and it would cost those tokens on every call for the rest of the session.
13
13
  */
14
14
  import { EnvelopeKey } from '../../surface/tools/tool-kit.js';
15
+ import { SkewPair } from './version-skew.js';
15
16
  /** Pending per pair — the SDK and the daemon are different upgrades and each is worth saying once. */
16
17
  const pending = new Map();
17
18
  /** The last message delivered for each pair, so the same skew is never repeated. */
@@ -25,8 +26,28 @@ export function noteVersionSkew(pair, action) {
25
26
  return;
26
27
  pending.set(pair, action);
27
28
  }
28
- /** The next undelivered skew, once. */
29
- export function takeVersionSkew() {
29
+ /**
30
+ * A daemon skew bound to the one MCP connection whose server announced it, told once (#1136).
31
+ *
32
+ * The page pair stays in the daemon-wide queue: it is a fact about a tab, true for whoever asks.
33
+ * This pair is a fact about the CALLER, and telling it to a different caller is advice to restart
34
+ * a server that is fine, or to run `reticle stop` and cut every agent on the daemon.
35
+ */
36
+ export function connectionSkew(action) {
37
+ let pendingAction = action;
38
+ return {
39
+ take: () => {
40
+ const taken = pendingAction;
41
+ pendingAction = undefined;
42
+ return taken;
43
+ },
44
+ };
45
+ }
46
+ /** The next undelivered skew, once: this connection's own first, then the daemon-wide queue. */
47
+ export function takeVersionSkew(connection) {
48
+ const own = connection?.take();
49
+ if (own !== undefined)
50
+ return { pair: SkewPair.DAEMON, action: own };
30
51
  for (const [pair, action] of pending) {
31
52
  pending.delete(pair);
32
53
  delivered.set(pair, action);
@@ -43,8 +64,8 @@ export function takeVersionSkew() {
43
64
  * feedback ask is replaced: the skew IS the next move, and inviting a bug report about it is
44
65
  * backwards. A recognized recovery is kept; the envelope still rides along.
45
66
  */
46
- export function takeVersionSkewOnto(payload) {
47
- const skew = takeVersionSkew();
67
+ export function takeVersionSkewOnto(payload, connection) {
68
+ const skew = takeVersionSkew(connection);
48
69
  if (skew === undefined)
49
70
  return payload;
50
71
  const { feedback, ...rest } = payload;
@@ -31,6 +31,7 @@
31
31
  * wrong verdict. Nothing here touches what is judged.
32
32
  */
33
33
  import { type HarnessDriverOptions } from './driver.js';
34
+ import { type OpenAiDriverOptions } from './openai-driver.js';
34
35
  /** What the caller must be able to do for values to survive past this drive. */
35
36
  export interface FillCache {
36
37
  get(label: string): string | undefined;
@@ -61,6 +62,9 @@ export declare function needsGeneration(label: string): boolean;
61
62
  */
62
63
  export declare function fillValues(deps: {
63
64
  cache?: FillCache;
65
+ /** GPT, the generator a Jev drive pairs with. Asked ahead of `generator` when both are set. */
66
+ openai?: OpenAiDriverOptions;
67
+ /** Anthropic, for a machine that holds only that key. */
64
68
  generator?: HarnessDriverOptions;
65
69
  /** One line about where the drive is, so a value suits the page it is typed on. */
66
70
  context?: () => string;
@@ -31,6 +31,7 @@
31
31
  * wrong verdict. Nothing here touches what is judged.
32
32
  */
33
33
  import { DEFAULT_HARNESS_BASE_URL, } from './driver.js';
34
+ import { DEFAULT_OPENAI_MODEL, OPENAI_REASONING_EFFORT, openAiCompletionsUrl, } from './openai-driver.js';
34
35
  /** Deliberately small. A field value is a phrase; anything longer is the model misunderstanding. */
35
36
  const MAX_TOKENS = 64;
36
37
  const ANTHROPIC_VERSION = '2023-06-01';
@@ -87,6 +88,40 @@ const PROMPT = (label, context) => `A test harness is filling in a form field on
87
88
  `Reply with ONLY the value to type. No quotes, no explanation, no markdown. It must be something a ` +
88
89
  `real user would plausibly enter, and short. If the field wants an identifier or a code, invent a ` +
89
90
  `well-formed one.`;
91
+ /**
92
+ * A model's one-line answer as a field value, or nothing. A model asked for one value occasionally
93
+ * answers with a sentence about the value; anything long enough to be prose is not a field value,
94
+ * and typing prose proves nothing about the app.
95
+ */
96
+ function asValue(raw) {
97
+ const text = raw?.trim().split('\n')[0];
98
+ if (text === undefined || 0 === text.length)
99
+ return undefined;
100
+ return 80 < text.length ? undefined : text.replace(/^["']|["']$/g, '');
101
+ }
102
+ /** One string from GPT over Chat Completions — directly, or through the platform with its key. */
103
+ async function generateWithOpenAi(options, label, context) {
104
+ const call = options.fetch ?? ((url, init) => fetch(url, init));
105
+ try {
106
+ const res = await call(openAiCompletionsUrl(options), {
107
+ method: 'POST',
108
+ headers: { 'content-type': 'application/json', authorization: `Bearer ${options.apiKey}` },
109
+ body: JSON.stringify({
110
+ model: options.model ?? DEFAULT_OPENAI_MODEL,
111
+ max_completion_tokens: MAX_TOKENS,
112
+ reasoning_effort: OPENAI_REASONING_EFFORT,
113
+ messages: [{ role: 'user', content: PROMPT(label, context) }],
114
+ }),
115
+ });
116
+ if (!res.ok)
117
+ return undefined;
118
+ const parsed = JSON.parse(await res.text());
119
+ return asValue(parsed.choices?.[0]?.message?.content ?? undefined);
120
+ }
121
+ catch {
122
+ return undefined;
123
+ }
124
+ }
90
125
  /** One string from a generating model, or nothing. Never throws: a drive must not fail over a field. */
91
126
  async function generateOne(options, label, context) {
92
127
  const call = options.fetch ?? ((url, init) => fetch(url, init));
@@ -109,17 +144,10 @@ async function generateOne(options, label, context) {
109
144
  return undefined;
110
145
  const parsed = JSON.parse(await res.text());
111
146
  const content = parsed.content ?? [];
112
- const text = content
147
+ return asValue(content
113
148
  .filter((block) => 'text' === block.type)
114
149
  .map((block) => block.text ?? '')
115
- .join('')
116
- .trim()
117
- .split('\n')[0];
118
- if (text === undefined || 0 === text.length)
119
- return undefined;
120
- // A model asked for one value occasionally answers with a sentence about the value. Anything
121
- // long enough to be prose is not a field value, and typing prose proves nothing about the app.
122
- return 80 < text.length ? undefined : text.replace(/^["']|["']$/g, '');
150
+ .join(''));
123
151
  }
124
152
  catch {
125
153
  // Offline, refused, rate-limited, malformed. All the same answer: use the heuristic.
@@ -142,10 +170,18 @@ export function fillValues(deps) {
142
170
  if (cached !== undefined)
143
171
  return cached;
144
172
  const fallback = heuristicFillValue(label);
145
- if (deps.generator === undefined || !needsGeneration(label) || MAX_PER_DRIVE <= spent)
173
+ const { openai, generator } = deps;
174
+ if ((openai === undefined && generator === undefined) ||
175
+ !needsGeneration(label) ||
176
+ MAX_PER_DRIVE <= spent)
146
177
  return fallback;
147
178
  spent += 1;
148
- const generated = await generateOne(deps.generator, label, deps.context?.() ?? '');
179
+ const context = deps.context?.() ?? '';
180
+ const generated = openai !== undefined
181
+ ? await generateWithOpenAi(openai, label, context)
182
+ : generator !== undefined
183
+ ? await generateOne(generator, label, context)
184
+ : undefined;
149
185
  if (generated === undefined)
150
186
  return fallback;
151
187
  // Cached under the LABEL, not the ref: refs expire with the page, labels are what the next drive
@@ -42,5 +42,17 @@ export interface OpenAiDriverOptions {
42
42
  * OpenAI key, which is the entire point of the proxy.
43
43
  */
44
44
  export declare function openAiOptionsFromEnv(env: Record<string, string | undefined>): OpenAiDriverOptions | undefined;
45
+ /**
46
+ * Where a Chat Completions call goes. The platform does not expose `/v1/chat/completions`; it
47
+ * exposes its own path and forwards. A base URL that is not OpenAI's is the platform's, which is
48
+ * the only other thing this talks to.
49
+ */
50
+ export declare function openAiCompletionsUrl(options: Pick<OpenAiDriverOptions, 'baseUrl'>): string;
51
+ /**
52
+ * The small GPT models refuse function tools on Chat Completions while reasoning is on: measured,
53
+ * `gpt-5.6-luna` answers 400 to the first request that carries tools. A driving turn is a choice,
54
+ * not a plan, so no hidden reasoning is also the right depth and the cheapest one.
55
+ */
56
+ export declare const OPENAI_REASONING_EFFORT = "none";
45
57
  /** Build a driver backed by Chat Completions. */
46
58
  export declare function openAiDriver(options: OpenAiDriverOptions): ModelDriver;
@@ -139,17 +139,28 @@ function parseArgs(raw) {
139
139
  return {};
140
140
  }
141
141
  }
142
+ /**
143
+ * Where a Chat Completions call goes. The platform does not expose `/v1/chat/completions`; it
144
+ * exposes its own path and forwards. A base URL that is not OpenAI's is the platform's, which is
145
+ * the only other thing this talks to.
146
+ */
147
+ export function openAiCompletionsUrl(options) {
148
+ const baseUrl = options.baseUrl ?? DEFAULT_OPENAI_BASE_URL;
149
+ return `${baseUrl}${DEFAULT_OPENAI_BASE_URL === baseUrl ? OPENAI_PATH : PLATFORM_PATH}`;
150
+ }
151
+ /**
152
+ * The small GPT models refuse function tools on Chat Completions while reasoning is on: measured,
153
+ * `gpt-5.6-luna` answers 400 to the first request that carries tools. A driving turn is a choice,
154
+ * not a plan, so no hidden reasoning is also the right depth and the cheapest one.
155
+ */
156
+ export const OPENAI_REASONING_EFFORT = 'none';
142
157
  /** Build a driver backed by Chat Completions. */
143
158
  export function openAiDriver(options) {
144
159
  const model = options.model ?? DEFAULT_OPENAI_MODEL;
145
- const baseUrl = options.baseUrl ?? DEFAULT_OPENAI_BASE_URL;
146
160
  const doFetch = options.fetch ?? ((url, init) => fetch(url, init));
147
- // The platform does not expose `/v1/chat/completions`; it exposes its own path and forwards. A
148
- // base URL that is not OpenAI's is the platform's, which is the only other thing this talks to.
149
- const path = DEFAULT_OPENAI_BASE_URL === baseUrl ? OPENAI_PATH : PLATFORM_PATH;
150
161
  return {
151
162
  async turn(input) {
152
- const response = await doFetch(`${baseUrl}${path}`, {
163
+ const response = await doFetch(openAiCompletionsUrl(options), {
153
164
  method: 'POST',
154
165
  headers: {
155
166
  'content-type': 'application/json',
@@ -158,6 +169,7 @@ export function openAiDriver(options) {
158
169
  body: JSON.stringify({
159
170
  model,
160
171
  max_completion_tokens: MAX_TOKENS,
172
+ reasoning_effort: OPENAI_REASONING_EFFORT,
161
173
  tools: input.tools.map(toWireTool),
162
174
  messages: [{ role: 'system', content: input.system }, ...toWireMessages(input.history)],
163
175
  }),
@@ -39,14 +39,14 @@ declare const LicensePayloadSchema: z.ZodObject<{
39
39
  exp: z.ZodNumber;
40
40
  features: z.ZodOptional<z.ZodArray<z.ZodString, "many">>;
41
41
  }, "strip", z.ZodTypeAny, {
42
- lid: string;
43
42
  org: string;
43
+ lid: string;
44
44
  plan: string;
45
45
  exp: number;
46
46
  features?: string[] | undefined;
47
47
  }, {
48
- lid: string;
49
48
  org: string;
49
+ lid: string;
50
50
  plan: string;
51
51
  exp: number;
52
52
  features?: string[] | undefined;
package/dist/index.js CHANGED
@@ -9,7 +9,7 @@ import { attachCloudSync } from './memory/cloud/sync-daemon.js';
9
9
  import { wireHooks } from './hooks/hook-commands.js';
10
10
  import { PROJECT_REGISTRY_FILE, parseProjectRegistry, projectCandidates, } from '@reticlehq/core/artifacts';
11
11
  import { discoverProjectConfigs } from './command/cli/config/config-discovery.js';
12
- import { artifactRootResolver } from './memory/project/artifact-root-resolver.js';
12
+ import { artifactRootResolver, projectDirectoryFor, } from './memory/project/artifact-root-resolver.js';
13
13
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
14
14
  import { AGENT_STOPPED_NOTICE, RETICLE_DEFAULT_PORT, bridgeWsUrl, ReticleCommand, ReticleDir, ReticleEnv, LOOPBACK_HOST, ReplayStatus, EventType, } from '@reticlehq/core';
15
15
  import { originOf } from './portal/session/session-manager.js';
@@ -21,6 +21,7 @@ import { reportAppInstrumented } from './telemetry/app-instrumented.js';
21
21
  import { resolveBridgeSecurityWithAutoToken } from './portal/bridge/bridge-security.js';
22
22
  import { Bridge } from './portal/bridge/bridge.js';
23
23
  import { sdkFixForDirectory } from './command/version/sdk-fix.js';
24
+ import { connectionSkew } from './command/version/version-nudge.js';
24
25
  import { SERVER_VERSION } from './command/version/identity/server-version.js';
25
26
  import { BaselineStore } from './memory/project/baselines.js';
26
27
  import { RecordingStore } from './language/flows/recording/tape/recordings.js';
@@ -225,12 +226,12 @@ async function resolveRealInput(options, onNavigateError, onNetworkDetail) {
225
226
  }
226
227
  /** Start the Reticle bridge (browser WS endpoint) and, by default, the MCP stdio server. */
227
228
  /**
228
- * The SDK-upgrade sentence for THIS project's package.json, evaluated at each HELLO so a
229
- * just-edited manifest is what we name. Falls back to the framework-neutral sensor when cwd is
230
- * not an app.
229
+ * The SDK-upgrade sentence for the package.json of the project the page announced, evaluated at
230
+ * each HELLO so a just-edited manifest is what we name. The daemon's cwd only when that project's
231
+ * directory is unknown, and the framework-neutral sensor when that is not an app either (#1135).
231
232
  */
232
- function sdkFixForCwd() {
233
- return sdkFixForDirectory(SERVER_VERSION, process.cwd());
233
+ function sdkFixForProject(projectId) {
234
+ return sdkFixForDirectory(SERVER_VERSION, projectDirectoryFor(projectId) ?? process.cwd());
234
235
  }
235
236
  /**
236
237
  * Resolve a session's artifact root, from everything this machine knows about where projects live.
@@ -289,7 +290,7 @@ export async function start(options = {}) {
289
290
  });
290
291
  const uninstallHooks = wireHooks(options.reticleRoot ?? join(process.cwd(), ReticleDir.ROOT), readProjectId(process.cwd()));
291
292
  const security = await resolveBridgeSecurityWithAutoToken(options);
292
- const bridge = new Bridge({ port, sdkFix: sdkFixForCwd, ...security });
293
+ const bridge = new Bridge({ port, sdkFix: sdkFixForProject, ...security });
293
294
  // Server-authoritative liveness: a Node-side reaper (immune to browser throttling) ends sessions
294
295
  // whose agent has gone idle, so a forgotten/crashed agent never leaves the HUD "running" forever.
295
296
  const reaper = new SessionReaper(bridge.sessions);
@@ -428,7 +429,12 @@ export async function startDaemon(options = {}) {
428
429
  });
429
430
  const security = await resolveBridgeSecurityWithAutoToken(options);
430
431
  const shared = createSharedServer(security.token === undefined ? {} : { token: security.token });
431
- const bridge = new Bridge({ port, server: shared.httpServer, sdkFix: sdkFixForCwd, ...security });
432
+ const bridge = new Bridge({
433
+ port,
434
+ server: shared.httpServer,
435
+ sdkFix: sdkFixForProject,
436
+ ...security,
437
+ });
432
438
  // The daemon owns listen (below), so the real bind error is reported there; absorb bridge.ready's
433
439
  // mirror rejection so a port collision can't surface as an unhandled promise rejection.
434
440
  void bridge.ready.catch(() => undefined);
@@ -567,7 +573,9 @@ export async function startDaemon(options = {}) {
567
573
  const effectiveDeps = realInput !== undefined ? { ...deps, realInput } : deps;
568
574
  // Read per attach, not once: a project that gets wired while this daemon is alive should stop
569
575
  // being told to wire itself on the next agent that connects.
570
- shared.attachMcp(() => createMcpServer(effectiveDeps, profile,
576
+ shared.attachMcp((peerSkew) => createMcpServer(peerSkew === undefined
577
+ ? effectiveDeps
578
+ : { ...effectiveDeps, peerSkew: connectionSkew(peerSkew) }, profile,
571
579
  // See the sibling call in `start`: a live session outweighs empty/stale durable memory (#1138).
572
580
  bridge.sessions.count() > 0 ||
573
581
  hasAnyProjectConnectedBefore(reticleStateHome(), port, projectIdsAt(process.cwd()))));
@@ -42,10 +42,10 @@ declare const CapsuleSchema: z.ZodObject<{
42
42
  seen: z.ZodOptional<z.ZodNumber>;
43
43
  lastSeenAt: z.ZodOptional<z.ZodNumber>;
44
44
  }, "strip", z.ZodTypeAny, {
45
- expected: string;
46
- origin: string;
47
45
  version: 1;
48
46
  id: string;
47
+ origin: string;
48
+ expected: string;
49
49
  steps: FlowStep[];
50
50
  createdAt: number;
51
51
  observed: string;
@@ -53,10 +53,10 @@ declare const CapsuleSchema: z.ZodObject<{
53
53
  seen?: number | undefined;
54
54
  lastSeenAt?: number | undefined;
55
55
  }, {
56
- expected: string;
57
- origin: string;
58
56
  version: 1;
59
57
  id: string;
58
+ origin: string;
59
+ expected: string;
60
60
  steps: FlowStep[];
61
61
  createdAt: number;
62
62
  observed: string;
@@ -75,6 +75,15 @@ export interface CloudSyncState {
75
75
  * fills the gap the server declared and never overrides the server on a question it answered.
76
76
  */
77
77
  sentRunIds?: string[];
78
+ /**
79
+ * The content hash of each run as the server last ACCEPTED it, for the runs still held locally.
80
+ *
81
+ * A run is not immutable: a session's live drive run is rewritten after every verdict under the
82
+ * same id. Sending by id alone delivered its first version and never the rest, so the server held
83
+ * a tab's first few verdicts and none after. The server upserts a run by id, so a changed run is
84
+ * simply sent again; this is how the machine tells a changed run from one it already delivered.
85
+ */
86
+ sentRunHashes?: Record<string, string>;
78
87
  /**
79
88
  * Runs the server refused, by run id: its reason, and the two things that could change the answer
80
89
  * (a hash of the run file, and of what the platform said it reads). A refused run is not offered
@@ -92,6 +92,16 @@ function partResult(body, part) {
92
92
  * Split runs into requests bounded by count and by serialized size. A run bigger than the byte
93
93
  * bound on its own still goes, alone: it cannot be split, and holding it back would hide it.
94
94
  */
95
+ /** The delivery record, kept only for the runs still on disk — the same bound as `sentRunIds`. */
96
+ function heldHashes(runs, recorded) {
97
+ const out = {};
98
+ for (const { runId } of runs) {
99
+ const hash = recorded[runId];
100
+ if (hash !== undefined)
101
+ out[runId] = hash;
102
+ }
103
+ return out;
104
+ }
95
105
  function batchRuns(runs) {
96
106
  const batches = [];
97
107
  let current = [];
@@ -194,8 +204,21 @@ export async function runSyncCycle(deps) {
194
204
  const priorRefusals = isRecord(deps.state.refusedRuns) ? deps.state.refusedRuns : {};
195
205
  const refusedRuns = {};
196
206
  const notRetried = [];
207
+ // Same rule as every record read from disk: only a string can equal a hash we computed.
208
+ const recorded = {};
209
+ if (isRecord(deps.state.sentRunHashes))
210
+ for (const [id, hash] of Object.entries(deps.state.sentRunHashes))
211
+ if ('string' === typeof hash)
212
+ recorded[id] = hash;
213
+ /*
214
+ * Changed means no record, or a record that differs. No record has to count: a machine upgraded
215
+ * onto this can hold runs the server has only in an older version, and a tab that has closed
216
+ * never changes again to earn a re-send. So each such run is
217
+ * sent once and recorded, a one-time cost bounded by what retention keeps.
218
+ */
219
+ const delivered = (run) => known.has(run.runId) && recorded[run.runId] === hashPayload(run.payload);
197
220
  const unsent = allRuns.filter((run) => {
198
- if (known.has(run.runId))
221
+ if (delivered(run))
199
222
  return false;
200
223
  const prior = priorRefusals[run.runId];
201
224
  const unchanged = prior !== undefined &&
@@ -305,8 +328,10 @@ export async function runSyncCycle(deps) {
305
328
  const refusedHere = new Map(runs.rejected.map((r) => [r.index, r.reason]));
306
329
  batch.forEach((run, index) => {
307
330
  const reason = refusedHere.get(index);
308
- if (reason === undefined)
331
+ if (reason === undefined) {
309
332
  known.add(run.runId);
333
+ recorded[run.runId] = hashPayload(run.payload);
334
+ }
310
335
  else
311
336
  refusedRuns[run.runId] = { reason, payloadHash: hashPayload(run.payload), acceptsHash };
312
337
  });
@@ -369,6 +394,7 @@ export async function runSyncCycle(deps) {
369
394
  nextState.refusedSets = refusedSets;
370
395
  if (pushError !== undefined) {
371
396
  nextState.sentRunIds = allRuns.map((r) => r.runId).filter((id) => known.has(id));
397
+ nextState.sentRunHashes = heldHashes(allRuns, recorded);
372
398
  deps.sink.writeState({ ...nextState, lastError: pushError });
373
399
  return {
374
400
  ...empty,
@@ -389,6 +415,7 @@ export async function runSyncCycle(deps) {
389
415
  * will never be attempted — so the record follows the artifacts and needs no number of its own.
390
416
  */
391
417
  nextState.sentRunIds = allRuns.map((r) => r.runId).filter((id) => known.has(id));
418
+ nextState.sentRunHashes = heldHashes(allRuns, recorded);
392
419
  // 3. COLLECT — always, even when there was nothing to send.
393
420
  const query = nextState.cursor === undefined ? '' : `?since=${encodeURIComponent(nextState.cursor)}`;
394
421
  const pull = await call(`${SYNC_PULL_PATH}${query}`);
@@ -70,6 +70,8 @@ export const INTENT_TOOLS = [
70
70
  })
71
71
  .optional(),
72
72
  }))
73
+ // Non-empty in the schema too, so the advertised contract says what `declare` enforces.
74
+ .min(1)
73
75
  .optional()
74
76
  .describe('declare only. Batchable — declare every intent for a feature in one call.'),
75
77
  id: z.string().optional().describe('bind only: which intent the predicate proves.'),
@@ -153,9 +155,12 @@ export const INTENT_TOOLS = [
153
155
  return { intents: await store.open() };
154
156
  }
155
157
  const raw = args['intents'];
156
- const entries = Array.isArray(raw)
157
- ? raw
158
- : [];
158
+ // Refused, not read as an empty list: `declare` with nothing to declare stored nothing and
159
+ // answered `{ intents: [] }`, which an agent reads as a declaration that worked (#1118).
160
+ if (!Array.isArray(raw) || 0 === raw.length) {
161
+ throw new Error(`${ReticleTool.INTENT} declare: \`intents\` must be a non-empty array of { id, statement }, got ${raw === undefined ? 'nothing' : JSON.stringify(raw)} — nothing was stored`);
162
+ }
163
+ const entries = raw;
159
164
  const declared = await store.declare(entries);
160
165
  // Said, not swallowed: an agent that declared something and finds nothing stored would
161
166
  // otherwise conclude the ledger lost it.
@@ -101,13 +101,13 @@ export declare const RouteEnvelopeSchema: z.ZodObject<{
101
101
  max: number;
102
102
  }>;
103
103
  }, "strip", z.ZodTypeAny, {
104
- durationMs: {
104
+ net: {
105
105
  count: number;
106
106
  mean: number;
107
107
  m2: number;
108
108
  max: number;
109
109
  };
110
- net: {
110
+ durationMs: {
111
111
  count: number;
112
112
  mean: number;
113
113
  m2: number;
@@ -126,13 +126,13 @@ export declare const RouteEnvelopeSchema: z.ZodObject<{
126
126
  max: number;
127
127
  };
128
128
  }, {
129
- durationMs: {
129
+ net: {
130
130
  count: number;
131
131
  mean: number;
132
132
  m2: number;
133
133
  max: number;
134
134
  };
135
- net: {
135
+ durationMs: {
136
136
  count: number;
137
137
  mean: number;
138
138
  m2: number;
@@ -155,13 +155,13 @@ export declare const RouteEnvelopeSchema: z.ZodObject<{
155
155
  route: string;
156
156
  samples: number;
157
157
  stats: {
158
- durationMs: {
158
+ net: {
159
159
  count: number;
160
160
  mean: number;
161
161
  m2: number;
162
162
  max: number;
163
163
  };
164
- net: {
164
+ durationMs: {
165
165
  count: number;
166
166
  mean: number;
167
167
  m2: number;
@@ -184,13 +184,13 @@ export declare const RouteEnvelopeSchema: z.ZodObject<{
184
184
  route: string;
185
185
  samples: number;
186
186
  stats: {
187
- durationMs: {
187
+ net: {
188
188
  count: number;
189
189
  mean: number;
190
190
  m2: number;
191
191
  max: number;
192
192
  };
193
- net: {
193
+ durationMs: {
194
194
  count: number;
195
195
  mean: number;
196
196
  m2: number;
@@ -62,5 +62,19 @@ export declare function readQueryEvents(reader: JournalReader | undefined, buffe
62
62
  * "nothing was lost" — see `JournalReader.readWriteLoss`.
63
63
  */
64
64
  export declare function readJournalWriteLoss(reader: JournalReader | undefined, buffer: QueryBuffer): Promise<JournalWriteLoss | undefined>;
65
+ /** The slice of the ring buffer `windowLost` reads. `RingBuffer` satisfies it structurally. */
66
+ export interface LossBuffer {
67
+ lostSince(cursor: number): boolean;
68
+ }
69
+ /**
70
+ * Did the EVENT STORE lose scarce evidence from a window opened at `cursor`? The input to whether a
71
+ * verdict's capture was clean — see `RingBuffer.lostSince`, and never the raw drop counter, which
72
+ * moves for the age and churn evictions that every live page produces continuously. The journal is
73
+ * the other half of that store, so a durable read that could not reach back to `cursor` lost
74
+ * exactly what an eviction would have. Both boundaries are INCLUSIVE (`t` is a millisecond many
75
+ * records share) and an absent `lostThroughT` impeaches every window rather than none — the
76
+ * conservative direction, both times.
77
+ */
78
+ export declare function windowLost(reader: JournalReader | undefined, buffer: LossBuffer, cursor: number): boolean;
65
79
  /** Apply since/until/actionId bounds to an event list. */
66
80
  export declare function filterEvents(events: readonly ReticleEvent[], options: EventQueryOptions): ReticleEvent[];
@@ -90,6 +90,21 @@ export async function readJournalWriteLoss(reader, buffer) {
90
90
  return undefined;
91
91
  return (await reader?.readWriteLoss?.()) ?? undefined;
92
92
  }
93
+ /**
94
+ * Did the EVENT STORE lose scarce evidence from a window opened at `cursor`? The input to whether a
95
+ * verdict's capture was clean — see `RingBuffer.lostSince`, and never the raw drop counter, which
96
+ * moves for the age and churn evictions that every live page produces continuously. The journal is
97
+ * the other half of that store, so a durable read that could not reach back to `cursor` lost
98
+ * exactly what an eviction would have. Both boundaries are INCLUSIVE (`t` is a millisecond many
99
+ * records share) and an absent `lostThroughT` impeaches every window rather than none — the
100
+ * conservative direction, both times.
101
+ */
102
+ export function windowLost(reader, buffer, cursor) {
103
+ if (buffer.lostSince(cursor))
104
+ return true;
105
+ const lost = reader?.readLoss?.();
106
+ return lost !== undefined && (lost.lostThroughT === undefined || lost.lostThroughT >= cursor);
107
+ }
93
108
  /** Apply since/until/actionId bounds to an event list. */
94
109
  export function filterEvents(events, options) {
95
110
  return events.filter((event) => (options.since === undefined || event.t >= options.since) &&
@@ -1,3 +1,13 @@
1
1
  import { type ProjectId } from '@reticlehq/core';
2
2
  import { type ArtifactRoot } from './artifact-root.js';
3
+ /**
4
+ * The directory of the project a page announced, or undefined when this machine does not know it.
5
+ *
6
+ * A daemon is shared: started from `$HOME`, a monorepo root, or another project's MCP client, its
7
+ * cwd is often not the app that sent the HELLO. The version-skew remedy read `package.json` from
8
+ * cwd anyway, so it fell back to the generic sensor package and told a Next.js project to install
9
+ * `@reticlehq/browser` (#1135). This is the same lookup the artifact root uses, keyed by the page's
10
+ * own project id.
11
+ */
12
+ export declare function projectDirectoryFor(projectId: string | undefined): string | undefined;
3
13
  export declare function artifactRootResolver(daemonRoot: string): (projectId: ProjectId | undefined, origin?: string) => ArtifactRoot;