@venturewild/workspace 0.1.13 → 0.2.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/LICENSE +21 -21
- package/README.md +112 -112
- package/package.json +83 -76
- package/server/bin/wild-workspace.mjs +825 -763
- package/server/src/agent.mjs +453 -386
- package/server/src/bazaar/core.mjs +579 -0
- package/server/src/bazaar/index.mjs +75 -0
- package/server/src/bazaar/mcp-server.mjs +328 -0
- package/server/src/bazaar/mock-tickup.mjs +97 -0
- package/server/src/bazaar/preview-server.mjs +95 -0
- package/server/src/bazaar/seed-recipes/customer-feedback-form/know-how.md +23 -0
- package/server/src/bazaar/seed-recipes/customer-feedback-form/recipe.json +24 -0
- package/server/src/bazaar/seed-recipes/landing-page-launch/know-how.md +29 -0
- package/server/src/bazaar/seed-recipes/landing-page-launch/recipe.json +25 -0
- package/server/src/bazaar/seed-recipes/personal-portfolio/know-how.md +21 -0
- package/server/src/bazaar/seed-recipes/personal-portfolio/recipe.json +24 -0
- package/server/src/bazaar/seed-recipes/receipt-sorter/know-how.md +31 -0
- package/server/src/bazaar/seed-recipes/receipt-sorter/recipe.json +25 -0
- package/server/src/bazaar/seed-recipes/tickup-hr-matching/know-how.md +79 -0
- package/server/src/bazaar/seed-recipes/tickup-hr-matching/recipe.json +32 -0
- package/server/src/canvas/core.mjs +324 -0
- package/server/src/canvas/index.mjs +42 -0
- package/server/src/canvas/mcp-server.mjs +253 -0
- package/server/src/config.mjs +365 -365
- package/server/src/daemon-supervisor.mjs +216 -216
- package/server/src/inbox.mjs +86 -86
- package/server/src/index.mjs +1948 -1726
- package/server/src/logpaths.mjs +98 -98
- package/server/src/pairing.mjs +9 -18
- package/server/src/service.mjs +419 -419
- package/server/src/share.mjs +182 -148
- package/server/src/sync.mjs +248 -248
- package/server/src/turn-mcp.mjs +46 -0
- package/web/dist/assets/index-DVWgeTl_.js +91 -0
- package/web/dist/assets/index-Dl0VT5e6.css +1 -0
- package/web/dist/index.html +2 -2
- package/web/dist/assets/index-Bj-mdLGj.css +0 -1
- package/web/dist/assets/index-CAzFAt7W.js +0 -89
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
// Bazaar MCP server — the tools the agent uses to "reach onto our shelf" (§3.7).
|
|
2
|
+
//
|
|
3
|
+
// Hand-rolled stdio JSON-RPC 2.0 (newline-delimited), so it needs NO new
|
|
4
|
+
// dependency and stays in the project's wrap-don't-embed style (like the
|
|
5
|
+
// stream-json parser in agent.mjs). `claude` spawns this per turn via --mcp-config
|
|
6
|
+
// and exposes the tools as mcp__bazaar__<name>.
|
|
7
|
+
//
|
|
8
|
+
// It imports the SAME core.mjs the main server uses, so there is one source of
|
|
9
|
+
// truth (state under ~/.wild-workspace/bazaar/) and no HTTP/port handshake. Tool
|
|
10
|
+
// results are JSON envelopes: the AGENT reads them (incl. the absorbed know-how),
|
|
11
|
+
// and the UI parses the same JSON to render rich bazaar cards.
|
|
12
|
+
//
|
|
13
|
+
// stdout carries ONLY JSON-RPC. Any diagnostics go to stderr.
|
|
14
|
+
|
|
15
|
+
import readline from 'node:readline';
|
|
16
|
+
import { createBazaar } from './core.mjs';
|
|
17
|
+
|
|
18
|
+
const bazaar = createBazaar(); // baseDir from WILD_WORKSPACE_GLOBAL_DIR (set by parent)
|
|
19
|
+
const PROTOCOL_VERSION = '2025-06-18';
|
|
20
|
+
|
|
21
|
+
function send(msg) {
|
|
22
|
+
process.stdout.write(`${JSON.stringify(msg)}\n`);
|
|
23
|
+
}
|
|
24
|
+
function result(id, res) {
|
|
25
|
+
send({ jsonrpc: '2.0', id, result: res });
|
|
26
|
+
}
|
|
27
|
+
function errorReply(id, code, message) {
|
|
28
|
+
send({ jsonrpc: '2.0', id, error: { code, message } });
|
|
29
|
+
}
|
|
30
|
+
function textContent(obj) {
|
|
31
|
+
return { content: [{ type: 'text', text: JSON.stringify(obj) }] };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// --- tool definitions -----------------------------------------------------
|
|
35
|
+
|
|
36
|
+
const TOOLS = [
|
|
37
|
+
{
|
|
38
|
+
name: 'search_shelf',
|
|
39
|
+
description:
|
|
40
|
+
"Search the bazaar shelf for a proven recipe that matches what the user wants to build. " +
|
|
41
|
+
"Call this FIRST when the user describes an outcome that plausibly matches a known build. " +
|
|
42
|
+
"Returns ranked matches with an outcome score (how often the recipe actually gets people a " +
|
|
43
|
+
"working result). Only surface a recipe to the user if there is a STRONG, clearly relevant match.",
|
|
44
|
+
inputSchema: {
|
|
45
|
+
type: 'object',
|
|
46
|
+
properties: {
|
|
47
|
+
need: { type: 'string', description: "Plain-language description of what the user wants." },
|
|
48
|
+
},
|
|
49
|
+
required: ['need'],
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
name: 'open_recipe',
|
|
54
|
+
description:
|
|
55
|
+
"Open a recipe to absorb the producer's know-how (the step-by-step way to build it their way). " +
|
|
56
|
+
"Call this after the user accepts a recipe, then build it one-shot following the know-how.",
|
|
57
|
+
inputSchema: {
|
|
58
|
+
type: 'object',
|
|
59
|
+
properties: { id: { type: 'string', description: 'The recipe id from search_shelf.' } },
|
|
60
|
+
required: ['id'],
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
name: 'launch_preview',
|
|
65
|
+
description:
|
|
66
|
+
"Point the user's live preview at the folder you built, so it opens on their screen. " +
|
|
67
|
+
"Call this right after writing the build files.",
|
|
68
|
+
inputSchema: {
|
|
69
|
+
type: 'object',
|
|
70
|
+
properties: {
|
|
71
|
+
dir: { type: 'string', description: 'The build folder (relative to the workspace), e.g. "candidate-matcher".' },
|
|
72
|
+
recipeId: { type: 'string', description: 'The recipe id this build came from (optional).' },
|
|
73
|
+
},
|
|
74
|
+
required: ['dir'],
|
|
75
|
+
},
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
name: 'record_use',
|
|
79
|
+
description:
|
|
80
|
+
"Record that the user built on a producer's recipe — this credits the producer and records the " +
|
|
81
|
+
"transaction (the three-way win). Call this once the build is done.",
|
|
82
|
+
inputSchema: {
|
|
83
|
+
type: 'object',
|
|
84
|
+
properties: {
|
|
85
|
+
recipeId: { type: 'string', description: 'The recipe id that was used.' },
|
|
86
|
+
summary: { type: 'string', description: 'One short line describing what you built for the user.' },
|
|
87
|
+
},
|
|
88
|
+
required: ['recipeId'],
|
|
89
|
+
},
|
|
90
|
+
},
|
|
91
|
+
{
|
|
92
|
+
name: 'publish_listing',
|
|
93
|
+
description:
|
|
94
|
+
"Package something the user built into a listing on the shelf, so other people's agents can " +
|
|
95
|
+
"build on it and the user earns when they do. Use when the user accepts your offer to package " +
|
|
96
|
+
"it, OR when the user explicitly asks to list/sell what they made.",
|
|
97
|
+
inputSchema: {
|
|
98
|
+
type: 'object',
|
|
99
|
+
properties: {
|
|
100
|
+
title: { type: 'string', description: 'A short title for the listing.' },
|
|
101
|
+
pitch: { type: 'string', description: 'One line that sells what it does.' },
|
|
102
|
+
summary: { type: 'string', description: 'A plain summary of what someone gets.' },
|
|
103
|
+
tags: { type: 'array', items: { type: 'string' }, description: 'Keywords others might search for.' },
|
|
104
|
+
knowHow: { type: 'string', description: "The how-to another agent would absorb to rebuild it." },
|
|
105
|
+
buildDir: { type: 'string', description: 'The folder the build lives in (optional).' },
|
|
106
|
+
},
|
|
107
|
+
required: ['title'],
|
|
108
|
+
},
|
|
109
|
+
},
|
|
110
|
+
{
|
|
111
|
+
name: 'draft_recipe',
|
|
112
|
+
description:
|
|
113
|
+
"Stage a recipe you EXTRACTED from the user's existing/past work for their REVIEW (does NOT " +
|
|
114
|
+
"publish). Use when self-seeding the shelf from past projects. The know-how MUST be " +
|
|
115
|
+
"GENERALIZED — the reusable method/architecture only, with NO client names, data, domains, " +
|
|
116
|
+
"keys, or secrets. Stage one draft per reusable pattern; the user reviews each and tells you " +
|
|
117
|
+
"which to publish.",
|
|
118
|
+
inputSchema: {
|
|
119
|
+
type: 'object',
|
|
120
|
+
properties: {
|
|
121
|
+
title: { type: 'string', description: 'A short title for the recipe.' },
|
|
122
|
+
pitch: { type: 'string', description: 'One line: what it lets someone build.' },
|
|
123
|
+
summary: { type: 'string', description: 'A plain summary of what someone gets.' },
|
|
124
|
+
tags: { type: 'array', items: { type: 'string' }, description: 'Keywords others might search for.' },
|
|
125
|
+
knowHow: { type: 'string', description: "The GENERALIZED how-to another agent would follow — no client specifics." },
|
|
126
|
+
sourceNote: { type: 'string', description: 'For the user to verify: what this was generalized from (e.g. "from 3 client landing pages") — no client names.' },
|
|
127
|
+
},
|
|
128
|
+
required: ['title', 'knowHow'],
|
|
129
|
+
},
|
|
130
|
+
},
|
|
131
|
+
{
|
|
132
|
+
name: 'publish_draft',
|
|
133
|
+
description:
|
|
134
|
+
"Publish a reviewed draft onto the shelf. Call ONLY after the user has explicitly approved that " +
|
|
135
|
+
"specific draft (by its id).",
|
|
136
|
+
inputSchema: {
|
|
137
|
+
type: 'object',
|
|
138
|
+
properties: { id: { type: 'string', description: 'The draft id from draft_recipe.' } },
|
|
139
|
+
required: ['id'],
|
|
140
|
+
},
|
|
141
|
+
},
|
|
142
|
+
{
|
|
143
|
+
name: 'publish_theme',
|
|
144
|
+
description:
|
|
145
|
+
"Publish a workspace THEME to the bazaar so others can discover and apply it (the user earns each " +
|
|
146
|
+
"time someone does). Use this when the user asks to share/list/sell a look they like, or after you " +
|
|
147
|
+
"made one they love. A theme is just colours (hex values) — pick a mode (light|dark), an accent, " +
|
|
148
|
+
"and the token colours that define the look. Give it a short title and a one-line pitch.",
|
|
149
|
+
inputSchema: {
|
|
150
|
+
type: 'object',
|
|
151
|
+
properties: {
|
|
152
|
+
title: { type: 'string', description: 'Short theme name, e.g. "Warm Sunset".' },
|
|
153
|
+
pitch: { type: 'string', description: 'One-line description of the vibe.' },
|
|
154
|
+
summary: { type: 'string', description: 'A slightly longer description (optional).' },
|
|
155
|
+
tags: { type: 'array', items: { type: 'string' }, description: 'e.g. ["dark","warm"].' },
|
|
156
|
+
mode: { type: 'string', enum: ['light', 'dark'], description: 'Base palette.' },
|
|
157
|
+
accent: { type: 'string', description: 'Primary accent, hex e.g. "#22d3ee".' },
|
|
158
|
+
bg: { type: 'string', description: 'page background, hex.' },
|
|
159
|
+
surface: { type: 'string', description: 'card face, hex.' },
|
|
160
|
+
text: { type: 'string', description: 'primary text, hex.' },
|
|
161
|
+
textMuted: { type: 'string', description: 'muted text, hex.' },
|
|
162
|
+
border: { type: 'string', description: 'borders, hex.' },
|
|
163
|
+
canvas1: { type: 'string', description: 'wallpaper stop 1, hex.' },
|
|
164
|
+
canvas2: { type: 'string', description: 'wallpaper stop 2, hex.' },
|
|
165
|
+
canvas3: { type: 'string', description: 'wallpaper stop 3, hex.' },
|
|
166
|
+
},
|
|
167
|
+
required: ['title'],
|
|
168
|
+
},
|
|
169
|
+
},
|
|
170
|
+
];
|
|
171
|
+
|
|
172
|
+
// --- tool dispatch --------------------------------------------------------
|
|
173
|
+
|
|
174
|
+
function callTool(name, args = {}) {
|
|
175
|
+
switch (name) {
|
|
176
|
+
case 'search_shelf': {
|
|
177
|
+
const hits = bazaar.search(args.need || '');
|
|
178
|
+
return textContent({
|
|
179
|
+
kind: 'search',
|
|
180
|
+
need: args.need || '',
|
|
181
|
+
count: hits.length,
|
|
182
|
+
hits,
|
|
183
|
+
top: hits[0] || null,
|
|
184
|
+
guidance:
|
|
185
|
+
hits.length === 0
|
|
186
|
+
? 'Nothing on the shelf is a strong match — build from scratch and do not mention the bazaar.'
|
|
187
|
+
: 'Surface the top match to the user only if it is clearly relevant (a strong, high-outcome match).',
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
case 'open_recipe': {
|
|
191
|
+
const r = bazaar.getRecipe(args.id);
|
|
192
|
+
if (!r) return { ...textContent({ kind: 'error', error: `no recipe "${args.id}"` }), isError: true };
|
|
193
|
+
return textContent({
|
|
194
|
+
kind: 'recipe',
|
|
195
|
+
card: bazaar.card(r),
|
|
196
|
+
knowHow: r.knowHow || '',
|
|
197
|
+
service: r.service || null,
|
|
198
|
+
note: 'Absorb this know-how and build it one-shot for the user.',
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
case 'launch_preview': {
|
|
202
|
+
const preview = bazaar.setPreview({ dir: args.dir, recipeId: args.recipeId || null });
|
|
203
|
+
return textContent({ kind: 'preview', url: '/preview/', dir: preview.dir, recipeId: preview.recipeId });
|
|
204
|
+
}
|
|
205
|
+
case 'record_use': {
|
|
206
|
+
const res = bazaar.recordUse({ recipeId: args.recipeId, summary: args.summary });
|
|
207
|
+
if (!res.ok) return { ...textContent({ kind: 'error', error: res.error }), isError: true };
|
|
208
|
+
// Backstop the preview: even if the agent forgot launch_preview, target the
|
|
209
|
+
// recipe's build dir so the preview still opens (review must-fix #3).
|
|
210
|
+
let preview = bazaar.getPreview();
|
|
211
|
+
if ((!preview || !preview.dir) && res.recipe?.buildDir) {
|
|
212
|
+
preview = bazaar.setPreview({ dir: res.recipe.buildDir, recipeId: res.recipe.id });
|
|
213
|
+
}
|
|
214
|
+
return textContent({
|
|
215
|
+
kind: 'three-way',
|
|
216
|
+
recipe: res.recipe,
|
|
217
|
+
threeWay: res.threeWay,
|
|
218
|
+
credit: res.credit,
|
|
219
|
+
service: res.service,
|
|
220
|
+
preview: preview?.dir ? { url: '/preview/', dir: preview.dir } : null,
|
|
221
|
+
simulated: true,
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
case 'publish_listing': {
|
|
225
|
+
const res = bazaar.publishListing({
|
|
226
|
+
title: args.title,
|
|
227
|
+
pitch: args.pitch,
|
|
228
|
+
summary: args.summary,
|
|
229
|
+
tags: args.tags || [],
|
|
230
|
+
knowHow: args.knowHow || '',
|
|
231
|
+
buildDir: args.buildDir || null,
|
|
232
|
+
});
|
|
233
|
+
return textContent({ kind: 'publish', listing: res.listing, earning: res.earning, simulated: true });
|
|
234
|
+
}
|
|
235
|
+
case 'draft_recipe': {
|
|
236
|
+
const draft = bazaar.stageDraft({
|
|
237
|
+
title: args.title,
|
|
238
|
+
pitch: args.pitch,
|
|
239
|
+
summary: args.summary,
|
|
240
|
+
tags: args.tags || [],
|
|
241
|
+
knowHow: args.knowHow || '',
|
|
242
|
+
sourceNote: args.sourceNote || '',
|
|
243
|
+
});
|
|
244
|
+
return textContent({ kind: 'draft', draft });
|
|
245
|
+
}
|
|
246
|
+
case 'publish_draft': {
|
|
247
|
+
const res = bazaar.publishDraft(args.id);
|
|
248
|
+
if (!res.ok) return { ...textContent({ kind: 'error', error: res.error }), isError: true };
|
|
249
|
+
return textContent({ kind: 'publish', listing: res.listing, earning: res.earning, simulated: true });
|
|
250
|
+
}
|
|
251
|
+
case 'publish_theme': {
|
|
252
|
+
const res = bazaar.publishTheme({
|
|
253
|
+
title: args.title,
|
|
254
|
+
pitch: args.pitch,
|
|
255
|
+
summary: args.summary,
|
|
256
|
+
tags: args.tags || [],
|
|
257
|
+
theme: {
|
|
258
|
+
mode: args.mode,
|
|
259
|
+
accent: args.accent,
|
|
260
|
+
bg: args.bg, surface: args.surface, text: args.text, textMuted: args.textMuted,
|
|
261
|
+
border: args.border, canvas1: args.canvas1, canvas2: args.canvas2, canvas3: args.canvas3,
|
|
262
|
+
},
|
|
263
|
+
});
|
|
264
|
+
return textContent({ kind: 'publish', listing: res.listing, earning: res.earning, simulated: true });
|
|
265
|
+
}
|
|
266
|
+
default:
|
|
267
|
+
return { ...textContent({ kind: 'error', error: `unknown tool ${name}` }), isError: true };
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// --- JSON-RPC loop --------------------------------------------------------
|
|
272
|
+
|
|
273
|
+
export function handleMessage(msg) {
|
|
274
|
+
if (!msg || msg.jsonrpc !== '2.0') return;
|
|
275
|
+
const { id, method, params } = msg;
|
|
276
|
+
const isNotification = id === undefined || id === null;
|
|
277
|
+
|
|
278
|
+
switch (method) {
|
|
279
|
+
case 'initialize':
|
|
280
|
+
return result(id, {
|
|
281
|
+
protocolVersion: params?.protocolVersion || PROTOCOL_VERSION,
|
|
282
|
+
capabilities: { tools: {} },
|
|
283
|
+
serverInfo: { name: 'bazaar', version: '1.0.0' },
|
|
284
|
+
});
|
|
285
|
+
case 'notifications/initialized':
|
|
286
|
+
case 'initialized':
|
|
287
|
+
return; // notification, no response
|
|
288
|
+
case 'ping':
|
|
289
|
+
return result(id, {});
|
|
290
|
+
case 'tools/list':
|
|
291
|
+
return result(id, { tools: TOOLS });
|
|
292
|
+
case 'tools/call': {
|
|
293
|
+
try {
|
|
294
|
+
const out = callTool(params?.name, params?.arguments || {});
|
|
295
|
+
return result(id, out);
|
|
296
|
+
} catch (e) {
|
|
297
|
+
return errorReply(id, -32603, `tool error: ${e?.message || e}`);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
default:
|
|
301
|
+
if (!isNotification) errorReply(id, -32601, `method not found: ${method}`);
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// Only run the stdio loop when executed as the MCP process (not when imported by a test).
|
|
307
|
+
const isDirectRun = process.argv[1] && process.argv[1].endsWith('mcp-server.mjs');
|
|
308
|
+
if (isDirectRun) {
|
|
309
|
+
const rl = readline.createInterface({ input: process.stdin });
|
|
310
|
+
rl.on('line', (line) => {
|
|
311
|
+
const trimmed = line.trim();
|
|
312
|
+
if (!trimmed) return;
|
|
313
|
+
let msg;
|
|
314
|
+
try {
|
|
315
|
+
msg = JSON.parse(trimmed);
|
|
316
|
+
} catch {
|
|
317
|
+
return; // ignore non-JSON noise
|
|
318
|
+
}
|
|
319
|
+
try {
|
|
320
|
+
handleMessage(msg);
|
|
321
|
+
} catch (e) {
|
|
322
|
+
process.stderr.write(`bazaar mcp error: ${e?.message || e}\n`);
|
|
323
|
+
}
|
|
324
|
+
});
|
|
325
|
+
process.stderr.write('bazaar mcp server ready\n');
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
export { TOOLS, callTool };
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// Mock "TickUp" candidate-matching service.
|
|
2
|
+
//
|
|
3
|
+
// v1 stand-in for the producer's real tool/API (there is no real TickUp). The
|
|
4
|
+
// point is the FLOW, not real ML: a deterministic, offline ranker so the wiring
|
|
5
|
+
// is demonstrable and the demo looks smart. The built site POSTs { role,
|
|
6
|
+
// candidates } here and renders the ranked result.
|
|
7
|
+
|
|
8
|
+
const STOPWORDS = new Set([
|
|
9
|
+
'a', 'an', 'the', 'and', 'or', 'of', 'to', 'in', 'on', 'for', 'with', 'at',
|
|
10
|
+
'by', 'plus', 'pace', 'senior', 'junior', 'mid', 'lead', 'staff', 'engineer',
|
|
11
|
+
'developer', 'years', 'year', 'yr', 'yrs', 'experience', 'exp', 'who', 'can',
|
|
12
|
+
'is', 'are', 'be', 'we', 'you', 'your', 'our', 'team', 'role', 'work',
|
|
13
|
+
]);
|
|
14
|
+
|
|
15
|
+
function tokenize(text) {
|
|
16
|
+
return new Set(
|
|
17
|
+
String(text || '')
|
|
18
|
+
.toLowerCase()
|
|
19
|
+
.replace(/[^a-z0-9+#.]+/g, ' ')
|
|
20
|
+
.split(' ')
|
|
21
|
+
.map((w) => w.trim())
|
|
22
|
+
.filter((w) => w.length >= 2 && !STOPWORDS.has(w)),
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function parseYears(text) {
|
|
27
|
+
const m = String(text || '').match(/(\d+)\s*\+?\s*(?:y|yr|yrs|year|years)\b/i);
|
|
28
|
+
return m ? Math.min(parseInt(m[1], 10), 25) : 0;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function parseCandidate(line) {
|
|
32
|
+
const raw = String(line || '').trim();
|
|
33
|
+
// split "Name — skills" on em/en dash, hyphen, or colon
|
|
34
|
+
const m = raw.split(/\s+[—–:-]\s+/);
|
|
35
|
+
let name = raw;
|
|
36
|
+
let skills = '';
|
|
37
|
+
if (m.length >= 2) {
|
|
38
|
+
name = m[0].trim();
|
|
39
|
+
skills = m.slice(1).join(' ').trim();
|
|
40
|
+
}
|
|
41
|
+
return { name: name || raw, skills, raw };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const clamp = (n, lo, hi) => Math.max(lo, Math.min(hi, n));
|
|
45
|
+
|
|
46
|
+
export function matchCandidates({ role, candidates } = {}) {
|
|
47
|
+
const roleTokens = tokenize(role);
|
|
48
|
+
const list = (Array.isArray(candidates) ? candidates : [])
|
|
49
|
+
// Defensive: a candidate may be a string, an object, or junk (null/number).
|
|
50
|
+
// `typeof null === 'object'` would crash `null.name`, so coerce carefully.
|
|
51
|
+
.map((c) => {
|
|
52
|
+
if (typeof c === 'string') return c;
|
|
53
|
+
if (c && typeof c === 'object') return `${c.name || ''} — ${c.skills || ''}`;
|
|
54
|
+
return '';
|
|
55
|
+
})
|
|
56
|
+
.map((l) => String(l).trim())
|
|
57
|
+
.filter(Boolean)
|
|
58
|
+
.filter((l) => /[a-z0-9]/i.test(l)) // drop punctuation-only lines (e.g. "{}" -> " — ")
|
|
59
|
+
.slice(0, 500); // bound CPU on a publicly-reachable endpoint
|
|
60
|
+
|
|
61
|
+
const ranked = list.map((line) => {
|
|
62
|
+
const { name, skills, raw } = parseCandidate(line);
|
|
63
|
+
// Skills as a structured list (chips) — the contract returns string[], so a
|
|
64
|
+
// consumer's site can render them directly without re-parsing.
|
|
65
|
+
const skillList = skills
|
|
66
|
+
.split(/[,;/]/)
|
|
67
|
+
.map((s) => s.trim())
|
|
68
|
+
.filter(Boolean)
|
|
69
|
+
.slice(0, 8);
|
|
70
|
+
const candTokens = tokenize(`${skills} ${name}`);
|
|
71
|
+
const matched = [...roleTokens].filter((t) => candTokens.has(t));
|
|
72
|
+
const years = parseYears(raw);
|
|
73
|
+
const yearsBonus = Math.min(years, 10) * 2;
|
|
74
|
+
let score;
|
|
75
|
+
if (matched.length > 0) {
|
|
76
|
+
score = clamp(40 + matched.length * 14 + yearsBonus, 45, 98);
|
|
77
|
+
} else {
|
|
78
|
+
score = clamp(8 + yearsBonus, 5, 35);
|
|
79
|
+
}
|
|
80
|
+
const top = matched.slice(0, 3);
|
|
81
|
+
let why;
|
|
82
|
+
if (top.length > 0) {
|
|
83
|
+
why =
|
|
84
|
+
`Strong on ${top.join(', ')}` +
|
|
85
|
+
(years ? ` · ${years}y experience` : '') +
|
|
86
|
+
(matched.length > 3 ? ` · +${matched.length - 3} more matches` : '');
|
|
87
|
+
} else {
|
|
88
|
+
why = years
|
|
89
|
+
? `Limited overlap with the role, but ${years}y of experience`
|
|
90
|
+
: 'Limited overlap with the role';
|
|
91
|
+
}
|
|
92
|
+
return { name, skills: skillList, score: Math.round(score), why, matched };
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
ranked.sort((a, b) => b.score - a.score);
|
|
96
|
+
return { role: String(role || ''), count: ranked.length, ranked };
|
|
97
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// Bazaar preview host — static-file helper for serving the agent's build through
|
|
2
|
+
// the MAIN wild-workspace server at /preview/* (same-origin, behind the existing
|
|
3
|
+
// auth/role middleware). NOT a separate listener: serving same-origin means no
|
|
4
|
+
// port to squat (no collision with the user's real dev servers), no mixed-content
|
|
5
|
+
// under the public https proxy, and the built page's fetch('./match') is genuinely
|
|
6
|
+
// same-origin. The matching service (POST /preview/match) and the live earnings
|
|
7
|
+
// broadcast live in index.mjs, which has the activity bus + chat clients.
|
|
8
|
+
//
|
|
9
|
+
// The build itself is the USER'S PRODUCT and lives in their workspace; this module
|
|
10
|
+
// only READS from a build dir. No bazaar bookkeeping is ever written into the
|
|
11
|
+
// synced workspace (CLAUDE.md rule #1) — that all lives in ~/.wild-workspace/bazaar.
|
|
12
|
+
|
|
13
|
+
import fs from 'node:fs';
|
|
14
|
+
import path from 'node:path';
|
|
15
|
+
import mime from 'mime-types';
|
|
16
|
+
|
|
17
|
+
// Confine the preview build dir to the user's workspace. `dir` comes from the
|
|
18
|
+
// agent (preview.json) and is NOT trusted to stay inside the workspace — an
|
|
19
|
+
// absolute path or `..` segments would otherwise let /preview/* read anywhere on
|
|
20
|
+
// disk (host secrets). Returns the absolute build dir, or null if it escapes.
|
|
21
|
+
export function confineBuildDir(workspaceDir, dir) {
|
|
22
|
+
if (!dir) return null;
|
|
23
|
+
const root = path.resolve(workspaceDir);
|
|
24
|
+
const resolved = path.resolve(root, dir);
|
|
25
|
+
if (resolved !== root && !resolved.startsWith(root + path.sep)) return null;
|
|
26
|
+
return resolved;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// True if `file` (after symlink resolution) stays inside `root` (after symlink
|
|
30
|
+
// resolution). Defeats a symlink/junction planted in the build dir that points
|
|
31
|
+
// outside it — the lexical guard in resolvePreviewFile follows no symlinks, but
|
|
32
|
+
// fs.readFile does.
|
|
33
|
+
function realpathInside(file, root) {
|
|
34
|
+
try {
|
|
35
|
+
const realRoot = fs.realpathSync(root);
|
|
36
|
+
const realFile = fs.realpathSync(file);
|
|
37
|
+
return realFile === realRoot || realFile.startsWith(realRoot + path.sep);
|
|
38
|
+
} catch {
|
|
39
|
+
return false; // missing/broken link — refuse
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Resolve a request path (everything after /preview/) to an absolute file inside
|
|
44
|
+
// buildDir, with a path-traversal guard. Returns null if it escapes buildDir.
|
|
45
|
+
export function resolvePreviewFile(buildDir, reqPath) {
|
|
46
|
+
const root = path.resolve(buildDir);
|
|
47
|
+
const clean = decodeURIComponent(String(reqPath || '').split('?')[0]).replace(/\\/g, '/');
|
|
48
|
+
const rel = clean.replace(/^\/+/, '');
|
|
49
|
+
const resolved = path.resolve(root, rel || 'index.html');
|
|
50
|
+
if (resolved !== root && !resolved.startsWith(root + path.sep)) return null;
|
|
51
|
+
return resolved;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Serve one file from the build dir. Returns { status, contentType, body, headers }.
|
|
55
|
+
// body is a Buffer (file bytes) or a string (the waiting/404 page).
|
|
56
|
+
export function servePreviewFile(buildDir, reqPath) {
|
|
57
|
+
if (!buildDir) {
|
|
58
|
+
return { status: 200, contentType: 'text/html', body: WAITING_PAGE };
|
|
59
|
+
}
|
|
60
|
+
const file = resolvePreviewFile(buildDir, reqPath);
|
|
61
|
+
if (!file) return { status: 403, contentType: 'text/plain', body: 'forbidden' };
|
|
62
|
+
|
|
63
|
+
let target = file;
|
|
64
|
+
try {
|
|
65
|
+
if (fs.statSync(target).isDirectory()) target = path.join(target, 'index.html');
|
|
66
|
+
} catch {
|
|
67
|
+
/* not a dir / doesn't exist — fall through to read */
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
try {
|
|
71
|
+
// Symlink guard: the lexical check above follows no symlinks, but readFileSync
|
|
72
|
+
// does — so re-check the real path stays inside the build dir before reading.
|
|
73
|
+
if (!realpathInside(target, buildDir)) {
|
|
74
|
+
return { status: 403, contentType: 'text/plain', body: 'forbidden' };
|
|
75
|
+
}
|
|
76
|
+
const body = fs.readFileSync(target);
|
|
77
|
+
const contentType = mime.lookup(target) || 'application/octet-stream';
|
|
78
|
+
return { status: 200, contentType, body, headers: { 'Cache-Control': 'no-store' } };
|
|
79
|
+
} catch {
|
|
80
|
+
// SPA-ish fallback: serve index.html for unknown paths if it exists.
|
|
81
|
+
try {
|
|
82
|
+
const body = fs.readFileSync(path.join(path.resolve(buildDir), 'index.html'));
|
|
83
|
+
return { status: 200, contentType: 'text/html', body, headers: { 'Cache-Control': 'no-store' } };
|
|
84
|
+
} catch {
|
|
85
|
+
return { status: 404, contentType: 'text/html', body: NOT_FOUND_PAGE };
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export const WAITING_PAGE = `<!doctype html><html><head><meta charset="utf-8"><title>Preview</title>
|
|
91
|
+
<style>body{font-family:system-ui,-apple-system,sans-serif;display:flex;align-items:center;justify-content:center;height:100vh;margin:0;background:#0f1117;color:#9aa4b2}div{text-align:center;max-width:340px;line-height:1.6}</style>
|
|
92
|
+
</head><body><div><p>🛠️ Your build will appear here the moment your agent finishes it.</p></div></body></html>`;
|
|
93
|
+
|
|
94
|
+
export const NOT_FOUND_PAGE = `<!doctype html><html><head><meta charset="utf-8"><title>Not found</title></head>
|
|
95
|
+
<body style="font-family:system-ui;padding:40px;color:#555">Nothing here yet.</body></html>`;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# Marco's feedback form — how to build it
|
|
2
|
+
|
|
3
|
+
> Absorb and build in one shot. Single self-contained `feedback-form/index.html`
|
|
4
|
+
> (inline CSS + JS), no build step.
|
|
5
|
+
|
|
6
|
+
## Build steps
|
|
7
|
+
|
|
8
|
+
1. Create `feedback-form/index.html`.
|
|
9
|
+
2. The page:
|
|
10
|
+
- A **star rating** (1–5, clickable).
|
|
11
|
+
- A **comment** textarea.
|
|
12
|
+
- A **Submit** button. On submit, store `{rating, comment, ts}` in
|
|
13
|
+
`localStorage` and clear the form.
|
|
14
|
+
- A **live summary**: average rating across stored responses + the 3 most
|
|
15
|
+
recent comments. Updates immediately on submit.
|
|
16
|
+
3. Clean, friendly styling, one accent color. Prefill nothing; show a small empty
|
|
17
|
+
state ("Be the first to leave feedback").
|
|
18
|
+
|
|
19
|
+
## Finish
|
|
20
|
+
|
|
21
|
+
After writing the files: call **`launch_preview`** `{ "dir": "feedback-form" }`,
|
|
22
|
+
then **`record_use`** with recipe id `customer-feedback-form` and a short summary,
|
|
23
|
+
then tell the user it's live in the preview (briefly).
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "customer-feedback-form",
|
|
3
|
+
"title": "Customer feedback form with instant summary",
|
|
4
|
+
"producer": { "name": "Marco", "handle": "marco", "kind": "maker" },
|
|
5
|
+
"pitch": "Collect feedback and see the mood at a glance — built in one shot.",
|
|
6
|
+
"vendorDescription": "Marco's feedback widget. A clean form (rating + comment) plus a live tally so you see how people feel without opening a spreadsheet.",
|
|
7
|
+
"summary": "A single page: a star rating, a comment box, and a running summary (average score + recent comments). Stores responses locally for the demo.",
|
|
8
|
+
"outcomeScore": 0.86,
|
|
9
|
+
"outcomeStats": { "builds": 29, "working": 25 },
|
|
10
|
+
"safetyBadge": "verified",
|
|
11
|
+
"rating": { "stars": 4.5, "count": 19 },
|
|
12
|
+
"tags": [
|
|
13
|
+
"feedback", "customer feedback", "survey", "form", "nps", "rating",
|
|
14
|
+
"review", "reviews", "questionnaire", "poll", "satisfaction", "comments"
|
|
15
|
+
],
|
|
16
|
+
"reward": {
|
|
17
|
+
"model": "one-time",
|
|
18
|
+
"unit": "per build",
|
|
19
|
+
"perUseValue": 0,
|
|
20
|
+
"oneTimeValue": 5.0,
|
|
21
|
+
"note": "Marco earns a one-time share each time his feedback-form know-how is used."
|
|
22
|
+
},
|
|
23
|
+
"buildDir": "feedback-form"
|
|
24
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# Lina's launch page — how to build it
|
|
2
|
+
|
|
3
|
+
> Absorb this and build the user a one-page launch site in one shot. Single
|
|
4
|
+
> self-contained `index.html` (inline CSS + JS), no build step.
|
|
5
|
+
|
|
6
|
+
## Build steps
|
|
7
|
+
|
|
8
|
+
1. Create `landing-page/index.html`.
|
|
9
|
+
2. Sections, top to bottom:
|
|
10
|
+
- **Hero**: a big headline, a one-line subhead, and a primary call-to-action
|
|
11
|
+
button. Use a soft gradient background. Prefill with the user's product if
|
|
12
|
+
you know it; otherwise a believable placeholder they can edit.
|
|
13
|
+
- **Three feature blocks**: each an icon (an emoji is fine), a short title, and
|
|
14
|
+
one sentence.
|
|
15
|
+
- **Social proof**: a single line of fake-but-tasteful stats (e.g. "Loved by
|
|
16
|
+
800+ early users").
|
|
17
|
+
- **Email waitlist**: an email input + "Join the waitlist" button. On submit,
|
|
18
|
+
store the address in `localStorage` and show a "You're on the list 🎉"
|
|
19
|
+
confirmation. (No backend needed for v1.)
|
|
20
|
+
3. House style: modern, generous whitespace, system font stack, one accent color.
|
|
21
|
+
Make it look like a real launch, not a template.
|
|
22
|
+
|
|
23
|
+
## Finish
|
|
24
|
+
|
|
25
|
+
After writing the files:
|
|
26
|
+
|
|
27
|
+
1. Call **`launch_preview`** with `{ "dir": "landing-page" }`.
|
|
28
|
+
2. Call **`record_use`** with recipe id `landing-page-launch` and a short summary.
|
|
29
|
+
3. Tell the user it's live in the preview, in one or two short sentences.
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "landing-page-launch",
|
|
3
|
+
"title": "Launch a landing page with a waitlist",
|
|
4
|
+
"producer": { "name": "Lina", "handle": "lina", "kind": "maker" },
|
|
5
|
+
"pitch": "A clean one-page launch site with a hero, features, and an email waitlist — live in one shot.",
|
|
6
|
+
"vendorDescription": "Lina's launch page. The exact layout that got her side-project 800 signups in a week: a punchy hero, three benefit blocks, social proof, and an email capture that just works.",
|
|
7
|
+
"summary": "A single-page marketing site: hero headline, three feature blocks, and an email signup that collects addresses. No setup. Make it yours by changing the words.",
|
|
8
|
+
"outcomeScore": 0.88,
|
|
9
|
+
"outcomeStats": { "builds": 51, "working": 45 },
|
|
10
|
+
"safetyBadge": "verified",
|
|
11
|
+
"rating": { "stars": 4.6, "count": 33 },
|
|
12
|
+
"tags": [
|
|
13
|
+
"landing page", "landing", "waitlist", "marketing site", "website",
|
|
14
|
+
"hero", "signup", "sign up", "coming soon", "product launch", "launch",
|
|
15
|
+
"email capture", "newsletter", "homepage", "promo"
|
|
16
|
+
],
|
|
17
|
+
"reward": {
|
|
18
|
+
"model": "one-time",
|
|
19
|
+
"unit": "per build",
|
|
20
|
+
"perUseValue": 0,
|
|
21
|
+
"oneTimeValue": 6.0,
|
|
22
|
+
"note": "Lina earns a one-time share each time her launch page know-how is used to build a site."
|
|
23
|
+
},
|
|
24
|
+
"buildDir": "landing-page"
|
|
25
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# Nia's portfolio — how to build it
|
|
2
|
+
|
|
3
|
+
> Absorb and build in one shot. Single self-contained `portfolio/index.html`
|
|
4
|
+
> (inline CSS + JS), no build step.
|
|
5
|
+
|
|
6
|
+
## Build steps
|
|
7
|
+
|
|
8
|
+
1. Create `portfolio/index.html`.
|
|
9
|
+
2. Sections:
|
|
10
|
+
- **Hero**: name, a one-line "what I do", and a primary contact button.
|
|
11
|
+
- **Projects**: a responsive grid of 3–6 cards (title, one-line description, a
|
|
12
|
+
tag or two). Use placeholder content the user can edit.
|
|
13
|
+
- **Contact**: an email line + simple links (placeholder).
|
|
14
|
+
3. House style: confident, lots of whitespace, one accent color, a tasteful hover
|
|
15
|
+
on the project cards. Make it look hireable.
|
|
16
|
+
|
|
17
|
+
## Finish
|
|
18
|
+
|
|
19
|
+
After writing the files: call **`launch_preview`** `{ "dir": "portfolio" }`, then
|
|
20
|
+
**`record_use`** with recipe id `personal-portfolio` and a short summary, then tell
|
|
21
|
+
the user it's live (briefly).
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "personal-portfolio",
|
|
3
|
+
"title": "Personal portfolio site",
|
|
4
|
+
"producer": { "name": "Nia", "handle": "nia", "kind": "maker" },
|
|
5
|
+
"pitch": "A sharp one-page portfolio — intro, projects, contact — live in one shot.",
|
|
6
|
+
"vendorDescription": "Nia's portfolio template. The layout that landed her three freelance clients: a confident intro, a project grid, and a contact line that converts.",
|
|
7
|
+
"summary": "A single-page portfolio: hero intro, a grid of project cards, and a contact section. Swap the text and images to make it yours.",
|
|
8
|
+
"outcomeScore": 0.81,
|
|
9
|
+
"outcomeStats": { "builds": 40, "working": 32 },
|
|
10
|
+
"safetyBadge": "tested",
|
|
11
|
+
"rating": { "stars": 4.3, "count": 21 },
|
|
12
|
+
"tags": [
|
|
13
|
+
"portfolio", "personal site", "personal website", "resume site", "cv site",
|
|
14
|
+
"about me", "showcase", "designer portfolio", "developer portfolio", "projects"
|
|
15
|
+
],
|
|
16
|
+
"reward": {
|
|
17
|
+
"model": "one-time",
|
|
18
|
+
"unit": "per build",
|
|
19
|
+
"perUseValue": 0,
|
|
20
|
+
"oneTimeValue": 5.0,
|
|
21
|
+
"note": "Nia earns a one-time share each time her portfolio know-how is used."
|
|
22
|
+
},
|
|
23
|
+
"buildDir": "portfolio"
|
|
24
|
+
}
|