@growthub/cli 0.10.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (28) hide show
  1. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/helper/apply/route.js +307 -0
  2. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/helper/query/route.js +372 -0
  3. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/helper/receipts/route.js +47 -0
  4. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/DataModelShell.jsx +664 -82
  5. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/HelperSidecar.jsx +1371 -0
  6. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/globals.css +1383 -24
  7. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/settings/integrations/page.jsx +7 -21
  8. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/settings/ownership/ownership-panel.jsx +222 -0
  9. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/settings/ownership/page.jsx +19 -0
  10. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/settings/settings-shell.jsx +2 -1
  11. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/workspace-builder.jsx +116 -24
  12. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/workspace-rail.jsx +497 -0
  13. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/growthub.config.json +20 -4
  14. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/adapters/sandboxes/default-local-intelligence.js +19 -4
  15. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-data-model.js +23 -5
  16. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-helper-apply.js +473 -0
  17. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-helper.js +583 -0
  18. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/package-lock.json +34 -0
  19. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/package.json +3 -1
  20. package/assets/worker-kits/growthub-custom-workspace-starter-v1/helpers/export-training-traces.mjs +144 -0
  21. package/assets/worker-kits/growthub-custom-workspace-starter-v1/helpers/grade-raw-pairs.mjs +279 -0
  22. package/assets/worker-kits/growthub-custom-workspace-starter-v1/helpers/harvest-cursor-traces.mjs +288 -0
  23. package/assets/worker-kits/growthub-custom-workspace-starter-v1/helpers/upload-graded-traces.mjs +128 -0
  24. package/assets/worker-kits/growthub-custom-workspace-starter-v1/kit.json +19 -0
  25. package/assets/worker-kits/growthub-custom-workspace-starter-v1/templates/seeded-configs/alignment-loop.config.json +264 -0
  26. package/assets/worker-kits/growthub-custom-workspace-starter-v1/workers/custom-workspace-operator/CLAUDE.md +38 -0
  27. package/dist/index.js +1416 -2627
  28. package/package.json +1 -1
