gestalt-mobile 0.6.1 → 0.8.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/client/assets/index-BhydfzsN.js +12 -0
- package/dist/client/assets/index-DN1aV-V1.css +1 -0
- package/dist/client/index.html +2 -2
- package/dist/server/server/app.js +3 -0
- package/dist/server/server/cli.js +1 -0
- package/dist/server/server/composition.js +40 -4
- package/dist/server/server/features/plans/application/measurement-snapshot.js +41 -0
- package/dist/server/server/features/plans/application/parse-supervised-plan.js +48 -1
- package/dist/server/server/features/plans/get-measurement/endpoint.js +21 -0
- package/dist/server/server/features/plans/get-measurement/response.js +6 -0
- package/dist/server/server/features/sessions/interaction/kind.js +1 -1
- package/dist/server/server/features/sessions/interaction/response-validator.js +8 -0
- package/dist/server/server/platform/codex/codex-process-launcher.js +2 -0
- package/dist/server/server/platform/codex/server-request.js +10 -0
- package/dist/server/server/platform/codex/session-runtime.js +80 -5
- package/dist/server/server/platform/plans/filesystem-plan-status-source.js +9 -2
- package/dist/server/server/platform/plans/plan-measurement-command.js +13 -0
- package/dist/server/server/platform/plans/plan-measurement-refresh.js +89 -0
- package/dist/server/shared/contracts/quiz.js +161 -0
- package/package.json +1 -1
- package/dist/client/assets/index-CQqFQn_3.js +0 -12
- package/dist/client/assets/index-DSU_Fcvo.css +0 -1
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright (C) 2026 Dyne.org foundation
|
|
3
|
+
* Designed by Denis Roio <jaromil@dyne.org>
|
|
4
|
+
* SPDX-License-Identifier: AGPL-3.0-or-later
|
|
5
|
+
*/
|
|
6
|
+
export function createPlanMeasurementSnapshot(input) {
|
|
7
|
+
return Object.freeze({
|
|
8
|
+
capturedAt: input.capturedAt,
|
|
9
|
+
weeklyRemainingPercent: weeklyRemainingPercent(input.rateLimits),
|
|
10
|
+
threadTokens: totalThreadTokens(input.tokenUsage),
|
|
11
|
+
});
|
|
12
|
+
}
|
|
13
|
+
/** Returns the longest valid rate-limit window, which is the weekly window. */
|
|
14
|
+
export function weeklyRateLimitWindow(windows) {
|
|
15
|
+
if (!windows)
|
|
16
|
+
return null;
|
|
17
|
+
const valid = windows.filter((window) => Number.isFinite(window.durationSeconds) &&
|
|
18
|
+
window.durationSeconds > 0 &&
|
|
19
|
+
Number.isFinite(window.usedPercent) &&
|
|
20
|
+
window.usedPercent >= 0 &&
|
|
21
|
+
window.usedPercent <= 100);
|
|
22
|
+
return valid.reduce((weekly, window) => (!weekly || window.durationSeconds > weekly.durationSeconds ? window : weekly), null);
|
|
23
|
+
}
|
|
24
|
+
export function weeklyRemainingPercent(windows) {
|
|
25
|
+
const weekly = weeklyRateLimitWindow(windows);
|
|
26
|
+
return weekly ? 100 - weekly.usedPercent : null;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Adds only the three independent cumulative counters in the normalized
|
|
30
|
+
* adapter contract. A missing or invalid counter makes token usage unavailable
|
|
31
|
+
* instead of silently treating it as zero.
|
|
32
|
+
*/
|
|
33
|
+
export function totalThreadTokens(usage) {
|
|
34
|
+
if (!usage || !Object.values(usage).every(isNonNegativeSafeInteger))
|
|
35
|
+
return null;
|
|
36
|
+
const total = usage.inputTokens + usage.cachedInputTokens + usage.outputTokens;
|
|
37
|
+
return Number.isSafeInteger(total) ? total : null;
|
|
38
|
+
}
|
|
39
|
+
function isNonNegativeSafeInteger(value) {
|
|
40
|
+
return Number.isSafeInteger(value) && value >= 0;
|
|
41
|
+
}
|
|
@@ -142,7 +142,8 @@ function buildSteps(headings) {
|
|
|
142
142
|
}
|
|
143
143
|
function makeStep(heading) {
|
|
144
144
|
const description = descriptionFor(heading);
|
|
145
|
-
|
|
145
|
+
const measurement = measurementFor(heading);
|
|
146
|
+
if (!description || measurement === null)
|
|
146
147
|
return null;
|
|
147
148
|
if (heading.level === 1) {
|
|
148
149
|
const reviewStatus = heading.properties.get('REVIEW_STATUS');
|
|
@@ -158,6 +159,7 @@ function makeStep(heading) {
|
|
|
158
159
|
reviewStatus: reviewStatus,
|
|
159
160
|
skills: skills.split(/\s+/).filter(Boolean),
|
|
160
161
|
description,
|
|
162
|
+
...(measurement ? { measurement } : {}),
|
|
161
163
|
children: [],
|
|
162
164
|
};
|
|
163
165
|
}
|
|
@@ -170,9 +172,54 @@ function makeStep(heading) {
|
|
|
170
172
|
state: heading.state,
|
|
171
173
|
priority: heading.priority,
|
|
172
174
|
description,
|
|
175
|
+
...(measurement ? { measurement } : {}),
|
|
173
176
|
children: [],
|
|
174
177
|
};
|
|
175
178
|
}
|
|
179
|
+
const measurementProperties = {
|
|
180
|
+
STARTED_AT: 'startedAt',
|
|
181
|
+
UPDATED_AT: 'updatedAt',
|
|
182
|
+
COMPLETED_AT: 'completedAt',
|
|
183
|
+
ELAPSED_SECONDS: 'elapsedSeconds',
|
|
184
|
+
WEEKLY_REMAINING_START: 'weeklyRemainingStart',
|
|
185
|
+
WEEKLY_REMAINING_CURRENT: 'weeklyRemainingCurrent',
|
|
186
|
+
WEEKLY_REMAINING_END: 'weeklyRemainingEnd',
|
|
187
|
+
WEEKLY_PERCENT_USED: 'weeklyPercentUsed',
|
|
188
|
+
TOKENS_START: 'tokensStart',
|
|
189
|
+
TOKENS_CURRENT: 'tokensCurrent',
|
|
190
|
+
TOKENS_END: 'tokensEnd',
|
|
191
|
+
TOKENS_USED: 'tokensUsed',
|
|
192
|
+
};
|
|
193
|
+
function measurementFor(heading) {
|
|
194
|
+
const measurement = {};
|
|
195
|
+
for (const [property, field] of Object.entries(measurementProperties)) {
|
|
196
|
+
const value = heading.properties.get(property);
|
|
197
|
+
if (value === undefined)
|
|
198
|
+
continue;
|
|
199
|
+
if (property.endsWith('_AT')) {
|
|
200
|
+
if (!isUtcIsoInstant(value))
|
|
201
|
+
return null;
|
|
202
|
+
measurement[field] = value;
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
if (!/^\d+$/.test(value))
|
|
206
|
+
return null;
|
|
207
|
+
const number = Number(value);
|
|
208
|
+
if (!Number.isSafeInteger(number) || (property.startsWith('WEEKLY_') && number > 100))
|
|
209
|
+
return null;
|
|
210
|
+
measurement[field] = number;
|
|
211
|
+
}
|
|
212
|
+
return Object.keys(measurement).length === 0 ? undefined : measurement;
|
|
213
|
+
}
|
|
214
|
+
function isUtcIsoInstant(value) {
|
|
215
|
+
if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{3})?Z$/.test(value))
|
|
216
|
+
return false;
|
|
217
|
+
const instant = new Date(value);
|
|
218
|
+
if (Number.isNaN(instant.getTime()))
|
|
219
|
+
return false;
|
|
220
|
+
const canonical = instant.toISOString();
|
|
221
|
+
return value === canonical || value === canonical.replace('.000Z', 'Z');
|
|
222
|
+
}
|
|
176
223
|
function descriptionFor(heading) {
|
|
177
224
|
const fields = heading.level === 1 ? ['Effort', 'Goal', 'Notes'] : ['Why', 'Change', 'Tests', 'Done when'];
|
|
178
225
|
if (!fields.every((field) => heading.descriptions.has(field)))
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright (C) 2026 Dyne.org foundation
|
|
3
|
+
* Designed by Denis Roio <jaromil@dyne.org>
|
|
4
|
+
* SPDX-License-Identifier: AGPL-3.0-or-later
|
|
5
|
+
*/
|
|
6
|
+
export function registerGetPlanMeasurement(app, deps) {
|
|
7
|
+
app.get('/api/sessions/:id/plan-measurement', async (request, reply) => {
|
|
8
|
+
const id = request.params.id;
|
|
9
|
+
const authorization = typeof request.headers.authorization === 'string'
|
|
10
|
+
? request.headers.authorization
|
|
11
|
+
: undefined;
|
|
12
|
+
if (!deps.exists(id) || !deps.authorize(id, authorization))
|
|
13
|
+
return reply.code(404).send({ code: 'SESSION_NOT_FOUND' });
|
|
14
|
+
try {
|
|
15
|
+
return reply.send(await deps.read(id));
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
return reply.code(503).send({ code: 'PLAN_MEASUREMENT_UNAVAILABLE' });
|
|
19
|
+
}
|
|
20
|
+
});
|
|
21
|
+
}
|
|
@@ -4,5 +4,5 @@
|
|
|
4
4
|
* SPDX-License-Identifier: AGPL-3.0-or-later
|
|
5
5
|
*/
|
|
6
6
|
export function isInteractionKind(value) {
|
|
7
|
-
return ['commandApproval', 'fileChangeApproval', 'permissionsApproval', 'userInput'].includes(value);
|
|
7
|
+
return ['commandApproval', 'fileChangeApproval', 'permissionsApproval', 'userInput', 'quiz'].includes(value);
|
|
8
8
|
}
|
|
@@ -3,16 +3,24 @@
|
|
|
3
3
|
* Designed by Denis Roio <jaromil@dyne.org>
|
|
4
4
|
* SPDX-License-Identifier: AGPL-3.0-or-later
|
|
5
5
|
*/
|
|
6
|
+
import { isQuizToolResponseForQuiz, parseQuiz } from '../../../../shared/contracts/quiz.js';
|
|
6
7
|
/** Validates the relay-safe subset of Codex's generated server-request responses. */
|
|
7
8
|
export function isValidInteractionResponse(kind, value) {
|
|
8
9
|
if (!isRecord(value))
|
|
9
10
|
return false;
|
|
10
11
|
if (kind === 'userInput')
|
|
11
12
|
return isValidUserInputResponse(value);
|
|
13
|
+
if (kind === 'quiz')
|
|
14
|
+
return false;
|
|
12
15
|
if (kind === 'permissionsApproval')
|
|
13
16
|
return isRecord(value.permissions) && (value.scope === 'turn' || value.scope === 'session');
|
|
14
17
|
return isValidApprovalDecision(kind, value.decision);
|
|
15
18
|
}
|
|
19
|
+
/** Validates a dynamic quiz reply against the quiz that created the interaction. */
|
|
20
|
+
export function isValidQuizInteractionResponse(payload, value) {
|
|
21
|
+
const quiz = parseQuiz(payload);
|
|
22
|
+
return quiz !== null && isQuizToolResponseForQuiz(quiz, value);
|
|
23
|
+
}
|
|
16
24
|
function isValidUserInputResponse(value) {
|
|
17
25
|
if (!isRecord(value.answers))
|
|
18
26
|
return false;
|
|
@@ -31,5 +31,7 @@ export function codexChildEnvironment(environment) {
|
|
|
31
31
|
const inherited = { ...process.env };
|
|
32
32
|
delete inherited.GESTALT_MOBILE_ORG_PLAN_STATUS_FILE;
|
|
33
33
|
delete inherited.GESTALT_MOBILE_ORG_PLAN_STATUS_DIRECTORY;
|
|
34
|
+
delete inherited.GESTALT_MOBILE_ORG_PLAN_MEASUREMENT_URL;
|
|
35
|
+
delete inherited.GESTALT_MOBILE_ORG_PLAN_MEASUREMENT_TOKEN;
|
|
34
36
|
return { ...inherited, ...environment };
|
|
35
37
|
}
|
|
@@ -3,7 +3,14 @@
|
|
|
3
3
|
* Designed by Denis Roio <jaromil@dyne.org>
|
|
4
4
|
* SPDX-License-Identifier: AGPL-3.0-or-later
|
|
5
5
|
*/
|
|
6
|
+
import { GESTALT_QUIZ_TOOL_NAME, parseQuiz } from '../../../shared/contracts/quiz.js';
|
|
6
7
|
export function toPendingInteraction(input) {
|
|
8
|
+
if (input.method === 'item/tool/call') {
|
|
9
|
+
if (!isRecord(input.params) || input.params.tool !== GESTALT_QUIZ_TOOL_NAME)
|
|
10
|
+
return null;
|
|
11
|
+
const quiz = parseQuiz(input.params.arguments);
|
|
12
|
+
return quiz ? { requestId: String(input.id), kind: 'quiz', payload: quiz } : null;
|
|
13
|
+
}
|
|
7
14
|
const kind = {
|
|
8
15
|
'item/commandExecution/requestApproval': 'commandApproval',
|
|
9
16
|
'item/fileChange/requestApproval': 'fileChangeApproval',
|
|
@@ -12,3 +19,6 @@ export function toPendingInteraction(input) {
|
|
|
12
19
|
}[input.method];
|
|
13
20
|
return kind ? { requestId: String(input.id), kind, payload: input.params } : null;
|
|
14
21
|
}
|
|
22
|
+
function isRecord(value) {
|
|
23
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
24
|
+
}
|
|
@@ -4,6 +4,9 @@
|
|
|
4
4
|
* SPDX-License-Identifier: AGPL-3.0-or-later
|
|
5
5
|
*/
|
|
6
6
|
import { RelaySession, } from '../../features/sessions/model/relay-session.js';
|
|
7
|
+
import { randomUUID } from 'node:crypto';
|
|
8
|
+
import { createPlanMeasurementSnapshot, } from '../../features/plans/application/measurement-snapshot.js';
|
|
9
|
+
import { gestaltQuizDynamicTool } from '../../../shared/contracts/quiz.js';
|
|
7
10
|
export class CodexSessionRuntime {
|
|
8
11
|
launch;
|
|
9
12
|
processes;
|
|
@@ -13,7 +16,8 @@ export class CodexSessionRuntime {
|
|
|
13
16
|
resolveSkills;
|
|
14
17
|
planStatusSource;
|
|
15
18
|
onPlanStatus;
|
|
16
|
-
|
|
19
|
+
planMeasurementBaseUrl;
|
|
20
|
+
constructor(launch, processes = new Map(), onNotification, onServerRequest, onProcessExit, resolveSkills, planStatusSource, onPlanStatus, planMeasurementBaseUrl) {
|
|
17
21
|
this.launch = launch;
|
|
18
22
|
this.processes = processes;
|
|
19
23
|
this.onNotification = onNotification;
|
|
@@ -22,11 +26,13 @@ export class CodexSessionRuntime {
|
|
|
22
26
|
this.resolveSkills = resolveSkills;
|
|
23
27
|
this.planStatusSource = planStatusSource;
|
|
24
28
|
this.onPlanStatus = onPlanStatus;
|
|
29
|
+
this.planMeasurementBaseUrl = planMeasurementBaseUrl;
|
|
25
30
|
}
|
|
26
31
|
pendingRequests = new Map();
|
|
27
32
|
exitUnsubscribers = new Map();
|
|
28
33
|
threadIds = new Map();
|
|
29
34
|
planStatusLeases = new Map();
|
|
35
|
+
planMeasurementTokens = new Map();
|
|
30
36
|
async start(session, now, settings = {}) {
|
|
31
37
|
const process = await this.launchForSession(session);
|
|
32
38
|
try {
|
|
@@ -35,11 +41,12 @@ export class CodexSessionRuntime {
|
|
|
35
41
|
this.attachExitHandler(session.id, process);
|
|
36
42
|
await process.rpc.request('initialize', {
|
|
37
43
|
clientInfo: { name: 'gestalt-mobile', version: '0.1.0' },
|
|
38
|
-
capabilities:
|
|
44
|
+
capabilities: { experimentalApi: true },
|
|
39
45
|
});
|
|
40
46
|
const result = (await process.rpc.request('thread/start', {
|
|
41
47
|
cwd: session.workspacePath,
|
|
42
48
|
approvalPolicy: settings.approvalPolicy ?? 'on-request',
|
|
49
|
+
dynamicTools: [gestaltQuizDynamicTool],
|
|
43
50
|
...(settings.model ? { model: settings.model } : {}),
|
|
44
51
|
...(settings.sandbox ? { sandbox: settings.sandbox } : {}),
|
|
45
52
|
}));
|
|
@@ -111,6 +118,24 @@ export class CodexSessionRuntime {
|
|
|
111
118
|
throw new Error('CODEX_SESSION_NOT_RUNNING');
|
|
112
119
|
await process.rpc.request('turn/interrupt', { threadId: session.threadId, turnId });
|
|
113
120
|
}
|
|
121
|
+
async readPlanMeasurement(session) {
|
|
122
|
+
const process = this.processes.get(session.id);
|
|
123
|
+
if (!process || !session.threadId)
|
|
124
|
+
throw new Error('CODEX_SESSION_NOT_RUNNING');
|
|
125
|
+
const [rateLimits, thread] = await Promise.all([
|
|
126
|
+
process.rpc.request('account/rateLimits/read', {}),
|
|
127
|
+
process.rpc.request('thread/read', { threadId: session.threadId, includeTurns: true }),
|
|
128
|
+
]);
|
|
129
|
+
return createPlanMeasurementSnapshot({
|
|
130
|
+
capturedAt: new Date().toISOString(),
|
|
131
|
+
rateLimits: rateLimitWindows(rateLimits),
|
|
132
|
+
tokenUsage: threadTokenUsage(thread),
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
authorizePlanMeasurement(sessionId, authorization) {
|
|
136
|
+
const token = this.planMeasurementTokens.get(sessionId);
|
|
137
|
+
return Boolean(token && authorization === `Bearer ${token}`);
|
|
138
|
+
}
|
|
114
139
|
async readHistory(session) {
|
|
115
140
|
const process = this.processes.get(session.id);
|
|
116
141
|
if (!process || !session.threadId)
|
|
@@ -140,11 +165,12 @@ export class CodexSessionRuntime {
|
|
|
140
165
|
this.attachExitHandler(session.id, process);
|
|
141
166
|
await process.rpc.request('initialize', {
|
|
142
167
|
clientInfo: { name: 'gestalt-mobile', version: '0.1.0' },
|
|
143
|
-
capabilities:
|
|
168
|
+
capabilities: { experimentalApi: true },
|
|
144
169
|
});
|
|
145
170
|
await process.rpc.request('thread/resume', {
|
|
146
171
|
threadId: session.threadId,
|
|
147
172
|
cwd: session.workspacePath,
|
|
173
|
+
dynamicTools: [gestaltQuizDynamicTool],
|
|
148
174
|
});
|
|
149
175
|
this.processes.set(session.id, process);
|
|
150
176
|
this.threadIds.set(session.id, session.threadId);
|
|
@@ -166,6 +192,7 @@ export class CodexSessionRuntime {
|
|
|
166
192
|
this.exitUnsubscribers.set(sessionId, process.onExit?.(() => {
|
|
167
193
|
this.processes.delete(sessionId);
|
|
168
194
|
this.threadIds.delete(sessionId);
|
|
195
|
+
this.planMeasurementTokens.delete(sessionId);
|
|
169
196
|
this.exitUnsubscribers.delete(sessionId);
|
|
170
197
|
this.releasePlanStatus(sessionId);
|
|
171
198
|
this.onProcessExit?.(sessionId);
|
|
@@ -178,14 +205,24 @@ export class CodexSessionRuntime {
|
|
|
178
205
|
if (lease)
|
|
179
206
|
this.planStatusLeases.set(session.id, lease);
|
|
180
207
|
try {
|
|
208
|
+
const token = randomUUID();
|
|
209
|
+
this.planMeasurementTokens.set(session.id, token);
|
|
181
210
|
return this.launch({
|
|
182
211
|
profile: session.profile,
|
|
183
212
|
cwd: session.workspacePath,
|
|
184
213
|
skillsConfig: await this.resolveSkills?.(session),
|
|
185
|
-
...(lease
|
|
214
|
+
...((lease || this.planMeasurementBaseUrl)
|
|
186
215
|
? {
|
|
187
216
|
environment: {
|
|
188
|
-
|
|
217
|
+
...(lease
|
|
218
|
+
? { GESTALT_MOBILE_ORG_PLAN_STATUS_DIRECTORY: lease.statusDirectory }
|
|
219
|
+
: {}),
|
|
220
|
+
...(this.planMeasurementBaseUrl
|
|
221
|
+
? {
|
|
222
|
+
GESTALT_MOBILE_ORG_PLAN_MEASUREMENT_URL: `${this.planMeasurementBaseUrl}/api/sessions/${session.id}/plan-measurement`,
|
|
223
|
+
GESTALT_MOBILE_ORG_PLAN_MEASUREMENT_TOKEN: token,
|
|
224
|
+
}
|
|
225
|
+
: {}),
|
|
189
226
|
},
|
|
190
227
|
}
|
|
191
228
|
: {}),
|
|
@@ -201,3 +238,41 @@ export class CodexSessionRuntime {
|
|
|
201
238
|
this.planStatusLeases.delete(sessionId);
|
|
202
239
|
}
|
|
203
240
|
}
|
|
241
|
+
function rateLimitWindows(value) {
|
|
242
|
+
const limits = asRecord(value)?.rateLimits;
|
|
243
|
+
if (!Array.isArray(limits))
|
|
244
|
+
return undefined;
|
|
245
|
+
return limits.flatMap((limit) => {
|
|
246
|
+
const record = asRecord(limit);
|
|
247
|
+
const durationMinutes = record?.windowDurationMins;
|
|
248
|
+
const usedPercent = record?.usedPercent;
|
|
249
|
+
return typeof durationMinutes === 'number' && typeof usedPercent === 'number'
|
|
250
|
+
? [{ durationSeconds: durationMinutes * 60, usedPercent }]
|
|
251
|
+
: [];
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
function threadTokenUsage(value) {
|
|
255
|
+
const turns = asRecord(asRecord(value)?.thread)?.turns;
|
|
256
|
+
if (!Array.isArray(turns))
|
|
257
|
+
return undefined;
|
|
258
|
+
let inputTokens = 0;
|
|
259
|
+
let cachedInputTokens = 0;
|
|
260
|
+
let outputTokens = 0;
|
|
261
|
+
for (const turn of turns) {
|
|
262
|
+
const usage = asRecord(asRecord(turn)?.tokenUsage);
|
|
263
|
+
if (!usage)
|
|
264
|
+
return undefined;
|
|
265
|
+
const input = usage.inputTokens;
|
|
266
|
+
const cached = usage.cachedInputTokens;
|
|
267
|
+
const output = usage.outputTokens;
|
|
268
|
+
if (typeof input !== 'number' || typeof cached !== 'number' || typeof output !== 'number')
|
|
269
|
+
return undefined;
|
|
270
|
+
inputTokens += input;
|
|
271
|
+
cachedInputTokens += cached;
|
|
272
|
+
outputTokens += output;
|
|
273
|
+
}
|
|
274
|
+
return { inputTokens, cachedInputTokens, outputTokens };
|
|
275
|
+
}
|
|
276
|
+
function asRecord(value) {
|
|
277
|
+
return value && typeof value === 'object' ? value : undefined;
|
|
278
|
+
}
|
|
@@ -259,7 +259,7 @@ class ActiveLease {
|
|
|
259
259
|
const previousStatusPath = this.activeStatusPath;
|
|
260
260
|
this.activeStatusPath = statusPath;
|
|
261
261
|
this.onActiveStatusPath(statusPath);
|
|
262
|
-
this.listener({ kind: 'updated', plan: result.plan, identity });
|
|
262
|
+
this.listener({ kind: 'updated', plan: result.plan, identity, planPath, reason: signal.reason });
|
|
263
263
|
if (previousStatusPath && previousStatusPath !== statusPath)
|
|
264
264
|
await rm(previousStatusPath, { force: true }).catch(() => { });
|
|
265
265
|
}
|
|
@@ -288,12 +288,19 @@ function parseSignal(source) {
|
|
|
288
288
|
return null;
|
|
289
289
|
if (typeof signal.reason !== 'string' || !isRfc3339Utc(signal.updatedAt))
|
|
290
290
|
return null;
|
|
291
|
-
return {
|
|
291
|
+
return {
|
|
292
|
+
planPath: signal.planPath,
|
|
293
|
+
updatedAt: signal.updatedAt,
|
|
294
|
+
reason: isPlanSignalReason(signal.reason) ? signal.reason : null,
|
|
295
|
+
};
|
|
292
296
|
}
|
|
293
297
|
catch {
|
|
294
298
|
return null;
|
|
295
299
|
}
|
|
296
300
|
}
|
|
301
|
+
function isPlanSignalReason(value) {
|
|
302
|
+
return value === 'authoring-start' || value === 'work-start' || value === 'checkpoint' || value === 'update';
|
|
303
|
+
}
|
|
297
304
|
function isRfc3339Utc(value) {
|
|
298
305
|
return (typeof value === 'string' &&
|
|
299
306
|
/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z$/.test(value) &&
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright (C) 2026 Dyne.org foundation
|
|
3
|
+
* Designed by Denis Roio <jaromil@dyne.org>
|
|
4
|
+
* SPDX-License-Identifier: AGPL-3.0-or-later
|
|
5
|
+
*/
|
|
6
|
+
import { execFile } from 'node:child_process';
|
|
7
|
+
import { promisify } from 'node:util';
|
|
8
|
+
const execute = promisify(execFile);
|
|
9
|
+
export const PLAN_MEASUREMENT_COMMAND_TIMEOUT_MS = 15_000;
|
|
10
|
+
/** Invokes the explicitly configured Org Plan helper without a shell. */
|
|
11
|
+
export async function checkpointPlanMeasurement(helperPath, planPath, stepId, snapshot) {
|
|
12
|
+
await execute(helperPath, ['measure', 'checkpoint', planPath, stepId, JSON.stringify(snapshot)], { shell: false, timeout: PLAN_MEASUREMENT_COMMAND_TIMEOUT_MS });
|
|
13
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright (C) 2026 Dyne.org foundation
|
|
3
|
+
* Designed by Denis Roio <jaromil@dyne.org>
|
|
4
|
+
* SPDX-License-Identifier: AGPL-3.0-or-later
|
|
5
|
+
*/
|
|
6
|
+
export const PLAN_MEASUREMENT_REFRESH_MS = 60_000;
|
|
7
|
+
/**
|
|
8
|
+
* Refreshes the current WIP step at a bounded cadence. A refresh is deliberately
|
|
9
|
+
* owned by the session that owns the plan, so replacing, closing, or stopping a
|
|
10
|
+
* session cannot leave a timer writing another session's plan.
|
|
11
|
+
*/
|
|
12
|
+
export class PlanMeasurementRefresh {
|
|
13
|
+
readSnapshot;
|
|
14
|
+
checkpoint;
|
|
15
|
+
active = new Map();
|
|
16
|
+
timers = new Map();
|
|
17
|
+
inFlight = new Set();
|
|
18
|
+
constructor(readSnapshot, checkpoint) {
|
|
19
|
+
this.readSnapshot = readSnapshot;
|
|
20
|
+
this.checkpoint = checkpoint;
|
|
21
|
+
}
|
|
22
|
+
accept(sessionId, update) {
|
|
23
|
+
const next = activeMeasurement(update);
|
|
24
|
+
if (!next)
|
|
25
|
+
return this.stop(sessionId);
|
|
26
|
+
const current = this.active.get(sessionId);
|
|
27
|
+
this.active.set(sessionId, next);
|
|
28
|
+
if (current?.planPath === next.planPath && current.stepId === next.stepId)
|
|
29
|
+
return;
|
|
30
|
+
this.clearTimer(sessionId);
|
|
31
|
+
this.schedule(sessionId);
|
|
32
|
+
}
|
|
33
|
+
stop(sessionId) {
|
|
34
|
+
this.active.delete(sessionId);
|
|
35
|
+
this.clearTimer(sessionId);
|
|
36
|
+
}
|
|
37
|
+
stopAll() {
|
|
38
|
+
for (const sessionId of this.active.keys())
|
|
39
|
+
this.stop(sessionId);
|
|
40
|
+
}
|
|
41
|
+
schedule(sessionId) {
|
|
42
|
+
this.timers.set(sessionId, setTimeout(() => {
|
|
43
|
+
this.timers.delete(sessionId);
|
|
44
|
+
void this.refresh(sessionId);
|
|
45
|
+
}, PLAN_MEASUREMENT_REFRESH_MS));
|
|
46
|
+
}
|
|
47
|
+
async refresh(sessionId) {
|
|
48
|
+
const active = this.active.get(sessionId);
|
|
49
|
+
if (!active || this.inFlight.has(sessionId))
|
|
50
|
+
return;
|
|
51
|
+
this.inFlight.add(sessionId);
|
|
52
|
+
try {
|
|
53
|
+
await this.checkpoint(active.planPath, active.stepId, await this.readSnapshot(sessionId));
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
// A transient Codex or helper failure makes this tick unavailable only.
|
|
57
|
+
}
|
|
58
|
+
finally {
|
|
59
|
+
this.inFlight.delete(sessionId);
|
|
60
|
+
if (this.active.get(sessionId) === active && !this.timers.has(sessionId))
|
|
61
|
+
this.schedule(sessionId);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
clearTimer(sessionId) {
|
|
65
|
+
const timer = this.timers.get(sessionId);
|
|
66
|
+
if (timer)
|
|
67
|
+
clearTimeout(timer);
|
|
68
|
+
this.timers.delete(sessionId);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
function activeMeasurement(update) {
|
|
72
|
+
if (update.kind !== 'updated')
|
|
73
|
+
return null;
|
|
74
|
+
const step = findStep(update.plan, update.plan.currentStepId);
|
|
75
|
+
return step?.state === 'WIP' ? { planPath: update.planPath, stepId: step.id } : null;
|
|
76
|
+
}
|
|
77
|
+
function findStep(plan, id) {
|
|
78
|
+
const visit = (steps) => {
|
|
79
|
+
for (const step of steps) {
|
|
80
|
+
if (step.id === id)
|
|
81
|
+
return step;
|
|
82
|
+
const child = visit(step.children);
|
|
83
|
+
if (child)
|
|
84
|
+
return child;
|
|
85
|
+
}
|
|
86
|
+
return undefined;
|
|
87
|
+
};
|
|
88
|
+
return visit(plan.steps);
|
|
89
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright (C) 2026 Dyne.org foundation
|
|
3
|
+
* Designed by Denis Roio <jaromil@dyne.org>
|
|
4
|
+
* SPDX-License-Identifier: AGPL-3.0-or-later
|
|
5
|
+
*/
|
|
6
|
+
export const GESTALT_QUIZ_TOOL_NAME = 'gestalt_quiz';
|
|
7
|
+
/**
|
|
8
|
+
* The app-server descriptor is intentionally plain JSON so the session adapter
|
|
9
|
+
* can register the same stable contract for new and resumed threads.
|
|
10
|
+
*/
|
|
11
|
+
export const gestaltQuizDynamicTool = {
|
|
12
|
+
type: 'function',
|
|
13
|
+
name: GESTALT_QUIZ_TOOL_NAME,
|
|
14
|
+
description: 'Ask the user one to eight bounded-choice quiz questions. Use this instead of writing a numbered-choice request in chat whenever you need the user to choose among defined options. Each question must include two to five choices and explicitly state whether a custom answer is allowed.',
|
|
15
|
+
inputSchema: {
|
|
16
|
+
type: 'object',
|
|
17
|
+
additionalProperties: false,
|
|
18
|
+
required: ['questions'],
|
|
19
|
+
properties: {
|
|
20
|
+
questions: {
|
|
21
|
+
type: 'array',
|
|
22
|
+
minItems: 1,
|
|
23
|
+
maxItems: 8,
|
|
24
|
+
items: {
|
|
25
|
+
type: 'object',
|
|
26
|
+
additionalProperties: false,
|
|
27
|
+
required: ['id', 'header', 'question', 'choices', 'allowCustom'],
|
|
28
|
+
properties: {
|
|
29
|
+
id: { type: 'string', minLength: 1, maxLength: 64, pattern: '^[A-Za-z][A-Za-z0-9_-]*$' },
|
|
30
|
+
header: { type: 'string', minLength: 1, maxLength: 120 },
|
|
31
|
+
question: { type: 'string', minLength: 1, maxLength: 600 },
|
|
32
|
+
choices: {
|
|
33
|
+
type: 'array',
|
|
34
|
+
minItems: 2,
|
|
35
|
+
maxItems: 5,
|
|
36
|
+
items: {
|
|
37
|
+
type: 'object',
|
|
38
|
+
additionalProperties: false,
|
|
39
|
+
required: ['label', 'description'],
|
|
40
|
+
properties: {
|
|
41
|
+
label: { type: 'string', minLength: 1, maxLength: 160 },
|
|
42
|
+
description: { type: 'string', minLength: 1, maxLength: 600 },
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
allowCustom: { type: 'boolean' },
|
|
47
|
+
isSecret: { type: 'boolean' },
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
/** Parses the bounded payload that is safe to persist and render as a quiz. */
|
|
55
|
+
export function parseQuiz(value, minimumChoices = 2) {
|
|
56
|
+
if (!isRecord(value) || !Array.isArray(value.questions))
|
|
57
|
+
return null;
|
|
58
|
+
if (value.questions.length < 1 || value.questions.length > 8)
|
|
59
|
+
return null;
|
|
60
|
+
const questions = value.questions.map((question) => parseQuestion(question, minimumChoices));
|
|
61
|
+
if (questions.some((question) => question === null))
|
|
62
|
+
return null;
|
|
63
|
+
const parsed = questions;
|
|
64
|
+
return new Set(parsed.map((question) => question.id)).size === parsed.length ? { questions: parsed } : null;
|
|
65
|
+
}
|
|
66
|
+
/** Maps Codex's experimental native request shape into the common quiz value. */
|
|
67
|
+
export function mapNativeUserInputToQuiz(value) {
|
|
68
|
+
if (!isRecord(value) || !Array.isArray(value.questions) || value.questions.length > 3)
|
|
69
|
+
return null;
|
|
70
|
+
return parseQuiz({
|
|
71
|
+
questions: value.questions.map((question) => isRecord(question)
|
|
72
|
+
? {
|
|
73
|
+
id: question.id,
|
|
74
|
+
header: question.header,
|
|
75
|
+
question: question.question,
|
|
76
|
+
choices: question.options,
|
|
77
|
+
allowCustom: question.isOther === true,
|
|
78
|
+
isSecret: question.isSecret === true,
|
|
79
|
+
}
|
|
80
|
+
: question),
|
|
81
|
+
}, 1);
|
|
82
|
+
}
|
|
83
|
+
/** Converts a completed quiz into the app-server dynamic-tool response shape. */
|
|
84
|
+
export function toQuizToolResponse(answers) {
|
|
85
|
+
return {
|
|
86
|
+
contentItems: [
|
|
87
|
+
{
|
|
88
|
+
type: 'input_text',
|
|
89
|
+
text: JSON.stringify({
|
|
90
|
+
answers: Object.fromEntries(answers.filter((answer) => answer.answer.trim()).map((answer) => [answer.id, answer.answer])),
|
|
91
|
+
}),
|
|
92
|
+
},
|
|
93
|
+
],
|
|
94
|
+
success: true,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
export function isQuizToolResponseForQuiz(quiz, value) {
|
|
98
|
+
if (!isRecord(value) || value.success !== true || !Array.isArray(value.contentItems))
|
|
99
|
+
return false;
|
|
100
|
+
const item = value.contentItems[0];
|
|
101
|
+
if (value.contentItems.length !== 1 || !isRecord(item) || item.type !== 'input_text' || typeof item.text !== 'string')
|
|
102
|
+
return false;
|
|
103
|
+
try {
|
|
104
|
+
const response = parseQuizResponse(JSON.parse(item.text));
|
|
105
|
+
return response !== null && isCompleteQuizResponse(quiz, response);
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
return false;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
function parseQuestion(value, minimumChoices) {
|
|
112
|
+
if (!isRecord(value) || !Array.isArray(value.choices) || value.choices.length < minimumChoices || value.choices.length > 5)
|
|
113
|
+
return null;
|
|
114
|
+
if (!isQuestionId(value.id) || !isBoundedText(value.header, 120) || !isBoundedText(value.question, 600))
|
|
115
|
+
return null;
|
|
116
|
+
if (typeof value.allowCustom !== 'boolean')
|
|
117
|
+
return null;
|
|
118
|
+
const choices = value.choices.map(parseChoice);
|
|
119
|
+
if (choices.some((choice) => choice === null))
|
|
120
|
+
return null;
|
|
121
|
+
return {
|
|
122
|
+
id: value.id,
|
|
123
|
+
header: value.header,
|
|
124
|
+
question: value.question,
|
|
125
|
+
choices: choices,
|
|
126
|
+
allowCustom: value.allowCustom,
|
|
127
|
+
isSecret: value.isSecret === true,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
function parseChoice(value) {
|
|
131
|
+
if (!isRecord(value) || !isBoundedText(value.label, 160) || !isBoundedText(value.description, 600))
|
|
132
|
+
return null;
|
|
133
|
+
return { label: value.label, description: value.description };
|
|
134
|
+
}
|
|
135
|
+
function parseQuizResponse(value) {
|
|
136
|
+
if (!isRecord(value) || !isRecord(value.answers))
|
|
137
|
+
return null;
|
|
138
|
+
const entries = Object.entries(value.answers);
|
|
139
|
+
if (entries.length === 0 || entries.some(([id, answer]) => !isQuestionId(id) || !isBoundedText(answer, 2_000)))
|
|
140
|
+
return null;
|
|
141
|
+
return { answers: Object.fromEntries(entries) };
|
|
142
|
+
}
|
|
143
|
+
function isCompleteQuizResponse(quiz, response) {
|
|
144
|
+
const ids = Object.keys(response.answers);
|
|
145
|
+
if (ids.length !== quiz.questions.length || ids.some((id) => !quiz.questions.some((question) => question.id === id)))
|
|
146
|
+
return false;
|
|
147
|
+
return quiz.questions.every((question) => {
|
|
148
|
+
const answer = response.answers[question.id];
|
|
149
|
+
return (typeof answer === 'string' &&
|
|
150
|
+
(question.allowCustom || question.choices.some((choice) => choice.label === answer)));
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
function isQuestionId(value) {
|
|
154
|
+
return typeof value === 'string' && /^[A-Za-z][A-Za-z0-9_-]{0,63}$/.test(value);
|
|
155
|
+
}
|
|
156
|
+
function isBoundedText(value, maximum) {
|
|
157
|
+
return typeof value === 'string' && value.trim().length > 0 && value.length <= maximum;
|
|
158
|
+
}
|
|
159
|
+
function isRecord(value) {
|
|
160
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
161
|
+
}
|