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.
@@ -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
- if (!description)
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
+ }
@@ -0,0 +1,6 @@
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 {};
@@ -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
- constructor(launch, processes = new Map(), onNotification, onServerRequest, onProcessExit, resolveSkills, planStatusSource, onPlanStatus) {
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: null,
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: null,
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
- GESTALT_MOBILE_ORG_PLAN_STATUS_DIRECTORY: lease.statusDirectory,
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 { planPath: signal.planPath, updatedAt: signal.updatedAt };
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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gestalt-mobile",
3
- "version": "0.6.1",
3
+ "version": "0.8.0",
4
4
  "description": "Mobile-first web relay for durable Codex development sessions",
5
5
  "keywords": [
6
6
  "codex",