granttap-mcp 0.8.5 → 0.8.6
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/apps/bridge/src/attachment-store.ts +38 -1
- package/apps/bridge/src/mesh/admin.ts +37 -0
- package/apps/bridge/src/monitor.ts +6 -0
- package/apps/mcp/src/mcp-tools/interaction.ts +113 -49
- package/apps/mcp/src/mcp-tools/operation-ledger.ts +53 -0
- package/package.json +1 -1
- package/packages/protocol/messages/mesh.ts +22 -0
- package/packages/protocol/schema.ts +2 -0
|
@@ -13,6 +13,9 @@ import type { UserAttachment, UserAttachmentUpload } from "../../../packages/pro
|
|
|
13
13
|
import { configDir } from "./config";
|
|
14
14
|
|
|
15
15
|
export const ATTACHMENT_TTL_MS = 2 * 60 * 60_000;
|
|
16
|
+
/** How many attachments may wait for their messages at once, and how much disk they may take. */
|
|
17
|
+
export const MAX_STAGED_ATTACHMENTS = 32;
|
|
18
|
+
export const MAX_STAGED_BYTES = 48 * 1_024 * 1_024;
|
|
16
19
|
|
|
17
20
|
function directory(): string {
|
|
18
21
|
const dir = join(configDir(), "attachments");
|
|
@@ -39,10 +42,44 @@ export function storeAttachment(upload: UserAttachmentUpload, room?: string, now
|
|
|
39
42
|
name: upload.name, mimeType: upload.mimeType, data: upload.data, receivedAt: now,
|
|
40
43
|
...(room ? { room } : {}),
|
|
41
44
|
};
|
|
42
|
-
|
|
45
|
+
const body = JSON.stringify(record);
|
|
46
|
+
// The staging area is bounded. One attachment too large for it is refused
|
|
47
|
+
// (the message that names it is rejected and the phone sends it inline);
|
|
48
|
+
// otherwise the oldest waiting ones make room, since a message that never
|
|
49
|
+
// came is the likeliest reason they are still here.
|
|
50
|
+
if (body.length > MAX_STAGED_BYTES) return false;
|
|
51
|
+
makeRoom(dir, body.length, id);
|
|
52
|
+
writeFileSync(join(dir, `${id}.json`), body, { mode: 0o600 });
|
|
43
53
|
return true;
|
|
44
54
|
}
|
|
45
55
|
|
|
56
|
+
function makeRoom(dir: string, incoming: number, incomingId: string): void {
|
|
57
|
+
let staged: Array<{ path: string; size: number; mtimeMs: number }> = [];
|
|
58
|
+
try {
|
|
59
|
+
staged = readdirSync(dir)
|
|
60
|
+
.filter((name) => name.endsWith(".json") && name !== `${incomingId}.json`)
|
|
61
|
+
.flatMap((name) => {
|
|
62
|
+
try {
|
|
63
|
+
const stat = statSync(join(dir, name));
|
|
64
|
+
return [{ path: join(dir, name), size: stat.size, mtimeMs: stat.mtimeMs }];
|
|
65
|
+
} catch {
|
|
66
|
+
return [];
|
|
67
|
+
}
|
|
68
|
+
})
|
|
69
|
+
.sort((left, right) => left.mtimeMs - right.mtimeMs);
|
|
70
|
+
} catch {
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
let count = staged.length;
|
|
74
|
+
let bytes = staged.reduce((total, item) => total + item.size, 0);
|
|
75
|
+
for (const item of staged) {
|
|
76
|
+
if (count < MAX_STAGED_ATTACHMENTS && bytes + incoming <= MAX_STAGED_BYTES) break;
|
|
77
|
+
rmSync(item.path, { force: true });
|
|
78
|
+
count -= 1;
|
|
79
|
+
bytes -= item.size;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
46
83
|
/** The attachment the message named, taken off disk; nothing when it never came. */
|
|
47
84
|
export function takeAttachment(attachmentId: string, room?: string, now = Date.now()): UserAttachment | undefined {
|
|
48
85
|
const id = safeId(attachmentId);
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the person may do to the mesh that no agent may.
|
|
3
|
+
*
|
|
4
|
+
* A claim is released by its owner, and only its owner: that is what keeps
|
|
5
|
+
* one agent from clearing another's hold on a file. It also means a claim
|
|
6
|
+
* whose owner died, or will not let go, stays until it expires. The person
|
|
7
|
+
* is not an owner and is not bound by that rule; they are the authority the
|
|
8
|
+
* rule protects. A release from the phone is therefore a command of its
|
|
9
|
+
* own, checked against the Project it names, and written down.
|
|
10
|
+
*/
|
|
11
|
+
import type { MeshClaimRelease, ResourceClaim } from "../../../../packages/protocol/schema";
|
|
12
|
+
import type { MeshStore } from "./store";
|
|
13
|
+
|
|
14
|
+
export type PersonRelease =
|
|
15
|
+
| { released: true; claim: ResourceClaim }
|
|
16
|
+
| { released: false; reason: "unknown_claim" | "other_project" };
|
|
17
|
+
|
|
18
|
+
export function releaseClaimByPerson(
|
|
19
|
+
store: MeshStore,
|
|
20
|
+
request: MeshClaimRelease,
|
|
21
|
+
log: (line: string) => void = (line) => process.stderr.write(`[monitor] mesh: ${line}\n`),
|
|
22
|
+
): PersonRelease {
|
|
23
|
+
// The claim must be one of this Project's: a claim id is not a secret, and
|
|
24
|
+
// a Project's person does not reach into another Project with it.
|
|
25
|
+
const inProject = store.snapshot(request.projectId)?.claims.find((item) => item.claimId === request.claimId);
|
|
26
|
+
if (!inProject) {
|
|
27
|
+
const elsewhere = store.activeClaims().some((item) => item.claimId === request.claimId);
|
|
28
|
+
log(`release of ${request.claimId} refused: ${elsewhere ? "not in this Project" : "no such claim"}`);
|
|
29
|
+
return { released: false, reason: elsewhere ? "other_project" : "unknown_claim" };
|
|
30
|
+
}
|
|
31
|
+
store.releaseClaim(request.claimId);
|
|
32
|
+
log(
|
|
33
|
+
`claim ${request.claimId} on ${inProject.resource} held by ${inProject.ownerSessionId} `
|
|
34
|
+
+ `released by the person${request.reason ? `: ${request.reason}` : ""}`,
|
|
35
|
+
);
|
|
36
|
+
return { released: true, claim: inProject };
|
|
37
|
+
}
|
|
@@ -53,6 +53,7 @@ import { approvalsStatus } from "./approval-state";
|
|
|
53
53
|
import { primeSessionKeys, sendSessionPayload } from "./session-keys";
|
|
54
54
|
import { sendMeshPayload } from "./session-keys";
|
|
55
55
|
import { handleMeshPayload, meshCatalog, meshSnapshots, prepareMeshHandoff } from "./mesh/runtime";
|
|
56
|
+
import { releaseClaimByPerson } from "./mesh/admin";
|
|
56
57
|
import { deriveObservedClaims } from "./mesh/observed-claims";
|
|
57
58
|
import { localMeshStore } from "./mesh/local";
|
|
58
59
|
import { cachedSessionActivity } from "./monitor-session-activity";
|
|
@@ -398,6 +399,11 @@ export function startSessionMonitor(client: RelayClient): SessionMonitor {
|
|
|
398
399
|
const prepared = await prepareMeshHandoff(client, payload);
|
|
399
400
|
if (prepared) void publish().catch(() => {});
|
|
400
401
|
return prepared;
|
|
402
|
+
} else if (payload.type === "mesh.claim.release" && loadRuntimeConfig().meshEnabled) {
|
|
403
|
+
// The person's own authority, not an owner's event: written down, then applied.
|
|
404
|
+
const outcome = releaseClaimByPerson(localMeshStore(), payload);
|
|
405
|
+
if (outcome.released) void publish().catch(() => {});
|
|
406
|
+
return true;
|
|
401
407
|
}
|
|
402
408
|
return false;
|
|
403
409
|
});
|
|
@@ -19,6 +19,7 @@ import { handleMeshPayload } from "../../../bridge/src/mesh/runtime";
|
|
|
19
19
|
import { sendMeshPayload } from "../../../bridge/src/session-keys";
|
|
20
20
|
import { isMeshEnabled, isProviderEnabled } from "../../../bridge/src/config/runtime";
|
|
21
21
|
import { sessionFromEnvironment } from "./mesh-resource";
|
|
22
|
+
import { OperationLedger, type OperationRecord } from "./operation-ledger";
|
|
22
23
|
import {
|
|
23
24
|
askOpenQuestionOutcome,
|
|
24
25
|
askYesNoOutcome,
|
|
@@ -31,6 +32,11 @@ import {
|
|
|
31
32
|
const NOT_PAIRED =
|
|
32
33
|
"GrantTap is not paired on this machine. Pair the desktop bridge with the GrantTap app first.";
|
|
33
34
|
const question = z.string().min(1).max(8_000).describe("The question to ask");
|
|
35
|
+
const operationId = z.string().trim().min(1).max(128).optional().describe(
|
|
36
|
+
"A name of your own for this call. Retrying with the same operationId repeats nothing that "
|
|
37
|
+
+ "already happened: an event is not published twice, a person is not asked twice, and only "
|
|
38
|
+
+ "the part that failed is done again.",
|
|
39
|
+
);
|
|
34
40
|
const meshEventInput = z.object({
|
|
35
41
|
capability: z.string().trim().min(1).max(128).optional(),
|
|
36
42
|
projectId: z.string().trim().min(1).max(128),
|
|
@@ -47,6 +53,9 @@ const UNATTRIBUTED =
|
|
|
47
53
|
+ "are published only for the execution that made the call, so its provider hook must "
|
|
48
54
|
+ "be installed and trusted (granttap setup).";
|
|
49
55
|
|
|
56
|
+
/** One ledger per server, which is one per chat: a retry from the same chat finds its record. */
|
|
57
|
+
const ledger = new OperationLedger();
|
|
58
|
+
|
|
50
59
|
/**
|
|
51
60
|
* What a call came to, in a form a model can branch on.
|
|
52
61
|
*
|
|
@@ -54,84 +63,122 @@ const UNATTRIBUTED =
|
|
|
54
63
|
* structured result is the contract: a status that keeps "sent", "published"
|
|
55
64
|
* and "refused" apart, a decision that is null when nobody answered, and the
|
|
56
65
|
* id of a recorded event so a retry can be told from a duplicate. A call
|
|
57
|
-
* that errors changed nothing
|
|
66
|
+
* that errors changed nothing; a call that changed something and then failed
|
|
67
|
+
* says exactly which part is still owed.
|
|
58
68
|
*/
|
|
59
69
|
const notifyOutput = {
|
|
60
70
|
status: z.enum(["sent", "published", "claim_rejected"]).describe(
|
|
61
71
|
"sent: only a status text went to the phone; published: the Mesh event was recorded on this "
|
|
62
72
|
+ "computer and sent; claim_rejected: the claim conflicts with another execution's and was not recorded",
|
|
63
73
|
),
|
|
64
|
-
messageSent: z.boolean().describe("Whether
|
|
74
|
+
messageSent: z.boolean().describe("Whether the status text was handed to the relay for the phone"),
|
|
65
75
|
meshEventId: z.string().optional().describe("The id of the Mesh event as recorded (for claim_rejected, the CONFLICT event)"),
|
|
66
76
|
conflict: z.object({
|
|
67
77
|
ownerSessionId: z.string(),
|
|
68
78
|
resource: z.string(),
|
|
69
79
|
}).optional().describe("Who holds the resource this claim collided with"),
|
|
70
80
|
scopedResource: z.string().optional().describe("This execution's scoped Mesh resource URI, when the call was attributed"),
|
|
81
|
+
error: z.string().optional().describe("The part of the call that failed after the rest had happened; retry with the same operationId"),
|
|
82
|
+
replayed: z.boolean().optional().describe("This answer repeats an earlier call with the same operationId"),
|
|
71
83
|
};
|
|
72
84
|
const yesNoOutput = {
|
|
73
85
|
status: z.enum(["answered", "timed_out"]).describe("timed_out: nobody answered before the wait ended"),
|
|
74
86
|
decision: z.enum(["yes", "no"]).nullable().describe("The person's answer; null when nobody answered. A timeout is not a refusal."),
|
|
87
|
+
replayed: z.boolean().optional().describe("This answer repeats an earlier call with the same operationId"),
|
|
75
88
|
};
|
|
76
89
|
const openOutput = {
|
|
77
90
|
status: z.enum(["answered", "timed_out"]).describe("timed_out: nobody answered before the wait ended"),
|
|
78
91
|
answer: z.string().nullable().describe("The person's words; null when nobody answered"),
|
|
92
|
+
replayed: z.boolean().optional().describe("This answer repeats an earlier call with the same operationId"),
|
|
79
93
|
};
|
|
80
94
|
|
|
95
|
+
function answered(text: string, outcome: Record<string, unknown>) {
|
|
96
|
+
return { content: [{ type: "text" as const, text }], structuredContent: outcome };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function replayed(record: OperationRecord) {
|
|
100
|
+
return answered(`${record.text}\n(replayed: this operationId was already handled)`, { ...record.outcome, replayed: true });
|
|
101
|
+
}
|
|
102
|
+
|
|
81
103
|
export function registerInteractionTools(server: McpServer): void {
|
|
82
104
|
server.registerTool(
|
|
83
105
|
"notify",
|
|
84
106
|
{
|
|
85
107
|
description:
|
|
86
108
|
"Send a non-blocking status to the user, or publish one bounded task-scoped Project Mesh event. "
|
|
87
|
-
+ "The result says whether the text was sent and whether the event was recorded; an error means nothing happened
|
|
109
|
+
+ "The result says whether the text was sent and whether the event was recorded; an error means nothing happened, "
|
|
110
|
+
+ "and a result with an error field says which part is still owed. Give the call an operationId to make retries safe. "
|
|
88
111
|
+ "A handoff is started by the person from the phone, never by an event published here.",
|
|
89
112
|
inputSchema: {
|
|
90
113
|
message: z.string().min(1).max(2_000).describe("Optional text to show on the user's devices").optional(),
|
|
91
114
|
meshEvent: meshEventInput.describe("Optional structured coordination event; never include hidden reasoning").optional(),
|
|
115
|
+
operationId,
|
|
92
116
|
},
|
|
93
117
|
outputSchema: notifyOutput,
|
|
94
118
|
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false },
|
|
95
119
|
},
|
|
96
|
-
async ({ message, meshEvent }) => {
|
|
120
|
+
async ({ message, meshEvent, operationId: operation }) => {
|
|
97
121
|
if (!message && !meshEvent) return {
|
|
98
122
|
isError: true,
|
|
99
123
|
content: [{ type: "text" as const, text: "Provide message or meshEvent." }],
|
|
100
124
|
};
|
|
101
125
|
const client = await relay();
|
|
102
126
|
if (!client) return notPaired();
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
}
|
|
127
|
-
|
|
127
|
+
const previous = operation ? ledger.recall("notify", operation) : undefined;
|
|
128
|
+
if (previous && (previous.pending !== "message" || !message)) return replayed(previous);
|
|
129
|
+
let outcome: Record<string, unknown>;
|
|
130
|
+
let text: string;
|
|
131
|
+
let changedSomething = Boolean(previous);
|
|
132
|
+
if (previous) {
|
|
133
|
+
// The event went out last time; only the status text is still owed.
|
|
134
|
+
outcome = { ...previous.outcome };
|
|
135
|
+
text = previous.text;
|
|
136
|
+
} else {
|
|
137
|
+
// The provider hook already saw this exact call inside its own session.
|
|
138
|
+
const attributed = consumeAttributedCall("notify", { message, meshEvent }, Date.now(), sessionFromEnvironment());
|
|
139
|
+
// The event first: its checks can refuse the whole call, and a status
|
|
140
|
+
// sent before a refusal would report work that did not happen.
|
|
141
|
+
const published = meshEvent
|
|
142
|
+
? await publishMeshEvent(client, meshEvent, attributed?.sessionId)
|
|
143
|
+
: undefined;
|
|
144
|
+
changedSomething = published != null;
|
|
145
|
+
const capability = isMeshEnabled() && attributed
|
|
146
|
+
? executionCapabilityFor(attributed.sessionId)
|
|
147
|
+
: undefined;
|
|
148
|
+
const scopedResource = capability ? `granttap://mesh/${capability.token}` : undefined;
|
|
149
|
+
text = published?.text ?? "sent to phone";
|
|
150
|
+
if (scopedResource) text = `${text}\nScoped Mesh state: ${scopedResource}`;
|
|
151
|
+
outcome = {
|
|
128
152
|
status: published?.status ?? "sent",
|
|
129
|
-
messageSent:
|
|
153
|
+
messageSent: false,
|
|
130
154
|
...(published ? { meshEventId: published.eventId } : {}),
|
|
131
155
|
...(published?.conflict ? { conflict: published.conflict } : {}),
|
|
132
156
|
...(scopedResource ? { scopedResource } : {}),
|
|
133
|
-
}
|
|
134
|
-
}
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
if (message) {
|
|
160
|
+
try {
|
|
161
|
+
await client.send(
|
|
162
|
+
{ type: "agent.event", text: message, kind: "status", createdAt: Date.now() },
|
|
163
|
+
"phone",
|
|
164
|
+
{ ttlMs: 15 * 60_000, wake: true },
|
|
165
|
+
);
|
|
166
|
+
outcome.messageSent = true;
|
|
167
|
+
delete outcome.error;
|
|
168
|
+
} catch (error) {
|
|
169
|
+
// Nothing else happened: the whole call failed, and says so.
|
|
170
|
+
if (!changedSomething) throw error;
|
|
171
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
172
|
+
outcome.messageSent = false;
|
|
173
|
+
outcome.error = `status text not sent: ${detail}`;
|
|
174
|
+
const partial = `${text}\nStatus text not sent (${detail}). The Mesh event is recorded; `
|
|
175
|
+
+ "retry with the same operationId to send only the text.";
|
|
176
|
+
if (operation) ledger.remember("notify", operation, { text, outcome, pending: "message" });
|
|
177
|
+
return answered(partial, { ...outcome, ...(previous ? { replayed: true } : {}) });
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
if (operation) ledger.remember("notify", operation, { text, outcome });
|
|
181
|
+
return answered(text, { ...outcome, ...(previous ? { replayed: true } : {}) });
|
|
135
182
|
},
|
|
136
183
|
);
|
|
137
184
|
server.registerTool(
|
|
@@ -140,12 +187,21 @@ export function registerInteractionTools(server: McpServer): void {
|
|
|
140
187
|
description:
|
|
141
188
|
"Ask the user a yes/no question on their phone/watch and wait for the tap. Returns 'yes' or 'no' "
|
|
142
189
|
+ "when they answered, or 'no-answer (timeout)' when nobody answered in time. A timeout is not a "
|
|
143
|
-
+ "refusal: do not treat it as 'no', and do not treat it as permission."
|
|
144
|
-
|
|
190
|
+
+ "refusal: do not treat it as 'no', and do not treat it as permission. With an operationId, a retry "
|
|
191
|
+
+ "returns the answer already given instead of asking again.",
|
|
192
|
+
inputSchema: { question: question.describe("A question answerable with yes/no"), operationId },
|
|
145
193
|
outputSchema: yesNoOutput,
|
|
146
194
|
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false },
|
|
147
195
|
},
|
|
148
|
-
async ({ question: text }) =>
|
|
196
|
+
async ({ question: text, operationId: operation }) => {
|
|
197
|
+
const previous = operation ? ledger.recall("ask_yes_no", operation) : undefined;
|
|
198
|
+
if (previous) return replayed(previous);
|
|
199
|
+
const result = await answerYesNo(text, interactionScope("ask_yes_no", text));
|
|
200
|
+
if (operation && !result.isError && result.structuredContent) {
|
|
201
|
+
ledger.remember("ask_yes_no", operation, { text: result.content[0]!.text, outcome: result.structuredContent });
|
|
202
|
+
}
|
|
203
|
+
return result;
|
|
204
|
+
},
|
|
149
205
|
);
|
|
150
206
|
server.registerTool(
|
|
151
207
|
"ask",
|
|
@@ -153,12 +209,20 @@ export function registerInteractionTools(server: McpServer): void {
|
|
|
153
209
|
description:
|
|
154
210
|
"Ask the user an open question on their phone/watch and wait for their spoken or typed reply. "
|
|
155
211
|
+ "Returns their answer text, or 'no-answer (timeout)' when nobody answered in time; a timeout is "
|
|
156
|
-
+ "not an answer.",
|
|
157
|
-
inputSchema: { question },
|
|
212
|
+
+ "not an answer. With an operationId, a retry returns the answer already given instead of asking again.",
|
|
213
|
+
inputSchema: { question, operationId },
|
|
158
214
|
outputSchema: openOutput,
|
|
159
215
|
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false },
|
|
160
216
|
},
|
|
161
|
-
async ({ question: text }) =>
|
|
217
|
+
async ({ question: text, operationId: operation }) => {
|
|
218
|
+
const previous = operation ? ledger.recall("ask", operation) : undefined;
|
|
219
|
+
if (previous) return replayed(previous);
|
|
220
|
+
const result = await answerOpenQuestion(text, interactionScope("ask", text));
|
|
221
|
+
if (operation && !result.isError && result.structuredContent) {
|
|
222
|
+
ledger.remember("ask", operation, { text: result.content[0]!.text, outcome: result.structuredContent });
|
|
223
|
+
}
|
|
224
|
+
return result;
|
|
225
|
+
},
|
|
162
226
|
);
|
|
163
227
|
}
|
|
164
228
|
|
|
@@ -271,7 +335,7 @@ async function publishMeshEvent(
|
|
|
271
335
|
|
|
272
336
|
/** Not paired is an error, not an outcome: nothing was sent, nothing was asked. */
|
|
273
337
|
function notPaired() {
|
|
274
|
-
return { isError: true, content: [{ type: "text" as const, text: NOT_PAIRED }] };
|
|
338
|
+
return { isError: true as const, content: [{ type: "text" as const, text: NOT_PAIRED }] };
|
|
275
339
|
}
|
|
276
340
|
|
|
277
341
|
function interactionScope(tool: "ask" | "ask_yes_no", text: string): TaskInteractionScope | undefined {
|
|
@@ -288,22 +352,22 @@ function interactionScope(tool: "ask" | "ask_yes_no", text: string): TaskInterac
|
|
|
288
352
|
};
|
|
289
353
|
}
|
|
290
354
|
|
|
291
|
-
|
|
355
|
+
type ToolAnswer = {
|
|
356
|
+
isError?: boolean;
|
|
357
|
+
content: Array<{ type: "text"; text: string }>;
|
|
358
|
+
structuredContent?: Record<string, unknown>;
|
|
359
|
+
};
|
|
360
|
+
|
|
361
|
+
async function answerYesNo(questionText: string, scope?: TaskInteractionScope): Promise<ToolAnswer> {
|
|
292
362
|
const client = await relay();
|
|
293
363
|
if (!client) return notPaired();
|
|
294
364
|
const outcome = await askYesNoOutcome(client, questionText, undefined, scope);
|
|
295
|
-
return
|
|
296
|
-
content: [{ type: "text" as const, text: yesNoText(outcome) }],
|
|
297
|
-
structuredContent: outcome,
|
|
298
|
-
};
|
|
365
|
+
return answered(yesNoText(outcome), outcome);
|
|
299
366
|
}
|
|
300
367
|
|
|
301
|
-
async function answerOpenQuestion(questionText: string, scope?: TaskInteractionScope) {
|
|
368
|
+
async function answerOpenQuestion(questionText: string, scope?: TaskInteractionScope): Promise<ToolAnswer> {
|
|
302
369
|
const client = await relay();
|
|
303
370
|
if (!client) return notPaired();
|
|
304
371
|
const outcome = await askOpenQuestionOutcome(client, questionText, undefined, scope);
|
|
305
|
-
return
|
|
306
|
-
content: [{ type: "text" as const, text: openAnswerText(outcome) }],
|
|
307
|
-
structuredContent: outcome,
|
|
308
|
-
};
|
|
372
|
+
return answered(openAnswerText(outcome), outcome);
|
|
309
373
|
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a call already did, by the name the model gave it.
|
|
3
|
+
*
|
|
4
|
+
* A tool call is not a transaction: a Mesh event can be recorded and sent
|
|
5
|
+
* before the status text fails on the wire, and a model that retries the
|
|
6
|
+
* whole call would publish the event twice — or ask the person the same
|
|
7
|
+
* question again. Given an operationId, the server remembers what each part
|
|
8
|
+
* of a call came to and, on the same id again, does only what is still
|
|
9
|
+
* undone, answering with the whole.
|
|
10
|
+
*/
|
|
11
|
+
export type OperationRecord = {
|
|
12
|
+
at: number;
|
|
13
|
+
tool: string;
|
|
14
|
+
text: string;
|
|
15
|
+
outcome: Record<string, unknown>;
|
|
16
|
+
/** The part of the call that has not happened yet, to be done on the retry. */
|
|
17
|
+
pending?: "message";
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export const OPERATION_TTL_MS = 15 * 60_000;
|
|
21
|
+
const MAX_OPERATIONS = 64;
|
|
22
|
+
|
|
23
|
+
export class OperationLedger {
|
|
24
|
+
private readonly records = new Map<string, OperationRecord>();
|
|
25
|
+
|
|
26
|
+
constructor(private readonly ttlMs = OPERATION_TTL_MS) {}
|
|
27
|
+
|
|
28
|
+
private key(tool: string, operationId: string): string {
|
|
29
|
+
return `${tool}\0${operationId}`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
recall(tool: string, operationId: string, now = Date.now()): OperationRecord | undefined {
|
|
33
|
+
const record = this.records.get(this.key(tool, operationId));
|
|
34
|
+
if (!record) return undefined;
|
|
35
|
+
if (now - record.at > this.ttlMs) {
|
|
36
|
+
this.records.delete(this.key(tool, operationId));
|
|
37
|
+
return undefined;
|
|
38
|
+
}
|
|
39
|
+
return record;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
remember(tool: string, operationId: string, record: Omit<OperationRecord, "at" | "tool">, now = Date.now()): void {
|
|
43
|
+
for (const [key, item] of this.records) {
|
|
44
|
+
if (now - item.at > this.ttlMs) this.records.delete(key);
|
|
45
|
+
}
|
|
46
|
+
this.records.set(this.key(tool, operationId), { ...record, at: now, tool });
|
|
47
|
+
while (this.records.size > MAX_OPERATIONS) {
|
|
48
|
+
const oldest = this.records.keys().next().value;
|
|
49
|
+
if (oldest == null) break;
|
|
50
|
+
this.records.delete(oldest);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
package/package.json
CHANGED
|
@@ -247,6 +247,28 @@ export const MeshEvent = z.object({
|
|
|
247
247
|
});
|
|
248
248
|
export type MeshEvent = z.infer<typeof MeshEvent>;
|
|
249
249
|
|
|
250
|
+
/**
|
|
251
|
+
* The person releases a claim that is nobody's to release by ownership: an
|
|
252
|
+
* agent that died holding a file, or one that will not let go. The phone
|
|
253
|
+
* sends it to each computer of the Project under the Project's key. It is
|
|
254
|
+
* not a RESOURCE_RELEASE event, which only an owner may make; it is the
|
|
255
|
+
* person's own authority, and the computer writes down that it was used.
|
|
256
|
+
*/
|
|
257
|
+
export const MeshClaimRelease = z.object({
|
|
258
|
+
type: z.literal("mesh.claim.release"),
|
|
259
|
+
sessionId: Identifier,
|
|
260
|
+
projectId: Identifier,
|
|
261
|
+
claimId: Identifier,
|
|
262
|
+
reason: Detail.optional(),
|
|
263
|
+
requestId: Identifier.optional(),
|
|
264
|
+
createdAt: z.number().nonnegative(),
|
|
265
|
+
}).strict().superRefine((value, ctx) => {
|
|
266
|
+
if (value.sessionId !== value.projectId) {
|
|
267
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["sessionId"], message: "project scope mismatch" });
|
|
268
|
+
}
|
|
269
|
+
});
|
|
270
|
+
export type MeshClaimRelease = z.infer<typeof MeshClaimRelease>;
|
|
271
|
+
|
|
250
272
|
export const MeshHandoffPrepare = z.object({
|
|
251
273
|
type: z.literal("mesh.handoff.prepare"),
|
|
252
274
|
sessionId: Identifier,
|
|
@@ -21,6 +21,7 @@ import { AgentId, Role } from "./messages/primitives";
|
|
|
21
21
|
import {
|
|
22
22
|
MeshEndpointPolicy,
|
|
23
23
|
MeshEvent,
|
|
24
|
+
MeshClaimRelease,
|
|
24
25
|
MeshHandoffPrepare,
|
|
25
26
|
MeshSnapshot,
|
|
26
27
|
} from "./messages/mesh";
|
|
@@ -116,6 +117,7 @@ export const Payload = z.union([
|
|
|
116
117
|
ToolUpdate,
|
|
117
118
|
ToolUpdateResult,
|
|
118
119
|
MeshEvent,
|
|
120
|
+
MeshClaimRelease,
|
|
119
121
|
MeshHandoffPrepare,
|
|
120
122
|
MeshSnapshot,
|
|
121
123
|
MeshEndpointPolicy,
|