@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.
- package/dist/command/cli/auth/cloud-session.d.ts +4 -4
- package/dist/command/cli/auth/device-flow.d.ts +2 -2
- package/dist/command/cli/cli-parse.d.ts +1 -1
- package/dist/command/cli/cli-parse.js +3 -1
- package/dist/command/cli/cloud-cli.js +59 -0
- package/dist/command/cli/cloud-login.d.ts +1 -1
- package/dist/command/cli/cloud-login.js +8 -7
- package/dist/command/cli/setup-mcp-cli.js +9 -1
- package/dist/command/cli.js +17 -1
- package/dist/command/setup/bringup/dev-server-wait.d.ts +2 -0
- package/dist/command/setup/bringup/dev-server-wait.js +1 -1
- package/dist/command/setup/init/init-runtime.js +53 -8
- package/dist/command/setup/node-effects.js +6 -1
- package/dist/command/setup/run-setup.d.ts +2 -0
- package/dist/command/setup/run-setup.js +7 -2
- package/dist/command/setup/setup-mcp.d.ts +2 -0
- package/dist/command/setup/setup-mcp.js +14 -5
- package/dist/command/version/peer-announce.d.ts +6 -2
- package/dist/command/version/peer-announce.js +8 -6
- package/dist/command/version/version-nudge.d.ts +17 -3
- package/dist/command/version/version-nudge.js +25 -4
- package/dist/features/harness/fill-values.d.ts +4 -0
- package/dist/features/harness/fill-values.js +47 -11
- package/dist/features/harness/openai-driver.d.ts +12 -0
- package/dist/features/harness/openai-driver.js +17 -5
- package/dist/features/license/license.d.ts +2 -2
- package/dist/index.js +17 -9
- package/dist/judgement/capsule/capsule-store.d.ts +4 -4
- package/dist/memory/cloud/sync-cycle.d.ts +9 -0
- package/dist/memory/cloud/sync-cycle.js +29 -2
- package/dist/memory/intent/intent-tools.js +8 -3
- package/dist/memory/journal/envelope.d.ts +8 -8
- package/dist/memory/journal/journal-query.d.ts +14 -0
- package/dist/memory/journal/journal-query.js +15 -0
- package/dist/memory/project/artifact-root-resolver.d.ts +10 -0
- package/dist/memory/project/artifact-root-resolver.js +46 -19
- package/dist/portal/bridge/bridge.d.ts +4 -3
- package/dist/portal/bridge/bridge.js +6 -1
- package/dist/portal/input/viewport-tools.js +16 -6
- package/dist/portal/session/session.d.ts +5 -8
- package/dist/portal/session/session.js +9 -13
- package/dist/surface/http-server.d.ts +1 -1
- package/dist/surface/http-server.js +4 -3
- package/dist/surface/mcp/mcp.js +1 -1
- package/dist/surface/mcp/proxy/proxy-log.js +3 -5
- package/dist/surface/tools/act-tools.js +2 -2
- package/dist/surface/tools/dynamic-tools.js +29 -2
- package/dist/surface/tools/error-recovery.js +1 -2
- package/dist/surface/tools/harness-explore.js +5 -1
- package/dist/surface/tools/invoke-tool.js +12 -8
- package/dist/surface/tools/tool-kit.d.ts +10 -0
- package/dist/telemetry/argument-shape.js +1 -1
- package/dist/telemetry/onboarding-firsts.d.ts +2 -2
- package/dist/telemetry/onboarding-firsts.js +2 -2
- package/docs/actions.mdx +1 -2
- package/docs/cli/cloud.mdx +6 -4
- package/docs/cli.mdx +13 -7
- package/docs/desktop.mdx +1 -1
- package/docs/getting-started.md +12 -2
- package/docs/install-agentic.mdx +1 -1
- package/docs/install-manual.mdx +1 -1
- package/docs/packages/next.mdx +3 -0
- package/docs/packages/tauri.mdx +1 -1
- package/docs/packages/vite-plugin.mdx +2 -0
- package/docs/packages.mdx +1 -1
- package/docs/quickstart.mdx +11 -9
- package/docs/tools/act.mdx +1 -1
- package/docs/usage.md +0 -1
- 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
|
|
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
|
-
/**
|
|
25
|
-
|
|
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
|
-
/**
|
|
29
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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(
|
|
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
|
|
229
|
-
* just-edited manifest is what we name.
|
|
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
|
|
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:
|
|
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({
|
|
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(
|
|
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 (
|
|
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
|
-
|
|
157
|
-
|
|
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
|
-
|
|
104
|
+
net: {
|
|
105
105
|
count: number;
|
|
106
106
|
mean: number;
|
|
107
107
|
m2: number;
|
|
108
108
|
max: number;
|
|
109
109
|
};
|
|
110
|
-
|
|
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
|
-
|
|
129
|
+
net: {
|
|
130
130
|
count: number;
|
|
131
131
|
mean: number;
|
|
132
132
|
m2: number;
|
|
133
133
|
max: number;
|
|
134
134
|
};
|
|
135
|
-
|
|
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
|
-
|
|
158
|
+
net: {
|
|
159
159
|
count: number;
|
|
160
160
|
mean: number;
|
|
161
161
|
m2: number;
|
|
162
162
|
max: number;
|
|
163
163
|
};
|
|
164
|
-
|
|
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
|
-
|
|
187
|
+
net: {
|
|
188
188
|
count: number;
|
|
189
189
|
mean: number;
|
|
190
190
|
m2: number;
|
|
191
191
|
max: number;
|
|
192
192
|
};
|
|
193
|
-
|
|
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;
|