@lovelybunch/api 1.0.49 → 1.0.51

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.
@@ -15,6 +15,7 @@ export interface StorageAdapter {
15
15
  }
16
16
  export declare class FileStorageAdapter implements StorageAdapter {
17
17
  private basePath;
18
+ private sanitizeForYAML;
18
19
  constructor(basePath?: string);
19
20
  ensureDirectories(): Promise<void>;
20
21
  createCP(cp: ChangeProposal): Promise<void>;
@@ -4,6 +4,33 @@ import matter from 'gray-matter';
4
4
  import Fuse from 'fuse.js';
5
5
  export class FileStorageAdapter {
6
6
  basePath;
7
+ // Remove undefined values recursively to avoid YAML dump errors
8
+ sanitizeForYAML(value) {
9
+ if (value === undefined)
10
+ return undefined;
11
+ if (value === null)
12
+ return null;
13
+ if (Array.isArray(value)) {
14
+ return value
15
+ .filter((v) => v !== undefined)
16
+ .map((v) => this.sanitizeForYAML(v));
17
+ }
18
+ if (value instanceof Date) {
19
+ return value.toISOString();
20
+ }
21
+ if (typeof value === 'object') {
22
+ const out = {};
23
+ for (const [k, v] of Object.entries(value)) {
24
+ if (v === undefined || typeof v === 'function')
25
+ continue;
26
+ const sanitized = this.sanitizeForYAML(v);
27
+ if (sanitized !== undefined)
28
+ out[k] = sanitized;
29
+ }
30
+ return out;
31
+ }
32
+ return value;
33
+ }
7
34
  constructor(basePath) {
8
35
  if (basePath) {
9
36
  this.basePath = basePath;
@@ -31,13 +58,20 @@ export class FileStorageAdapter {
31
58
  await this.ensureDirectories();
32
59
  // Extract content from the proposal if it exists
33
60
  const { content, ...frontmatter } = cp;
61
+ // Normalize date fields to Date instances in case callers provide ISO strings
62
+ const createdAt = cp.metadata.createdAt instanceof Date
63
+ ? cp.metadata.createdAt
64
+ : new Date(cp.metadata.createdAt);
65
+ const updatedAt = cp.metadata.updatedAt instanceof Date
66
+ ? cp.metadata.updatedAt
67
+ : new Date(cp.metadata.updatedAt);
34
68
  // Convert the proposal to markdown with YAML frontmatter
35
- const markdown = matter.stringify(content || this.getDefaultContent(cp), {
69
+ const frontmatterData = this.sanitizeForYAML({
36
70
  // Required fields
37
71
  id: cp.id,
38
72
  intent: cp.intent,
39
- createdAt: cp.metadata.createdAt.toISOString(),
40
- updatedAt: cp.metadata.updatedAt.toISOString(),
73
+ createdAt: createdAt.toISOString(),
74
+ updatedAt: updatedAt.toISOString(),
41
75
  status: cp.status,
42
76
  priority: cp.metadata.priority || 'medium',
43
77
  // Author information
@@ -61,8 +95,9 @@ export class FileStorageAdapter {
61
95
  // Metadata
62
96
  tags: cp.metadata.tags || [],
63
97
  labels: [],
64
- comments: cp.comments || []
98
+ comments: (cp.comments || []).filter((c) => c !== undefined)
65
99
  });
100
+ const markdown = matter.stringify(content || this.getDefaultContent(cp), frontmatterData);
66
101
  const filePath = path.join(this.basePath, 'proposals', `${cp.id}.md`);
67
102
  await fs.writeFile(filePath, markdown, 'utf-8');
68
103
  }
@@ -10,6 +10,9 @@ export interface TerminalSession {
10
10
  enableLogging?: boolean;
11
11
  logFilePath?: string;
12
12
  backlog: string;
13
+ previewSockets?: Set<WebSocket>;
14
+ previewBuffer?: string;
15
+ previewFlushTimer?: NodeJS.Timeout | null;
13
16
  }
14
17
  export declare class TerminalManager {
15
18
  private sessions;
@@ -25,4 +28,6 @@ export declare class TerminalManager {
25
28
  private cleanupInactiveSessions;
26
29
  getAllSessions(): TerminalSession[];
27
30
  destroy(): void;
31
+ private enqueuePreviewBroadcast;
32
+ attachPreviewWebSocket(sessionId: string, ws: WebSocket): boolean;
28
33
  }
@@ -114,6 +114,9 @@ export class TerminalManager {
114
114
  enableLogging,
115
115
  logFilePath,
116
116
  backlog: '',
117
+ previewSockets: new Set(),
118
+ previewBuffer: '',
119
+ previewFlushTimer: null,
117
120
  };
118
121
  this.sessions.set(sessionId, session);
119
122
  // Set up PTY event handlers
@@ -147,6 +150,8 @@ export class TerminalManager {
147
150
  data: data,
148
151
  }));
149
152
  }
153
+ // Broadcast to preview viewers with throttling
154
+ this.enqueuePreviewBroadcast(session, data);
150
155
  });
151
156
  ptyProcess.onExit((e) => {
152
157
  // Log session end if logging is enabled
@@ -261,6 +266,17 @@ export class TerminalManager {
261
266
  if (session.websocket && session.websocket.readyState === WebSocket.OPEN) {
262
267
  session.websocket.close();
263
268
  }
269
+ // Close all preview sockets
270
+ if (session.previewSockets && session.previewSockets.size > 0) {
271
+ for (const ws of Array.from(session.previewSockets)) {
272
+ try {
273
+ if (ws.readyState === WebSocket.OPEN)
274
+ ws.close();
275
+ }
276
+ catch { }
277
+ }
278
+ session.previewSockets.clear();
279
+ }
264
280
  // Kill PTY process
265
281
  try {
266
282
  session.pty.kill();
@@ -310,5 +326,68 @@ export class TerminalManager {
310
326
  clearInterval(this.cleanupInterval);
311
327
  }
312
328
  }
329
+ enqueuePreviewBroadcast(session, chunk) {
330
+ try {
331
+ if (!session.previewSockets || session.previewSockets.size === 0)
332
+ return;
333
+ session.previewBuffer = (session.previewBuffer || '') + chunk;
334
+ if (session.previewFlushTimer)
335
+ return;
336
+ session.previewFlushTimer = setTimeout(() => {
337
+ const payload = session.previewBuffer || '';
338
+ session.previewBuffer = '';
339
+ session.previewFlushTimer = null;
340
+ // Clean up closed sockets while broadcasting
341
+ const dead = [];
342
+ for (const ws of session.previewSockets) {
343
+ try {
344
+ if (ws.readyState === WebSocket.OPEN) {
345
+ ws.send(JSON.stringify({ type: 'data', data: payload }));
346
+ }
347
+ else {
348
+ dead.push(ws);
349
+ }
350
+ }
351
+ catch {
352
+ dead.push(ws);
353
+ }
354
+ }
355
+ if (dead.length) {
356
+ for (const d of dead)
357
+ session.previewSockets.delete(d);
358
+ }
359
+ }, 75); // batch messages ~13fps
360
+ }
361
+ catch { }
362
+ }
363
+ attachPreviewWebSocket(sessionId, ws) {
364
+ const session = this.sessions.get(sessionId);
365
+ if (!session)
366
+ return false;
367
+ if (!session.previewSockets)
368
+ session.previewSockets = new Set();
369
+ session.previewSockets.add(ws);
370
+ session.lastActivity = new Date();
371
+ // On attach, send a snapshot of recent backlog (last 4KB)
372
+ try {
373
+ const backlog = session.backlog || '';
374
+ const sliceStart = Math.max(0, backlog.length - 4096);
375
+ const chunk = backlog.slice(sliceStart);
376
+ if (ws.readyState === WebSocket.OPEN) {
377
+ ws.send(JSON.stringify({ type: 'snapshot', data: chunk }));
378
+ }
379
+ }
380
+ catch { }
381
+ ws.on('message', () => {
382
+ // Ignore messages: preview sockets are read-only
383
+ });
384
+ ws.on('close', () => {
385
+ session.previewSockets?.delete(ws);
386
+ });
387
+ ws.on('error', () => {
388
+ session.previewSockets?.delete(ws);
389
+ });
390
+ return true;
391
+ }
313
392
  }
314
393
  // We'll use the global manager instead of creating a direct export
@@ -2,7 +2,7 @@ import { homedir } from 'os';
2
2
  import { join } from 'path';
3
3
  import { existsSync, readFileSync } from 'fs';
4
4
  import { fileURLToPath } from 'url';
5
- import { proposalsTool, listProposalsTool } from '@lovelybunch/mcp';
5
+ import { proposalsTool, listProposalsTool, validateProposalData } from '@lovelybunch/mcp';
6
6
  import { FileStorageAdapter } from '../../../../lib/storage/file-storage.js';
7
7
  import { getAuthorInfo } from '../../../../lib/user-preferences.js';
8
8
  // Function to get global config API key as fallback
@@ -239,24 +239,62 @@ async function executeProposalsToolDirect(args, storage) {
239
239
  }
240
240
  // Get author info
241
241
  const author = await getAuthorInfo();
242
- // Create the proposal
243
- const proposalToCreate = {
244
- ...proposal,
245
- author,
246
- status: 'draft',
247
- metadata: {
248
- ...proposal.metadata,
249
- createdAt: new Date().toISOString(),
250
- updatedAt: new Date().toISOString(),
251
- reviewers: proposal.metadata?.reviewers || [],
252
- productSpecRef: proposal.metadata?.productSpecRef || null
242
+ // Normalize and validate incoming proposal
243
+ const validatedProposal = validateProposalData(proposal);
244
+ // Normalize plan steps: accept strings or objects
245
+ const planSteps = (validatedProposal.planSteps || []).map((step, index) => {
246
+ if (typeof step === 'string') {
247
+ return {
248
+ id: `step-${index + 1}`,
249
+ description: step,
250
+ status: 'pending'
251
+ };
253
252
  }
253
+ return {
254
+ id: step.id || `step-${index + 1}`,
255
+ description: step.description || '',
256
+ status: step.status || 'pending',
257
+ command: step.command,
258
+ expectedOutcome: step.expectedOutcome,
259
+ output: step.output,
260
+ error: step.error,
261
+ executedAt: step.executedAt
262
+ };
263
+ });
264
+ const now = new Date();
265
+ const newProposal = {
266
+ id: validatedProposal.id || `cp-${Date.now()}`,
267
+ intent: validatedProposal.intent || '',
268
+ content: validatedProposal.content || '',
269
+ author: {
270
+ id: validatedProposal.author?.id || 'current-user',
271
+ name: validatedProposal.author?.name || author.name || 'Unknown User',
272
+ email: validatedProposal.author?.email || author.email || '',
273
+ type: validatedProposal.author?.type || 'human'
274
+ },
275
+ planSteps,
276
+ evidence: validatedProposal.evidence || [],
277
+ policies: validatedProposal.policies || [],
278
+ featureFlags: validatedProposal.featureFlags || [],
279
+ experiments: validatedProposal.experiments || [],
280
+ telemetryContracts: validatedProposal.telemetryContracts || [],
281
+ releasePlan: validatedProposal.releasePlan || { strategy: 'immediate' },
282
+ status: validatedProposal.status || 'draft',
283
+ metadata: {
284
+ createdAt: now,
285
+ updatedAt: now,
286
+ reviewers: validatedProposal.metadata?.reviewers || [],
287
+ aiInteractions: validatedProposal.metadata?.aiInteractions || [],
288
+ tags: validatedProposal.metadata?.tags || [],
289
+ priority: validatedProposal.metadata?.priority || 'medium'
290
+ },
291
+ productSpecRef: validatedProposal.productSpecRef
254
292
  };
255
- await storage.createCP(proposalToCreate);
293
+ await storage.createCP(newProposal);
256
294
  return {
257
295
  success: true,
258
- data: proposalToCreate,
259
- message: `Created proposal ${proposalToCreate.id}`
296
+ data: newProposal,
297
+ message: `Created proposal ${newProposal.id} (${newProposal.intent})`
260
298
  };
261
299
  }
262
300
  case 'update': {
@@ -0,0 +1 @@
1
+ export { default } from './route.js';
@@ -0,0 +1 @@
1
+ export { default } from './route.js';
@@ -0,0 +1,3 @@
1
+ import { Hono } from 'hono';
2
+ declare const app: Hono<import("hono/types").BlankEnv, import("hono/types").BlankSchema, "/">;
3
+ export default app;
@@ -0,0 +1,59 @@
1
+ import { Hono } from 'hono';
2
+ import { promises as fs } from 'fs';
3
+ import path from 'path';
4
+ const app = new Hono();
5
+ const getMcpConfigPath = () => {
6
+ const gaitPath = path.join(process.cwd(), '.gait');
7
+ return path.join(gaitPath, 'mcp', 'config.json');
8
+ };
9
+ const ensureMcpDirectory = async () => {
10
+ const gaitPath = path.join(process.cwd(), '.gait');
11
+ const mcpPath = path.join(gaitPath, 'mcp');
12
+ try {
13
+ await fs.access(mcpPath);
14
+ }
15
+ catch {
16
+ await fs.mkdir(mcpPath, { recursive: true });
17
+ }
18
+ };
19
+ // GET /api/v1/mcp/config - Get MCP configuration
20
+ app.get('/', async (c) => {
21
+ try {
22
+ await ensureMcpDirectory();
23
+ const configPath = getMcpConfigPath();
24
+ try {
25
+ const configData = await fs.readFile(configPath, 'utf-8');
26
+ const config = JSON.parse(configData);
27
+ return c.json(config);
28
+ }
29
+ catch (error) {
30
+ // If file doesn't exist or is invalid, return empty config
31
+ return c.json({});
32
+ }
33
+ }
34
+ catch (error) {
35
+ console.error('Error reading MCP config:', error);
36
+ return c.json({ error: 'Failed to read MCP configuration' }, 500);
37
+ }
38
+ });
39
+ // PUT /api/v1/mcp/config - Update MCP configuration
40
+ app.put('/', async (c) => {
41
+ try {
42
+ const body = await c.req.json();
43
+ await ensureMcpDirectory();
44
+ const configPath = getMcpConfigPath();
45
+ // Validate the configuration structure
46
+ if (typeof body !== 'object') {
47
+ return c.json({ error: 'Invalid configuration format' }, 400);
48
+ }
49
+ // Write the configuration to file
50
+ const configData = JSON.stringify(body, null, 2);
51
+ await fs.writeFile(configPath, configData, 'utf-8');
52
+ return c.json({ message: 'MCP configuration updated successfully' });
53
+ }
54
+ catch (error) {
55
+ console.error('Error updating MCP config:', error);
56
+ return c.json({ error: 'Failed to update MCP configuration' }, 500);
57
+ }
58
+ });
59
+ export default app;
@@ -40,10 +40,10 @@ app.get('/', async (c) => {
40
40
  }
41
41
  }
42
42
  const names = Object.keys(externalServers);
43
- // Add built-in tools
43
+ // Add built-in tools (include full JSON schema for parameters)
44
44
  const builtInTools = {
45
- proposals: proposalsTool,
46
- listProposals: listProposalsTool
45
+ change_proposals: proposalsTool,
46
+ list_proposals: listProposalsTool
47
47
  };
48
48
  return c.json({
49
49
  success: true,
@@ -57,6 +57,25 @@ app.get('/', async (c) => {
57
57
  return c.json({ success: false, error: 'Failed to load MCP servers', servers: [], tools: {} }, 500);
58
58
  }
59
59
  });
60
+ /**
61
+ * GET /api/v1/mcp/schema
62
+ * Returns the JSON Schemas for built-in tools so LLMs and clients can construct valid calls.
63
+ */
64
+ app.get('/schema', async (c) => {
65
+ try {
66
+ const schema = {
67
+ tools: {
68
+ change_proposals: proposalsTool,
69
+ list_proposals: listProposalsTool
70
+ }
71
+ };
72
+ return c.json(schema);
73
+ }
74
+ catch (err) {
75
+ console.error('Error returning MCP schema:', err);
76
+ return c.json({ error: 'Failed to load MCP schema' }, 500);
77
+ }
78
+ });
60
79
  /**
61
80
  * POST /api/v1/mcp/execute
62
81
  * Execute a tool call
@@ -1,12 +1,51 @@
1
1
  import { getGlobalTerminalManager } from '../../../../../lib/terminal/global-manager.js';
2
+ // Lightweight ANSI escape sequence stripper to keep preview readable
3
+ // This avoids adding a dependency just for previews
4
+ const ANSI_REGEX = /[\u001B\u009B][[\]()#;?]*(?:\d{1,4}(?:;\d{0,4})*)?[0-9A-ORZcf-nqry=><]/g;
5
+ function stripAnsi(input) {
6
+ try {
7
+ return input.replace(ANSI_REGEX, '');
8
+ }
9
+ catch {
10
+ return input;
11
+ }
12
+ }
13
+ // Create a small preview from the backlog without scanning the entire string
14
+ function buildPreview(backlog, lines, maxBytes, strip) {
15
+ if (!backlog)
16
+ return [];
17
+ // Take only the last maxBytes to avoid processing the full backlog
18
+ const sliceStart = Math.max(0, backlog.length - maxBytes);
19
+ let chunk = backlog.slice(sliceStart);
20
+ // Normalize newlines for readability
21
+ chunk = chunk.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
22
+ // Strip ANSI sequences unless colorized previews are requested
23
+ if (strip) {
24
+ chunk = stripAnsi(chunk);
25
+ }
26
+ const parts = chunk.split('\n');
27
+ const tail = parts.slice(-Math.max(1, lines));
28
+ return tail;
29
+ }
2
30
  export async function GET(c) {
3
31
  try {
4
32
  const url = new URL(c.req.url);
5
33
  const searchParams = url.searchParams;
6
34
  const proposalId = searchParams.get('proposalId');
35
+ const sessionId = searchParams.get('sessionId');
36
+ const withPreview = ['1', 'true', 'yes'].includes((searchParams.get('withPreview') || '').toLowerCase());
37
+ const linesParam = parseInt(searchParams.get('lines') || '0', 10);
38
+ const previewLines = Number.isFinite(linesParam) && linesParam > 0 ? Math.min(linesParam, 10) : 4; // cap at 10
39
+ const maxBytesParam = parseInt(searchParams.get('previewBytes') || '0', 10);
40
+ const previewBytes = Number.isFinite(maxBytesParam) && maxBytesParam > 0 ? Math.min(maxBytesParam, 8192) : 4096; // cap at 8KB
41
+ const colorize = ['1', 'true', 'yes'].includes((searchParams.get('colorize') || '').toLowerCase());
7
42
  const terminalManager = getGlobalTerminalManager();
8
43
  let sessions;
9
- if (proposalId) {
44
+ if (sessionId) {
45
+ const s = terminalManager.getSession(sessionId);
46
+ sessions = s ? [s] : [];
47
+ }
48
+ else if (proposalId) {
10
49
  sessions = terminalManager.getSessionsByProposal(proposalId);
11
50
  }
12
51
  else {
@@ -19,6 +58,9 @@ export async function GET(c) {
19
58
  createdAt: session.createdAt,
20
59
  lastActivity: session.lastActivity,
21
60
  connected: !!session.websocket,
61
+ ...(withPreview
62
+ ? { preview: buildPreview(session.backlog || '', previewLines, previewBytes, !colorize) }
63
+ : {}),
22
64
  }));
23
65
  return c.json({ sessions: sessionInfo });
24
66
  }
@@ -52,6 +52,28 @@ app.get('/ws/terminal/:sessionId', upgradeWebSocket((c) => ({
52
52
  console.error('WebSocket error:', evt);
53
53
  }
54
54
  })));
55
+ // WebSocket route for terminal preview sessions (read-only viewers)
56
+ app.get('/ws/terminal-preview/:sessionId', upgradeWebSocket((c) => ({
57
+ onOpen: (_evt, ws) => {
58
+ const sessionId = c.req.param('sessionId');
59
+ const terminalManager = getGlobalTerminalManager();
60
+ const rawWs = ws.raw;
61
+ if (!rawWs) {
62
+ try {
63
+ ws.close(1000, 'WebSocket error');
64
+ }
65
+ catch { }
66
+ return;
67
+ }
68
+ const success = terminalManager.attachPreviewWebSocket(sessionId, rawWs);
69
+ if (!success) {
70
+ try {
71
+ ws.close(1000, 'Session not found');
72
+ }
73
+ catch { }
74
+ }
75
+ },
76
+ })));
55
77
  // Import and register API routes
56
78
  import proposals from './routes/api/v1/proposals/index.js';
57
79
  import terminalSessions from './routes/api/v1/terminal/sessions/index.js';
package/dist/server.js CHANGED
@@ -50,6 +50,28 @@ app.get('/ws/terminal/:sessionId', upgradeWebSocket((c) => ({
50
50
  console.error('WebSocket error:', evt);
51
51
  }
52
52
  })));
53
+ // WebSocket route for terminal preview sessions (read-only viewers)
54
+ app.get('/ws/terminal-preview/:sessionId', upgradeWebSocket((c) => ({
55
+ onOpen: (evt, ws) => {
56
+ const sessionId = c.req.param('sessionId');
57
+ const terminalManager = getGlobalTerminalManager();
58
+ const rawWs = ws.raw;
59
+ if (!rawWs) {
60
+ try {
61
+ ws.close(1000, 'WebSocket error');
62
+ }
63
+ catch { }
64
+ return;
65
+ }
66
+ const success = terminalManager.attachPreviewWebSocket(sessionId, rawWs);
67
+ if (!success) {
68
+ try {
69
+ ws.close(1000, 'Session not found');
70
+ }
71
+ catch { }
72
+ }
73
+ },
74
+ })));
53
75
  // Import and register API routes
54
76
  import proposals from './routes/api/v1/proposals/index.js';
55
77
  import terminalSessions from './routes/api/v1/terminal/sessions/index.js';
@@ -105,3 +127,4 @@ const server = serve({
105
127
  injectWebSocket(server);
106
128
  console.log(`🚀 Server running at http://localhost:${port}`);
107
129
  console.log(`🔌 WebSocket available at ws://localhost:${port}/ws/terminal/:sessionId`);
130
+ console.log(`🔎 Preview WebSocket available at ws://localhost:${port}/ws/terminal-preview/:sessionId`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lovelybunch/api",
3
- "version": "1.0.49",
3
+ "version": "1.0.51",
4
4
  "type": "module",
5
5
  "main": "dist/server-with-static.js",
6
6
  "exports": {
@@ -32,9 +32,9 @@
32
32
  "dependencies": {
33
33
  "@hono/node-server": "^1.13.7",
34
34
  "@hono/node-ws": "^1.0.6",
35
- "@lovelybunch/core": "^1.0.49",
36
- "@lovelybunch/mcp": "^1.0.48",
37
- "@lovelybunch/types": "^1.0.49",
35
+ "@lovelybunch/core": "^1.0.51",
36
+ "@lovelybunch/mcp": "^1.0.51",
37
+ "@lovelybunch/types": "^1.0.51",
38
38
  "dotenv": "^17.2.1",
39
39
  "fuse.js": "^7.0.0",
40
40
  "gray-matter": "^4.0.3",