@@ -0,0 +1,307 @@
1
+ /**
2
+ * POST /api/workspace/helper/apply
3
+ *
4
+ * Governed mutation endpoint for workspace helper proposals.
5
+ *
6
+ * Takes accepted WorkspaceHelperProposal objects from a prior query response,
7
+ * validates each against the PATCH allowlist + validateWorkspaceConfig, writes
8
+ * the merged config, and persists a durable receipt per applied proposal.
9
+ *
10
+ * The apply step is always explicit and human-reviewed. The helper never
11
+ * calls this route on its own — it is a separate governed action.
12
+ *
13
+ * Request body (WorkspaceHelperApplyRequest):
14
+ * {
15
+ * proposals: WorkspaceHelperProposal[],
16
+ * reviewedBy?: string,
17
+ * sessionId?: string,
18
+ * }
19
+ *
20
+ * Response (WorkspaceHelperApplyResponse):
21
+ * {
22
+ * ok: boolean,
23
+ * applied: WorkspaceHelperApplyReceipt[],
24
+ * skipped: { proposal, reason }[],
25
+ * workspaceConfig?: object,
26
+ * error?: string,
27
+ * }
28
+ */
29
+
30
+ import { NextResponse } from "next/server";
31
+ import {
32
+ readWorkspaceConfig,
33
+ writeWorkspaceConfig,
34
+ readWorkspaceSourceRecords,
35
+ writeWorkspaceSourceRecords,
36
+ describePersistenceMode,
37
+ } from "@/lib/workspace-config";
38
+ import {
39
+ applyProposalToConfig,
40
+ validateProposalForApply,
41
+ buildApplyReceipt,
42
+ upsertHelperThreadRow,
43
+ } from "@/lib/workspace-helper-apply";
44
+
45
+ const HELPER_APPLY_SOURCE_KEY = "helper:apply:receipts";
46
+
47
+ async function POST(request) {
48
+ let body;
49
+ try {
50
+ body = await request.json();
51
+ } catch {
52
+ return NextResponse.json({ ok: false, error: "invalid JSON body" }, { status: 400 });
53
+ }
54
+
55
+ if (!Array.isArray(body?.proposals) || body.proposals.length === 0) {
56
+ return NextResponse.json(
57
+ { ok: false, error: "proposals must be a non-empty array" },
58
+ { status: 400 }
59
+ );
60
+ }
61
+
62
+ const reviewedBy = typeof body.reviewedBy === "string" ? body.reviewedBy.trim() : "user";
63
+ const sessionId = typeof body.sessionId === "string" ? body.sessionId.trim() : null;
64
+ const threadId = typeof body.threadId === "string" && body.threadId.trim() ? body.threadId.trim() : null;
65
+ const appliedAt = new Date().toISOString();
66
+
67
+ let currentConfig;
68
+ try {
69
+ currentConfig = await readWorkspaceConfig();
70
+ } catch (err) {
71
+ return NextResponse.json(
72
+ { ok: false, error: err?.message || "failed to read workspace config" },
73
+ { status: 500 }
74
+ );
75
+ }
76
+
77
+ const applied = [];
78
+ const skipped = [];
79
+ let workingConfig = currentConfig;
80
+
81
+ for (const proposal of body.proposals) {
82
+ if (
83
+ !proposal ||
84
+ typeof proposal.type !== "string" ||
85
+ typeof proposal.affectedField !== "string" ||
86
+ !proposal.payload ||
87
+ typeof proposal.payload !== "object"
88
+ ) {
89
+ skipped.push({ proposal, reason: "malformed proposal: missing type, affectedField, or payload" });
90
+ continue;
91
+ }
92
+
93
+ // explain.object proposals are informational — no config write needed
94
+ if (proposal.type === "explain.object") {
95
+ applied.push(buildApplyReceipt(proposal, appliedAt, reviewedBy, sessionId));
96
+ continue;
97
+ }
98
+
99
+ const validation = validateProposalForApply(proposal, workingConfig);
100
+ if (!validation.ok) {
101
+ skipped.push({ proposal, reason: validation.error || "failed validation" });
102
+ continue;
103
+ }
104
+
105
+ try {
106
+ workingConfig = applyProposalToConfig(workingConfig, proposal);
107
+ applied.push(buildApplyReceipt(proposal, appliedAt, reviewedBy, sessionId));
108
+ } catch (err) {
109
+ skipped.push({ proposal, reason: err?.message || "apply threw" });
110
+ }
111
+ }
112
+
113
+ // Patch — collect every affected field from accepted proposals AND
114
+ // append the thread row update (so the user-visible Helper Threads object
115
+ // refreshes in the same atomic write as the proposed mutations).
116
+ const mutatingApplied = applied.filter((r) => r.type !== "explain.object");
117
+
118
+ // Upsert the thread row so audit history reflects this apply turn even
119
+ // when nothing mutated (all skipped / explain-only) and even when the
120
+ // CLI flow applies a proposals.json that carries a fresh threadId
121
+ // (no prior in-session query). Both query and apply land on the same
122
+ // governed object so the user sees one row per conversation.
123
+ //
124
+ // Apply outcomes are also appended to the row's messages[] as a
125
+ // `system` turn so the conversation captures both proposals and their
126
+ // governed apply receipts. The conversation tail stays under the cap
127
+ // (~40 entries) via upsertHelperThreadRow's slicing.
128
+ if (threadId) {
129
+ try {
130
+ const existingRows = (workingConfig?.dataModel?.objects || []).find((o) => o?.id === "helper-threads")?.rows || [];
131
+ const existingRow = existingRows.find((r) => r?.id === threadId) || {};
132
+ const firstProposal = body.proposals?.[0];
133
+ const seedTitle = existingRow.title
134
+ || (firstProposal?.rationale ? String(firstProposal.rationale).slice(0, 72) : "Helper thread");
135
+
136
+ // Build a short, plain-language system message describing the apply
137
+ // outcome. This is what the user sees in the conversation panel
138
+ // after their accepted proposals are written.
139
+ const applyLines = [];
140
+ if (applied.length > 0) {
141
+ const summary = applied
142
+ .map((a) => `${a.type}${a.affectedField ? ` → ${a.affectedField}` : ""}`)
143
+ .join(", ");
144
+ applyLines.push(`Applied ${applied.length} change${applied.length === 1 ? "" : "s"}: ${summary}.`);
145
+ }
146
+ if (skipped.length > 0) {
147
+ const skipSummary = skipped
148
+ .slice(0, 4)
149
+ .map((s) => `${s.proposal?.type || "unknown"} (${s.reason || "no reason"})`)
150
+ .join("; ");
151
+ applyLines.push(`Skipped ${skipped.length}: ${skipSummary}${skipped.length > 4 ? "; …" : ""}.`);
152
+ }
153
+ if (applyLines.length === 0) {
154
+ applyLines.push("Apply completed with no changes.");
155
+ }
156
+ const applySystemMessage = {
157
+ role: "system",
158
+ content: applyLines.join(" "),
159
+ ts: appliedAt,
160
+ kind: "apply-receipt",
161
+ appliedCount: applied.length,
162
+ skippedCount: skipped.length,
163
+ };
164
+ const priorMessages = Array.isArray(existingRow.messages) ? existingRow.messages : [];
165
+ // Effectively unlimited cap — keeps the full multi-turn dashboard
166
+ // construction history. The user explicitly removed the 40-turn
167
+ // arbitrary limit so the helper can do complex multi-step real-data
168
+ // builds without losing context.
169
+ const nextMessages = [...priorMessages, applySystemMessage].slice(-400);
170
+
171
+ // Intent fallback — affectedField is in the PATCH-allowlist vocabulary
172
+ // (dashboards | widgetTypes | canvas | dataModel) and is NOT a member
173
+ // of the 7-intent vocabulary. Never assign it as a thread intent.
174
+ // Walk the proposal type back to its intent only when it's safe.
175
+ const TYPE_TO_INTENT_HINT = {
176
+ "dashboard.create": "build_dashboard",
177
+ "dashboard.update": "edit_view",
178
+ "widgetType.bind": "create_widget",
179
+ "canvas.widget.add": "create_widget",
180
+ "canvas.tab.create": "edit_view",
181
+ "dataModel.object.create": "create_object",
182
+ "dataModel.object.update": "edit_view",
183
+ "dataModel.row.add": "create_object",
184
+ "repair.binding": "repair",
185
+ "explain.object": "explain",
186
+ };
187
+ const proposalIntent = firstProposal?.type ? TYPE_TO_INTENT_HINT[firstProposal.type] : null;
188
+ const safeIntent = existingRow.intent || proposalIntent || "explain";
189
+ workingConfig = upsertHelperThreadRow(workingConfig, {
190
+ id: threadId,
191
+ title: seedTitle,
192
+ intent: safeIntent,
193
+ prompt: existingRow.prompt || "",
194
+ summary: existingRow.summary || "",
195
+ proposals: existingRow.proposals || body.proposals || [],
196
+ warnings: existingRow.warnings || [],
197
+ receipts: existingRow.receipts || null,
198
+ model: existingRow.model || "external-apply",
199
+ applied: (existingRow.applied || 0) + applied.length,
200
+ skipped: (existingRow.skipped || 0) + skipped.length,
201
+ // Persist the full proposal payload alongside the receipt so the
202
+ // sidecar can rehydrate ToolCallCard rows (icon + title + JSON
203
+ // metadata + Open link) on page refresh — without this the
204
+ // chevron-accordion would show nothing useful after a reload.
205
+ lastApplied: applied.map((a, idx) => ({
206
+ type: a.type,
207
+ affectedField: a.affectedField,
208
+ rationale: a.rationale,
209
+ confidence: a.confidence,
210
+ payload: body.proposals?.[idx]?.payload ?? null,
211
+ })),
212
+ lastSkipped: skipped.map((s) => ({
213
+ type: s.proposal?.type,
214
+ affectedField: s.proposal?.affectedField,
215
+ reason: s.reason,
216
+ payload: s.proposal?.payload ?? null,
217
+ })),
218
+ turnCount: (existingRow.turnCount || 0) + 1,
219
+ messages: nextMessages,
220
+ updatedAt: appliedAt,
221
+ });
222
+ } catch {
223
+ // Non-fatal — thread row update failures do not block the apply response.
224
+ }
225
+ }
226
+
227
+ // Build PATCH from affected fields. If the thread row was updated above,
228
+ // dataModel will already reflect it. Otherwise only mutating proposals
229
+ // contribute fields.
230
+ const threadTouched = threadId && mutatingApplied.every((r) => r.affectedField !== "dataModel");
231
+ if (mutatingApplied.length > 0 || threadTouched) {
232
+ const patchFields = new Set(mutatingApplied.map((r) => r.affectedField));
233
+ if (threadId) patchFields.add("dataModel");
234
+ const patch = {};
235
+ for (const field of patchFields) {
236
+ patch[field] = workingConfig[field];
237
+ }
238
+
239
+ try {
240
+ const next = await writeWorkspaceConfig(patch);
241
+ workingConfig = next;
242
+ } catch (err) {
243
+ if (err.code === "WORKSPACE_PERSISTENCE_READ_ONLY") {
244
+ return NextResponse.json(
245
+ {
246
+ ok: false,
247
+ error: "workspace config is read-only in this runtime",
248
+ reason: err.message,
249
+ guidance:
250
+ err.guidance ||
251
+ "Edit growthub.config.json locally, or set WORKSPACE_CONFIG_ALLOW_FS_WRITE=true on a writable runtime.",
252
+ },
253
+ { status: 409 }
254
+ );
255
+ }
256
+ if (err.code === "INVALID_WORKSPACE_CONFIG") {
257
+ return NextResponse.json({ ok: false, error: err.message, details: err.details }, { status: 400 });
258
+ }
259
+ return NextResponse.json(
260
+ { ok: false, error: err?.message || "failed to write workspace config" },
261
+ { status: 500 }
262
+ );
263
+ }
264
+ }
265
+
266
+ // Persist receipts to source-records for fine-tune loop seeding
267
+ const persistence = describePersistenceMode();
268
+ if (persistence.canSave && applied.length > 0) {
269
+ try {
270
+ const existing = await readWorkspaceSourceRecords(HELPER_APPLY_SOURCE_KEY);
271
+ const priorRecords = Array.isArray(existing?.records) ? existing.records : [];
272
+ const newRecords = applied.map((receipt) => ({
273
+ ...receipt,
274
+ sessionId: sessionId || null,
275
+ reviewedBy,
276
+ }));
277
+ await writeWorkspaceSourceRecords(
278
+ HELPER_APPLY_SOURCE_KEY,
279
+ [...priorRecords, ...newRecords].slice(-200),
280
+ { integrationId: HELPER_APPLY_SOURCE_KEY, fetchedAt: appliedAt }
281
+ );
282
+ } catch {
283
+ // Non-fatal
284
+ }
285
+ }
286
+
287
+ // If we updated a thread row, return the new conversation tail so the
288
+ // sidecar can render the apply-receipt system message in-place without
289
+ // a separate fetch.
290
+ let messagesAfterApply;
291
+ if (threadId) {
292
+ const ht = (workingConfig?.dataModel?.objects || []).find((o) => o?.id === "helper-threads");
293
+ const row = ht?.rows?.find((r) => r?.id === threadId);
294
+ if (row && Array.isArray(row.messages)) messagesAfterApply = row.messages;
295
+ }
296
+
297
+ return NextResponse.json({
298
+ ok: true,
299
+ threadId,
300
+ applied,
301
+ skipped,
302
+ workspaceConfig: workingConfig,
303
+ messages: messagesAfterApply,
304
+ });
305
+ }
306
+
307
+ export { POST };
@@ -0,0 +1,372 @@
1
+ /**
2
+ * POST /api/workspace/helper/query
3
+ *
4
+ * Workspace-native helper endpoint. Accepts a natural-language business brief
5
+ * and returns structured proposals valid against the workspace PATCH allowlist.
6
+ *
7
+ * This route is propose-only. No workspace config is mutated here. Accepted
8
+ * proposals are applied through POST /api/workspace/helper/apply, which
9
+ * validates each proposal against validateWorkspaceConfig before writing.
10
+ *
11
+ * Execution model:
12
+ * 1. Read and sanitize the live growthub.config.json (or accept a snapshot).
13
+ * 2. Build a workspace-grammar-injected system prompt via workspace-helper.js.
14
+ * 3. Dispatch through the local-intelligence sandbox adapter.
15
+ * 4. Parse the growthub-local-model-sandbox-v1 envelope.
16
+ * 5. Validate proposals, write a run record to source-records, return response.
17
+ *
18
+ * Request body (WorkspaceHelperQuery):
19
+ * {
20
+ * intent: "build_dashboard" | "create_widget" | "register_api" |
21
+ * "create_object" | "edit_view" | "repair" | "explain",
22
+ * workspaceSnapshot?: WorkspaceHelperSnapshot, // optional — server reads live config if omitted
23
+ * userPrompt: string,
24
+ * mode?: "propose",
25
+ * model?: string,
26
+ * adapterMode?: string,
27
+ * localEndpoint?: string,
28
+ * }
29
+ *
30
+ * Response (WorkspaceHelperResponse):
31
+ * {
32
+ * ok: boolean,
33
+ * summary: string,
34
+ * proposals: WorkspaceHelperProposal[],
35
+ * warnings: string[],
36
+ * receipts: WorkspaceHelperReceipt,
37
+ * error?: string,
38
+ * }
39
+ */
40
+
41
+ import { NextResponse } from "next/server";
42
+ import {
43
+ readWorkspaceConfig,
44
+ writeWorkspaceConfig,
45
+ readWorkspaceSourceRecords,
46
+ writeWorkspaceSourceRecords,
47
+ describePersistenceMode,
48
+ } from "@/lib/workspace-config";
49
+ import {
50
+ sanitizeWorkspaceSnapshot,
51
+ buildChatMessages,
52
+ inferIntentFromPrompt,
53
+ parseHelperEnvelope,
54
+ validateProposals,
55
+ } from "@/lib/workspace-helper";
56
+ import {
57
+ ensureSandboxAdaptersLoaded,
58
+ getSandboxAdapter,
59
+ } from "@/lib/adapters/sandboxes";
60
+ import {
61
+ upsertHelperThreadRow,
62
+ nextThreadId,
63
+ } from "@/lib/workspace-helper-apply";
64
+
65
+ const VALID_INTENTS = [
66
+ "build_dashboard",
67
+ "create_widget",
68
+ "register_api",
69
+ "create_object",
70
+ "edit_view",
71
+ "repair",
72
+ "explain",
73
+ ];
74
+
75
+ const HELPER_SOURCE_KEY_PREFIX = "helper";
76
+
77
+ function helperSourceId(intent, runId) {
78
+ return `${HELPER_SOURCE_KEY_PREFIX}:${intent}:${runId}`;
79
+ }
80
+
81
+ async function POST(request) {
82
+ let body;
83
+ try {
84
+ body = await request.json();
85
+ } catch {
86
+ return NextResponse.json({ ok: false, error: "invalid JSON body" }, { status: 400 });
87
+ }
88
+
89
+ const intent = typeof body?.intent === "string" ? body.intent.trim() : "";
90
+ if (!intent || !VALID_INTENTS.includes(intent)) {
91
+ return NextResponse.json({
92
+ ok: false,
93
+ error: `intent must be one of: ${VALID_INTENTS.join(", ")}`,
94
+ proposals: [],
95
+ warnings: [],
96
+ });
97
+ }
98
+
99
+ const userPrompt = typeof body?.userPrompt === "string" ? body.userPrompt.trim() : "";
100
+ if (!userPrompt) {
101
+ return NextResponse.json({ ok: false, error: "userPrompt is required", proposals: [], warnings: [] });
102
+ }
103
+
104
+ const modelOverride = typeof body?.model === "string" ? body.model.trim() : "";
105
+ const adapterModeOverride = typeof body?.adapterMode === "string" ? body.adapterMode.trim() : "";
106
+ const localEndpointOverride = typeof body?.localEndpoint === "string" ? body.localEndpoint.trim() : "";
107
+
108
+ // Always sanitize the snapshot — even when supplied by the client — so
109
+ // envRefs values, credentials, and row data can never travel into the
110
+ // inference prompt regardless of how a caller frames the request.
111
+ let snapshot;
112
+ let liveConfigForThread = null;
113
+ if (body?.workspaceSnapshot && typeof body.workspaceSnapshot === "object") {
114
+ snapshot = sanitizeWorkspaceSnapshot(body.workspaceSnapshot);
115
+ } else {
116
+ const liveConfig = await readWorkspaceConfig();
117
+ snapshot = sanitizeWorkspaceSnapshot(liveConfig);
118
+ liveConfigForThread = liveConfig;
119
+ }
120
+
121
+ // Thread continuity — pull prior messages from the governed helper-threads
122
+ // row when the caller supplied a threadId so the chat completion gets the
123
+ // full conversation context.
124
+ const incomingThreadId = typeof body?.threadId === "string" ? body.threadId.trim() : "";
125
+ let priorMessages = [];
126
+ let priorThreadIntent = null;
127
+ if (incomingThreadId) {
128
+ try {
129
+ if (!liveConfigForThread) liveConfigForThread = await readWorkspaceConfig();
130
+ const ht = (liveConfigForThread?.dataModel?.objects || []).find((o) => o?.id === "helper-threads");
131
+ const row = ht?.rows?.find((r) => r?.id === incomingThreadId);
132
+ if (row) {
133
+ priorMessages = Array.isArray(row.messages) ? row.messages : [];
134
+ priorThreadIntent = typeof row.intent === "string" ? row.intent : null;
135
+ }
136
+ } catch {
137
+ // Non-fatal — fall through with no prior context.
138
+ }
139
+ }
140
+
141
+ // L3 heuristic intent routing.
142
+ //
143
+ // If the caller is continuing a thread, the original intent is locked
144
+ // for the rest of the thread (the UI surfaces it as the active mode).
145
+ // For a brand-new thread we only override the caller's chosen intent
146
+ // when the prompt produces a strong, unambiguous signal that conflicts.
147
+ let resolvedIntent = priorThreadIntent || intent;
148
+ let intentInference = null;
149
+ if (!priorThreadIntent) {
150
+ intentInference = inferIntentFromPrompt(userPrompt, intent);
151
+ if (
152
+ intentInference.confidence >= 2 &&
153
+ intentInference.intent !== intent &&
154
+ // Only override the safest defaults — never blow away a deliberate pick.
155
+ (intent === "create_object" || intent === "explain")
156
+ ) {
157
+ resolvedIntent = intentInference.intent;
158
+ }
159
+ }
160
+
161
+ const chatMessages = buildChatMessages({
162
+ snapshot,
163
+ intent: resolvedIntent,
164
+ priorMessages,
165
+ newUserPrompt: userPrompt,
166
+ });
167
+ // Keep the userIntent fallback populated for adapter compatibility — the
168
+ // adapter prefers `messages` when present and only reads `userIntent`
169
+ // when `messages` is empty.
170
+ const userIntent = userPrompt;
171
+
172
+ await ensureSandboxAdaptersLoaded();
173
+ const adapter = getSandboxAdapter("local-intelligence");
174
+ if (!adapter) {
175
+ return NextResponse.json(
176
+ {
177
+ ok: false,
178
+ error: "local-intelligence adapter not registered. Ensure sandbox adapters are loaded.",
179
+ },
180
+ { status: 503 }
181
+ );
182
+ }
183
+
184
+ const runId = `helper_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
185
+ const ranAt = new Date().toISOString();
186
+
187
+ let adapterResult;
188
+ try {
189
+ adapterResult = await adapter.run({
190
+ runId,
191
+ name: `workspace-helper-${resolvedIntent}`,
192
+ runtime: "node",
193
+ agentHost: "",
194
+ command: userIntent,
195
+ timeoutMs: 90000,
196
+ networkAllow: true,
197
+ allowList: [],
198
+ env: {},
199
+ envRefSlugs: [],
200
+ envRefsMissing: [],
201
+ workdir: "/tmp",
202
+ ranAt,
203
+ intelligenceSandbox: {
204
+ // Structured multi-turn message array — the adapter passes this
205
+ // straight through to the chat completions endpoint so the model
206
+ // sees the full conversation history with stable system prefix
207
+ // (KV-cache friendly).
208
+ messages: chatMessages,
209
+ userIntent,
210
+ localModel: modelOverride || process.env.NATIVE_INTELLIGENCE_LOCAL_MODEL || process.env.OLLAMA_MODEL || "gemma3:4b",
211
+ localEndpoint: localEndpointOverride || "",
212
+ intelligenceAdapterMode: adapterModeOverride || "ollama",
213
+ },
214
+ });
215
+ } catch (err) {
216
+ return NextResponse.json(
217
+ {
218
+ ok: false,
219
+ error: err?.message || "adapter threw during helper query",
220
+ },
221
+ { status: 500 }
222
+ );
223
+ }
224
+
225
+ if (!adapterResult.ok) {
226
+ return NextResponse.json(
227
+ {
228
+ ok: false,
229
+ error: adapterResult.error || "local-intelligence adapter returned error",
230
+ receipts: {
231
+ model: "unknown",
232
+ adapterMode: adapterModeOverride || "ollama",
233
+ endpoint: "",
234
+ confidence: 0,
235
+ latencyMs: adapterResult.durationMs || 0,
236
+ ranAt,
237
+ runId,
238
+ },
239
+ },
240
+ { status: 502 }
241
+ );
242
+ }
243
+
244
+ const parsed = parseHelperEnvelope(adapterResult.stdout, intent);
245
+ const { valid: validProposals, errors: validationErrors } = validateProposals(parsed.proposals);
246
+
247
+ const warnings = [...parsed.warnings];
248
+ if (validationErrors.length > 0) {
249
+ warnings.push(...validationErrors);
250
+ }
251
+
252
+ const receipts = {
253
+ model: parsed.model,
254
+ adapterMode: parsed.adapterMode,
255
+ endpoint: parsed.endpoint,
256
+ confidence: parsed.confidence,
257
+ latencyMs: parsed.latencyMs,
258
+ ranAt,
259
+ runId,
260
+ };
261
+
262
+ // Thread row — every governed conversation turn lands here so the user
263
+ // can reopen it from /data-model → Helper Threads. Either continue an
264
+ // existing thread (when the caller supplies threadId) or start a new one.
265
+ const threadId = incomingThreadId || nextThreadId();
266
+ const turnCount = 1;
267
+
268
+ // Build the new messages tail — one user turn (raw prompt) and one
269
+ // assistant turn (the JSON envelope we just returned). This is the
270
+ // conversation history that gets replayed on the next turn.
271
+ const nowIso = new Date().toISOString();
272
+ const assistantEnvelope = JSON.stringify({
273
+ summary: parsed.summary,
274
+ proposals: validProposals,
275
+ warnings,
276
+ });
277
+ const newTurn = [
278
+ { role: "user", content: userPrompt, ts: nowIso },
279
+ { role: "assistant", content: assistantEnvelope, ts: nowIso, summary: parsed.summary, proposals: validProposals, warnings },
280
+ ];
281
+ const nextMessages = [...priorMessages, ...newTurn].slice(-40);
282
+
283
+ const response = {
284
+ ok: true,
285
+ threadId,
286
+ intent: resolvedIntent,
287
+ intentInference,
288
+ summary: parsed.summary,
289
+ proposals: validProposals,
290
+ warnings,
291
+ receipts,
292
+ messages: nextMessages,
293
+ };
294
+
295
+ const persistence = describePersistenceMode();
296
+ if (persistence.canSave) {
297
+ // Audit-trail (source-records) — preserved exactly as before for the
298
+ // distillation pipeline.
299
+ try {
300
+ const sourceId = helperSourceId(resolvedIntent, runId);
301
+ const existing = await readWorkspaceSourceRecords(sourceId);
302
+ const priorRecords = Array.isArray(existing?.records) ? existing.records : [];
303
+ const record = {
304
+ runId,
305
+ ranAt,
306
+ intent: resolvedIntent,
307
+ intentRequested: intent,
308
+ userPrompt,
309
+ summary: parsed.summary,
310
+ proposalCount: validProposals.length,
311
+ warningCount: warnings.length,
312
+ model: parsed.model,
313
+ adapterMode: parsed.adapterMode,
314
+ confidence: parsed.confidence,
315
+ latencyMs: parsed.latencyMs,
316
+ threadId,
317
+ turnIndex: priorMessages.filter((m) => m?.role === "user").length + 1,
318
+ };
319
+ await writeWorkspaceSourceRecords(sourceId, [...priorRecords, record].slice(-50), {
320
+ integrationId: sourceId,
321
+ fetchedAt: ranAt,
322
+ });
323
+ } catch {
324
+ // Non-fatal — source record write failure does not block the response
325
+ }
326
+
327
+ // Governed thread row — visible to the user as a Data Model row with a
328
+ // "Reopen" hyperlink that re-hydrates the sidecar. The row carries the
329
+ // full multi-turn message history; the assistant's latest turn is also
330
+ // surfaced as `summary` / `proposals` / `receipts` on the row for
331
+ // quick triage from the Data Model surface.
332
+ try {
333
+ const liveConfig = liveConfigForThread || (await readWorkspaceConfig());
334
+ const existingRows = (liveConfig?.dataModel?.objects || []).find((o) => o?.id === "helper-threads")?.rows || [];
335
+ const existingRow = existingRows.find((r) => r?.id === threadId) || {};
336
+ const merged = upsertHelperThreadRow(liveConfig, {
337
+ id: threadId,
338
+ title: existingRow.title || truncateRowTitle(userPrompt),
339
+ intent: resolvedIntent,
340
+ prompt: existingRow.prompt || userPrompt,
341
+ summary: parsed.summary,
342
+ proposals: validProposals,
343
+ warnings,
344
+ receipts,
345
+ model: parsed.model || "unknown",
346
+ applied: existingRow.applied || 0,
347
+ skipped: existingRow.skipped || 0,
348
+ turnCount: (existingRow.turnCount || 0) + turnCount,
349
+ messages: nextMessages,
350
+ updatedAt: ranAt,
351
+ });
352
+ await writeWorkspaceConfig({ dataModel: merged.dataModel });
353
+ } catch (err) {
354
+ // Non-fatal — the helper response is still returned even if the row
355
+ // write fails (e.g. read-only runtime). The audit trail above is
356
+ // unaffected.
357
+ if (err && err.code !== "WORKSPACE_PERSISTENCE_READ_ONLY") {
358
+ // swallow but do not propagate; users still receive the proposals
359
+ }
360
+ }
361
+ }
362
+
363
+ return NextResponse.json(response);
364
+ }
365
+
366
+ function truncateRowTitle(prompt, max = 72) {
367
+ const text = String(prompt || "").replace(/\s+/g, " ").trim();
368
+ if (text.length <= max) return text;
369
+ return `${text.slice(0, max - 1).trimEnd()}…`;
370
+ }
371
+
372
+ export { POST };