@growthub/cli 0.10.1 → 0.12.1
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/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/helper/apply/route.js +307 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/helper/query/route.js +372 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/helper/receipts/route.js +47 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/DataModelShell.jsx +402 -49
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/HelperSidecar.jsx +1371 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/globals.css +1348 -21
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/settings/integrations/page.jsx +7 -21
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/settings/ownership/ownership-panel.jsx +222 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/settings/ownership/page.jsx +19 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/settings/settings-shell.jsx +2 -1
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/workspace-builder.jsx +116 -24
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/workspace-rail.jsx +497 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/growthub.config.json +20 -4
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/adapters/sandboxes/default-local-intelligence.js +19 -4
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-data-model.js +15 -4
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-helper-apply.js +473 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-helper.js +583 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/package-lock.json +34 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/package.json +3 -1
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/kit.json +9 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/workers/custom-workspace-operator/CLAUDE.md +38 -0
- package/dist/index.js +2935 -2073
- 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 };
|