@maintainer-pro/ai-cli 0.1.2 → 0.1.4

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/README.md CHANGED
@@ -58,6 +58,5 @@ Chat history is stored in Maintainer Pro admin. Do not put API keys in the brows
58
58
  |---------|------|
59
59
  | [`@maintainer-pro/ai-server`](https://www.npmjs.com/package/@maintainer-pro/ai-server) | Local Node chat server (sidecar) that uses this SDK |
60
60
  | [`@maintainer-pro/ai-ui`](https://www.npmjs.com/package/@maintainer-pro/ai-ui) | Embed widget (`ai-ui.iife.js`) — sidecar in dev, static file in prod |
61
- | [`@maintainer-pro/setup`](https://www.npmjs.com/package/@maintainer-pro/setup) | Download the sandbox setup guide |
62
61
 
63
62
  Optional SQL persistence lives on `@maintainer-pro/ai-cli/db` (install `better-sqlite3`, `postgres`, or `mysql2`).
package/dist/index.cjs CHANGED
@@ -30,6 +30,7 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
30
30
  // src/index.ts
31
31
  var index_exports = {};
32
32
  __export(index_exports, {
33
+ WORKING_PROVIDER: () => WORKING_PROVIDER,
33
34
  buildClaudeUserPrompt: () => buildClaudeUserPrompt,
34
35
  buildConversationPrompt: () => buildConversationPrompt,
35
36
  buildCursorPrompt: () => buildCursorPrompt,
@@ -48,6 +49,7 @@ __export(index_exports, {
48
49
  createToolValidator: () => createToolValidator,
49
50
  formatClientContext: () => formatClientContext,
50
51
  getProviderPreference: () => getProviderPreference,
52
+ inspectAndRepairWorkspace: () => inspectAndRepairWorkspace,
51
53
  parseAiResponse: () => parseAiResponse,
52
54
  providerLabel: () => providerLabel,
53
55
  resolveCliBinary: () => resolveCliBinary,
@@ -102,7 +104,7 @@ function splitMessages(messages) {
102
104
  const history = latestUserIndex > 0 ? messages.slice(0, latestUserIndex) : [];
103
105
  return { request, history };
104
106
  }
105
- function buildUserFacingPrompt(systemPrompt, messages, context, attachmentPaths, priorConversationsContext) {
107
+ function buildUserFacingPrompt(systemPrompt, messages, context, attachmentPaths, priorConversationsContext, technical) {
106
108
  const { request, history } = splitMessages(messages);
107
109
  const historyBlock = history.length > 0 ? `Conversation so far:
108
110
  ${buildConversationPrompt(history)}
@@ -122,27 +124,30 @@ ${buildConversationPrompt(history)}
122
124
  ${attachmentPaths.map((p) => `- ${p}`).join("\n")}
123
125
 
124
126
  ` : "";
127
+ const closer = technical ? "Do the work now. Follow the requested output format exactly." : "Do the work now (or ask one short clarifying question if needed). Reply in English, non-technical and user-facing. If it's not resolving, offer a quick call.";
125
128
  return `${systemSection}${contextSection}${priorSection}${historyBlock}${attachmentsSection}Current user request:
126
129
  ${request || "(See the attached screenshot(s).)"}
127
130
 
128
- Do the work now (or ask one short clarifying question if needed). Reply in English, non-technical and user-facing. If it's not resolving, offer a quick call.`;
131
+ ${closer}`;
129
132
  }
130
- function buildCursorPrompt(systemPrompt, messages, context, attachmentPaths, priorConversationsContext) {
133
+ function buildCursorPrompt(systemPrompt, messages, context, attachmentPaths, priorConversationsContext, technical) {
131
134
  return buildUserFacingPrompt(
132
135
  systemPrompt,
133
136
  messages,
134
137
  context,
135
138
  attachmentPaths,
136
- priorConversationsContext
139
+ priorConversationsContext,
140
+ technical
137
141
  );
138
142
  }
139
- function buildClaudeUserPrompt(messages, context, attachmentPaths, priorConversationsContext) {
143
+ function buildClaudeUserPrompt(messages, context, attachmentPaths, priorConversationsContext, technical) {
140
144
  return buildUserFacingPrompt(
141
145
  null,
142
146
  messages,
143
147
  context,
144
148
  attachmentPaths,
145
- priorConversationsContext
149
+ priorConversationsContext,
150
+ technical
146
151
  );
147
152
  }
148
153
 
@@ -255,7 +260,8 @@ async function callClaudeCli(command, messages, context, options) {
255
260
  messages,
256
261
  context,
257
262
  options.attachmentPaths,
258
- options.priorConversationsContext
263
+ options.priorConversationsContext,
264
+ options.technical
259
265
  );
260
266
  const resolved = resolveCliCommand("claude", command);
261
267
  const workspace = options.workspaceDir ?? process.env.AI_CLI_WORKSPACE ?? process.cwd();
@@ -294,7 +300,8 @@ async function callCursorCli(command, messages, context, options) {
294
300
  messages,
295
301
  context,
296
302
  options.attachmentPaths,
297
- options.priorConversationsContext
303
+ options.priorConversationsContext,
304
+ options.technical
298
305
  );
299
306
  const resolved = resolveCliCommand("cursor", command);
300
307
  const workspace = options.workspaceDir ?? process.env.AI_CLI_WORKSPACE ?? process.cwd();
@@ -335,7 +342,8 @@ async function callAntigravityCli(command, messages, context, options) {
335
342
  messages,
336
343
  context,
337
344
  options.attachmentPaths,
338
- options.priorConversationsContext
345
+ options.priorConversationsContext,
346
+ options.technical
339
347
  );
340
348
  const prompt = `You are operating as a coding agent with full permission to read and edit files in this workspace. Do not introduce yourself. Do not ask what you are. Execute the user's latest request now (edit files and/or emit runtime tool JSON as instructed).
341
349
 
@@ -592,7 +600,9 @@ async function buildPriorConversationsContext(db, options) {
592
600
  ).slice(0, maxConversations) : ranked;
593
601
  const blocks = selected.map((c) => {
594
602
  const slice = c.messages.length > maxMessages ? c.messages.slice(-maxMessages) : c.messages;
595
- const lines = slice.filter((m) => m.role === "user" || m.role === "assistant").map(
603
+ const lines = slice.filter(
604
+ (m) => (m.role === "user" || m.role === "assistant") && m.provider !== "working" && Boolean(m.content?.trim())
605
+ ).map(
596
606
  (m) => `${m.role === "user" ? "Human" : "Assistant"}: ${truncate(m.content)}`
597
607
  );
598
608
  const when = c.updatedAt ? ` (updated ${c.updatedAt.slice(0, 10)})` : "";
@@ -625,6 +635,18 @@ function createToolValidator(schemas) {
625
635
  }
626
636
 
627
637
  // src/http/handler.ts
638
+ var WORKING_PROVIDER = "working";
639
+ async function setWorkingMessage(db, conversationId) {
640
+ await db.ensureConversation(conversationId);
641
+ await db.clearWorkingMessages?.(conversationId);
642
+ await db.saveMessage({
643
+ conversationId,
644
+ role: "assistant",
645
+ content: "",
646
+ provider: WORKING_PROVIDER,
647
+ senderType: "ai"
648
+ });
649
+ }
628
650
  var SHARED_CONVERSATION_ID = "shared";
629
651
  var conversationTurnSeq = /* @__PURE__ */ new Map();
630
652
  function beginConversationTurn(conversationId) {
@@ -697,6 +719,8 @@ function createChatHandler(options) {
697
719
  }
698
720
  },
699
721
  async POST(request) {
722
+ let trackedConversationId;
723
+ let trackedTurn = 0;
700
724
  try {
701
725
  const body = await request.json();
702
726
  const conversationId = await resolveSharedConversationId(
@@ -704,21 +728,16 @@ function createChatHandler(options) {
704
728
  options,
705
729
  body.conversationId
706
730
  );
707
- if (body.type === "status") {
708
- const content = body.content?.trim();
709
- if (!conversationId || !content) {
731
+ trackedConversationId = conversationId;
732
+ if (body.type === "working-clear") {
733
+ if (!conversationId) {
710
734
  return Response.json(
711
- { error: "conversationId and content are required" },
735
+ { error: "conversationId is required" },
712
736
  { status: 400 }
713
737
  );
714
738
  }
715
- if (options.db) {
716
- await options.db.ensureConversation(conversationId);
717
- await options.db.saveMessage({
718
- conversationId,
719
- role: "assistant",
720
- content
721
- });
739
+ if (options.db?.clearWorkingMessages) {
740
+ await options.db.clearWorkingMessages(conversationId);
722
741
  }
723
742
  return Response.json({ ok: true, conversationId });
724
743
  }
@@ -766,7 +785,8 @@ function createChatHandler(options) {
766
785
  );
767
786
  persistAttachmentPaths = attachmentPaths;
768
787
  }
769
- const turn = beginConversationTurn(conversationId);
788
+ const turn = beginConversationTurn(conversationId ?? SHARED_CONVERSATION_ID);
789
+ trackedTurn = turn;
770
790
  const signal = request.signal;
771
791
  if (options.db && conversationId) {
772
792
  const latest = messages[messages.length - 1];
@@ -782,13 +802,26 @@ function createChatHandler(options) {
782
802
  senderName: body.senderName?.trim() || void 0
783
803
  });
784
804
  }
805
+ await setWorkingMessage(options.db, conversationId);
785
806
  }
786
807
  const latestUser = [...messages].reverse().find((m) => m.role === "user");
787
808
  const priorConversationsContext = options.db ? await buildPriorConversationsContext(options.db, {
788
809
  excludeConversationId: conversationId,
789
810
  currentRequest: latestUser?.content ?? ""
790
811
  }) : "";
791
- if (signal.aborted || !isActiveConversationTurn(conversationId, turn)) {
812
+ if (!isActiveConversationTurn(
813
+ conversationId ?? SHARED_CONVERSATION_ID,
814
+ turn
815
+ )) {
816
+ return Response.json({
817
+ superseded: true,
818
+ conversationId: conversationId ?? null
819
+ });
820
+ }
821
+ if (signal.aborted) {
822
+ if (options.db?.clearWorkingMessages && conversationId) {
823
+ await options.db.clearWorkingMessages(conversationId);
824
+ }
792
825
  return Response.json({
793
826
  superseded: true,
794
827
  conversationId: conversationId ?? null
@@ -799,7 +832,10 @@ function createChatHandler(options) {
799
832
  attachmentPaths,
800
833
  priorConversationsContext: priorConversationsContext || void 0
801
834
  });
802
- if (!isActiveConversationTurn(conversationId, turn)) {
835
+ if (!isActiveConversationTurn(
836
+ conversationId ?? SHARED_CONVERSATION_ID,
837
+ turn
838
+ )) {
803
839
  return Response.json({
804
840
  superseded: true,
805
841
  conversationId: conversationId ?? null
@@ -811,6 +847,7 @@ function createChatHandler(options) {
811
847
  }
812
848
  if (options.db && conversationId) {
813
849
  await options.db.ensureConversation(conversationId);
850
+ await options.db.clearWorkingMessages?.(conversationId);
814
851
  await options.db.saveMessage({
815
852
  conversationId,
816
853
  role: "assistant",
@@ -834,6 +871,11 @@ function createChatHandler(options) {
834
871
  });
835
872
  } catch (err) {
836
873
  console.error("Chat API error:", err);
874
+ if (trackedConversationId && options.db?.clearWorkingMessages && isActiveConversationTurn(trackedConversationId, trackedTurn)) {
875
+ await options.db.clearWorkingMessages(trackedConversationId).catch(
876
+ () => void 0
877
+ );
878
+ }
837
879
  return Response.json(
838
880
  {
839
881
  error: "Sorry about that \u2014 I couldn't finish this right now. Happy to jump on a quick call if you'd like help \u2014 just let us know when works."
@@ -903,6 +945,16 @@ function createLocalDirectoryStore(baseDir) {
903
945
  await writeConversation(filePath, existing);
904
946
  return message;
905
947
  },
948
+ async clearWorkingMessages(conversationId) {
949
+ const filePath = fileFor(conversationId);
950
+ const existing = await readConversation(filePath);
951
+ if (!existing) return;
952
+ const next = existing.messages.filter((m) => m.provider !== "working");
953
+ if (next.length === existing.messages.length) return;
954
+ existing.messages = next;
955
+ existing.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
956
+ await writeConversation(filePath, existing);
957
+ },
906
958
  async listMessages(conversationId) {
907
959
  const existing = await readConversation(fileFor(conversationId));
908
960
  return existing?.messages ?? [];
@@ -1009,6 +1061,15 @@ function createMaintainerProStore(options) {
1009
1061
  );
1010
1062
  return data.message;
1011
1063
  },
1064
+ async clearWorkingMessages(conversationId) {
1065
+ await mpFetch(
1066
+ baseUrl,
1067
+ apiKey,
1068
+ `/api/v1/store/conversations/${encodeURIComponent(conversationId)}/messages?provider=working`,
1069
+ { method: "DELETE" },
1070
+ fetchImpl
1071
+ );
1072
+ },
1012
1073
  async listMessages(conversationId) {
1013
1074
  const data = await mpFetch(
1014
1075
  baseUrl,
@@ -1088,8 +1149,115 @@ function createMaintainerProStoreFromEnv() {
1088
1149
  if (!baseUrl || !apiKey) return null;
1089
1150
  return createMaintainerProStore({ baseUrl, apiKey });
1090
1151
  }
1152
+
1153
+ // src/workspace-inspect.ts
1154
+ var import_zod = require("zod");
1155
+ var inspectJsonSchema = import_zod.z.object({
1156
+ kind: import_zod.z.enum(["next", "vite", "html", "empty", "other"]).optional(),
1157
+ name: import_zod.z.string().max(200).optional(),
1158
+ summary: import_zod.z.string().max(800).optional(),
1159
+ scripts: import_zod.z.object({
1160
+ ui: import_zod.z.string().min(1).max(64).optional(),
1161
+ backend: import_zod.z.string().min(1).max(64).optional(),
1162
+ app: import_zod.z.string().min(1).max(64).optional()
1163
+ }).optional(),
1164
+ ports: import_zod.z.object({
1165
+ ui: import_zod.z.number().int().min(1).max(65535).optional(),
1166
+ backend: import_zod.z.number().int().min(1).max(65535).optional(),
1167
+ app: import_zod.z.number().int().min(1).max(65535).optional()
1168
+ }).optional(),
1169
+ issues: import_zod.z.array(import_zod.z.string().max(500)).max(20).optional(),
1170
+ fixes: import_zod.z.array(import_zod.z.string().max(500)).max(20).optional(),
1171
+ ready: import_zod.z.boolean().optional()
1172
+ });
1173
+ function extractInspectJson(text) {
1174
+ const fence = text.match(/```json\s*([\s\S]*?)```/i);
1175
+ const raw = fence?.[1]?.trim() || text.match(/\{[\s\S]*\}/)?.[0];
1176
+ if (!raw) return null;
1177
+ try {
1178
+ const parsed = inspectJsonSchema.safeParse(JSON.parse(raw));
1179
+ return parsed.success ? parsed.data : null;
1180
+ } catch {
1181
+ return null;
1182
+ }
1183
+ }
1184
+ function setupSystemPrompt(input) {
1185
+ return `You are Maintainer Pro's local setup assistant on the developer's machine.
1186
+
1187
+ Workspace: ${input.workspaceDir}
1188
+ App name: ${input.appName || "the app"}
1189
+
1190
+ Inspect this repository (package.json, README, lockfiles, existing env files, how the UI and API start).
1191
+ You MAY edit files to fix setup problems that would block local run or the chat widget (env keys, widget mount, missing config).
1192
+ Do NOT start long-lived dev servers, tunnels, or install global tools.
1193
+ Do NOT run \`npm run dev\` or similar.
1194
+
1195
+ Script names you report MUST already exist in package.json. Omit chat/ai-server scripts.
1196
+
1197
+ End with a short human summary AND one \`\`\`json fence with:
1198
+
1199
+ {
1200
+ "kind": "next" | "vite" | "html" | "empty" | "other",
1201
+ "name": "package or folder name",
1202
+ "summary": "one sentence about this project",
1203
+ "scripts": { "ui": "dev:client", "backend": "dev:server" },
1204
+ "ports": { "ui": 5173, "backend": 4100 },
1205
+ "issues": ["remaining problems"],
1206
+ "fixes": ["what you changed"],
1207
+ "ready": true
1208
+ }`;
1209
+ }
1210
+ function setupUserMessage(input) {
1211
+ if (input.problem?.trim()) {
1212
+ return `The local bridge failed to keep this project running:
1213
+
1214
+ ${input.problem.trim()}
1215
+
1216
+ ${input.extraContext ? `${input.extraContext.trim()}
1217
+
1218
+ ` : ""}Inspect the repo, fix the cause if you can, and return the JSON report.`;
1219
+ }
1220
+ return `${input.extraContext ? `${input.extraContext.trim()}
1221
+
1222
+ ` : ""}Inspect this project, fix any setup gaps you can, and return the JSON report.`;
1223
+ }
1224
+ async function inspectAndRepairWorkspace(input) {
1225
+ const workspaceDir = input.workspaceDir;
1226
+ const provider = await resolveProvider({ preference: "auto" });
1227
+ const response = await callAi(
1228
+ [{ role: "user", content: setupUserMessage(input) }],
1229
+ {
1230
+ route: "/local-setup",
1231
+ pageTitle: input.appName || "Local setup",
1232
+ relevantFiles: ["package.json", "README.md", ".env", ".env.example"],
1233
+ data: {
1234
+ workspaceDir,
1235
+ problem: input.problem || null
1236
+ }
1237
+ },
1238
+ {
1239
+ systemPrompt: setupSystemPrompt(input),
1240
+ workspaceDir,
1241
+ technical: true
1242
+ }
1243
+ );
1244
+ const parsed = extractInspectJson(response.text);
1245
+ return {
1246
+ kind: parsed?.kind ?? "other",
1247
+ name: parsed?.name?.trim() || input.appName || "",
1248
+ summary: parsed?.summary?.trim() || response.text.replace(/```json[\s\S]*```/i, "").trim().slice(0, 400),
1249
+ scripts: parsed?.scripts ?? {},
1250
+ ports: parsed?.ports ?? {},
1251
+ issues: parsed?.issues ?? [],
1252
+ fixes: parsed?.fixes ?? [],
1253
+ ready: parsed?.ready ?? parsed?.issues?.length === 0,
1254
+ provider: response.provider || provider.id,
1255
+ rawText: response.text
1256
+ };
1257
+ }
1091
1258
  // Annotate the CommonJS export names for ESM import in node:
1092
1259
  0 && (module.exports = {
1260
+ WORKING_PROVIDER,
1093
1261
  buildClaudeUserPrompt,
1094
1262
  buildConversationPrompt,
1095
1263
  buildCursorPrompt,
@@ -1108,6 +1276,7 @@ function createMaintainerProStoreFromEnv() {
1108
1276
  createToolValidator,
1109
1277
  formatClientContext,
1110
1278
  getProviderPreference,
1279
+ inspectAndRepairWorkspace,
1111
1280
  parseAiResponse,
1112
1281
  providerLabel,
1113
1282
  resolveCliBinary,
package/dist/index.d.cts CHANGED
@@ -56,6 +56,8 @@ interface CallAiOptions {
56
56
  priorConversationsContext?: string;
57
57
  /** Extra / override providers (e.g. future HTTP model backends). */
58
58
  providers?: AiProvider[];
59
+ /** When true, skip the end-user chat footer so the agent can return structured output. */
60
+ technical?: boolean;
59
61
  }
60
62
  interface AiProvider {
61
63
  id: string;
@@ -85,9 +87,9 @@ declare function parseAiResponse(raw: string, provider: AiCliProviderId): AiResp
85
87
  declare function buildConversationPrompt(messages: ChatMessage[]): string;
86
88
  declare function formatClientContext(context?: ClientContext): string;
87
89
  /** Full prompt for providers without a native --system-prompt flag (Cursor). */
88
- declare function buildCursorPrompt(systemPrompt: string, messages: ChatMessage[], context?: ClientContext, attachmentPaths?: string[], priorConversationsContext?: string): string;
90
+ declare function buildCursorPrompt(systemPrompt: string, messages: ChatMessage[], context?: ClientContext, attachmentPaths?: string[], priorConversationsContext?: string, technical?: boolean): string;
89
91
  /** Conversation + context for Claude (system prompt passed separately). */
90
- declare function buildClaudeUserPrompt(messages: ChatMessage[], context?: ClientContext, attachmentPaths?: string[], priorConversationsContext?: string): string;
92
+ declare function buildClaudeUserPrompt(messages: ChatMessage[], context?: ClientContext, attachmentPaths?: string[], priorConversationsContext?: string, technical?: boolean): string;
91
93
 
92
94
  declare function createDefaultSystemPrompt(input: {
93
95
  productDescription: string;
@@ -111,6 +113,8 @@ declare function createToolValidator(schemas: ToolSchemaMap): (toolCall: ToolCal
111
113
  error: string;
112
114
  };
113
115
 
116
+ /** Stored on assistant rows while a single in-flight AI turn is running. */
117
+ declare const WORKING_PROVIDER = "working";
114
118
  /** Minimal persistence contract (file store, SQL, or Maintainer Pro). */
115
119
  interface ChatStoreLike {
116
120
  ensureConversation(id: string): Promise<void>;
@@ -133,6 +137,8 @@ interface ChatStoreLike {
133
137
  senderType?: "client" | "developer" | "ai" | null;
134
138
  senderName?: string | null;
135
139
  }>>;
140
+ /** Remove in-progress working placeholders (at most one per conversation). */
141
+ clearWorkingMessages?(conversationId: string): Promise<void>;
136
142
  /** All conversations under the store (for prior-chat context). */
137
143
  listConversations?(): Promise<Array<{
138
144
  id: string;
@@ -141,6 +147,7 @@ interface ChatStoreLike {
141
147
  role: string;
142
148
  content: string;
143
149
  createdAt?: string | Date;
150
+ provider?: string | null;
144
151
  }>;
145
152
  }>>;
146
153
  saveToolEvents?(conversationId: string, events: Array<{
@@ -236,4 +243,39 @@ interface PriorConversationOptions {
236
243
  */
237
244
  declare function buildPriorConversationsContext(db: ChatStoreLike, options: PriorConversationOptions): Promise<string>;
238
245
 
239
- export { type AiCliProviderId, type AiProvider, type AiResponse, type CallAiOptions, type ChatAttachment, type ChatHandlerOptions, type ChatHandlers, type ChatMessage, type ChatSenderType, type ChatStoreLike, type ChatUser, type ClientContext, type LocalStoredMessage, type MaintainerProStoreOptions, type PriorConversationOptions, type ProviderPreference, type ToolCall, type ToolSchemaMap, buildClaudeUserPrompt, buildConversationPrompt, buildCursorPrompt, buildPriorConversationsContext, callAi, commandExists, createAntigravityProvider, createBuiltinProviders, createChatHandler, createClaudeProvider, createCursorProvider, createDefaultSystemPrompt, createLocalDirectoryStore, createMaintainerProStore, createMaintainerProStoreFromEnv, createToolValidator, formatClientContext, getProviderPreference, parseAiResponse, providerLabel, resolveCliBinary, resolveProvider, saveChatAttachments, toNextRoute };
246
+ type WorkspaceKind = "next" | "vite" | "html" | "empty" | "other";
247
+ interface WorkspaceInspectInput {
248
+ workspaceDir: string;
249
+ appName?: string;
250
+ /** When set, the agent diagnoses and tries to fix this failure. */
251
+ problem?: string;
252
+ extraContext?: string;
253
+ }
254
+ interface WorkspaceInspectResult {
255
+ kind: WorkspaceKind;
256
+ name: string;
257
+ summary: string;
258
+ scripts: {
259
+ ui?: string;
260
+ backend?: string;
261
+ app?: string;
262
+ };
263
+ ports: {
264
+ ui?: number;
265
+ backend?: number;
266
+ app?: number;
267
+ };
268
+ issues: string[];
269
+ fixes: string[];
270
+ ready: boolean;
271
+ provider: string;
272
+ rawText: string;
273
+ }
274
+ /**
275
+ * Ask the coding-agent CLI (via callAi) to inspect a workspace and optionally
276
+ * repair setup issues. Used by ai-bridge so host-process planning uses the
277
+ * same provider resolution as chat.
278
+ */
279
+ declare function inspectAndRepairWorkspace(input: WorkspaceInspectInput): Promise<WorkspaceInspectResult>;
280
+
281
+ export { type AiCliProviderId, type AiProvider, type AiResponse, type CallAiOptions, type ChatAttachment, type ChatHandlerOptions, type ChatHandlers, type ChatMessage, type ChatSenderType, type ChatStoreLike, type ChatUser, type ClientContext, type LocalStoredMessage, type MaintainerProStoreOptions, type PriorConversationOptions, type ProviderPreference, type ToolCall, type ToolSchemaMap, WORKING_PROVIDER, type WorkspaceInspectInput, type WorkspaceInspectResult, type WorkspaceKind, buildClaudeUserPrompt, buildConversationPrompt, buildCursorPrompt, buildPriorConversationsContext, callAi, commandExists, createAntigravityProvider, createBuiltinProviders, createChatHandler, createClaudeProvider, createCursorProvider, createDefaultSystemPrompt, createLocalDirectoryStore, createMaintainerProStore, createMaintainerProStoreFromEnv, createToolValidator, formatClientContext, getProviderPreference, inspectAndRepairWorkspace, parseAiResponse, providerLabel, resolveCliBinary, resolveProvider, saveChatAttachments, toNextRoute };
package/dist/index.d.ts CHANGED
@@ -56,6 +56,8 @@ interface CallAiOptions {
56
56
  priorConversationsContext?: string;
57
57
  /** Extra / override providers (e.g. future HTTP model backends). */
58
58
  providers?: AiProvider[];
59
+ /** When true, skip the end-user chat footer so the agent can return structured output. */
60
+ technical?: boolean;
59
61
  }
60
62
  interface AiProvider {
61
63
  id: string;
@@ -85,9 +87,9 @@ declare function parseAiResponse(raw: string, provider: AiCliProviderId): AiResp
85
87
  declare function buildConversationPrompt(messages: ChatMessage[]): string;
86
88
  declare function formatClientContext(context?: ClientContext): string;
87
89
  /** Full prompt for providers without a native --system-prompt flag (Cursor). */
88
- declare function buildCursorPrompt(systemPrompt: string, messages: ChatMessage[], context?: ClientContext, attachmentPaths?: string[], priorConversationsContext?: string): string;
90
+ declare function buildCursorPrompt(systemPrompt: string, messages: ChatMessage[], context?: ClientContext, attachmentPaths?: string[], priorConversationsContext?: string, technical?: boolean): string;
89
91
  /** Conversation + context for Claude (system prompt passed separately). */
90
- declare function buildClaudeUserPrompt(messages: ChatMessage[], context?: ClientContext, attachmentPaths?: string[], priorConversationsContext?: string): string;
92
+ declare function buildClaudeUserPrompt(messages: ChatMessage[], context?: ClientContext, attachmentPaths?: string[], priorConversationsContext?: string, technical?: boolean): string;
91
93
 
92
94
  declare function createDefaultSystemPrompt(input: {
93
95
  productDescription: string;
@@ -111,6 +113,8 @@ declare function createToolValidator(schemas: ToolSchemaMap): (toolCall: ToolCal
111
113
  error: string;
112
114
  };
113
115
 
116
+ /** Stored on assistant rows while a single in-flight AI turn is running. */
117
+ declare const WORKING_PROVIDER = "working";
114
118
  /** Minimal persistence contract (file store, SQL, or Maintainer Pro). */
115
119
  interface ChatStoreLike {
116
120
  ensureConversation(id: string): Promise<void>;
@@ -133,6 +137,8 @@ interface ChatStoreLike {
133
137
  senderType?: "client" | "developer" | "ai" | null;
134
138
  senderName?: string | null;
135
139
  }>>;
140
+ /** Remove in-progress working placeholders (at most one per conversation). */
141
+ clearWorkingMessages?(conversationId: string): Promise<void>;
136
142
  /** All conversations under the store (for prior-chat context). */
137
143
  listConversations?(): Promise<Array<{
138
144
  id: string;
@@ -141,6 +147,7 @@ interface ChatStoreLike {
141
147
  role: string;
142
148
  content: string;
143
149
  createdAt?: string | Date;
150
+ provider?: string | null;
144
151
  }>;
145
152
  }>>;
146
153
  saveToolEvents?(conversationId: string, events: Array<{
@@ -236,4 +243,39 @@ interface PriorConversationOptions {
236
243
  */
237
244
  declare function buildPriorConversationsContext(db: ChatStoreLike, options: PriorConversationOptions): Promise<string>;
238
245
 
239
- export { type AiCliProviderId, type AiProvider, type AiResponse, type CallAiOptions, type ChatAttachment, type ChatHandlerOptions, type ChatHandlers, type ChatMessage, type ChatSenderType, type ChatStoreLike, type ChatUser, type ClientContext, type LocalStoredMessage, type MaintainerProStoreOptions, type PriorConversationOptions, type ProviderPreference, type ToolCall, type ToolSchemaMap, buildClaudeUserPrompt, buildConversationPrompt, buildCursorPrompt, buildPriorConversationsContext, callAi, commandExists, createAntigravityProvider, createBuiltinProviders, createChatHandler, createClaudeProvider, createCursorProvider, createDefaultSystemPrompt, createLocalDirectoryStore, createMaintainerProStore, createMaintainerProStoreFromEnv, createToolValidator, formatClientContext, getProviderPreference, parseAiResponse, providerLabel, resolveCliBinary, resolveProvider, saveChatAttachments, toNextRoute };
246
+ type WorkspaceKind = "next" | "vite" | "html" | "empty" | "other";
247
+ interface WorkspaceInspectInput {
248
+ workspaceDir: string;
249
+ appName?: string;
250
+ /** When set, the agent diagnoses and tries to fix this failure. */
251
+ problem?: string;
252
+ extraContext?: string;
253
+ }
254
+ interface WorkspaceInspectResult {
255
+ kind: WorkspaceKind;
256
+ name: string;
257
+ summary: string;
258
+ scripts: {
259
+ ui?: string;
260
+ backend?: string;
261
+ app?: string;
262
+ };
263
+ ports: {
264
+ ui?: number;
265
+ backend?: number;
266
+ app?: number;
267
+ };
268
+ issues: string[];
269
+ fixes: string[];
270
+ ready: boolean;
271
+ provider: string;
272
+ rawText: string;
273
+ }
274
+ /**
275
+ * Ask the coding-agent CLI (via callAi) to inspect a workspace and optionally
276
+ * repair setup issues. Used by ai-bridge so host-process planning uses the
277
+ * same provider resolution as chat.
278
+ */
279
+ declare function inspectAndRepairWorkspace(input: WorkspaceInspectInput): Promise<WorkspaceInspectResult>;
280
+
281
+ export { type AiCliProviderId, type AiProvider, type AiResponse, type CallAiOptions, type ChatAttachment, type ChatHandlerOptions, type ChatHandlers, type ChatMessage, type ChatSenderType, type ChatStoreLike, type ChatUser, type ClientContext, type LocalStoredMessage, type MaintainerProStoreOptions, type PriorConversationOptions, type ProviderPreference, type ToolCall, type ToolSchemaMap, WORKING_PROVIDER, type WorkspaceInspectInput, type WorkspaceInspectResult, type WorkspaceKind, buildClaudeUserPrompt, buildConversationPrompt, buildCursorPrompt, buildPriorConversationsContext, callAi, commandExists, createAntigravityProvider, createBuiltinProviders, createChatHandler, createClaudeProvider, createCursorProvider, createDefaultSystemPrompt, createLocalDirectoryStore, createMaintainerProStore, createMaintainerProStoreFromEnv, createToolValidator, formatClientContext, getProviderPreference, inspectAndRepairWorkspace, parseAiResponse, providerLabel, resolveCliBinary, resolveProvider, saveChatAttachments, toNextRoute };
package/dist/index.js CHANGED
@@ -43,7 +43,7 @@ function splitMessages(messages) {
43
43
  const history = latestUserIndex > 0 ? messages.slice(0, latestUserIndex) : [];
44
44
  return { request, history };
45
45
  }
46
- function buildUserFacingPrompt(systemPrompt, messages, context, attachmentPaths, priorConversationsContext) {
46
+ function buildUserFacingPrompt(systemPrompt, messages, context, attachmentPaths, priorConversationsContext, technical) {
47
47
  const { request, history } = splitMessages(messages);
48
48
  const historyBlock = history.length > 0 ? `Conversation so far:
49
49
  ${buildConversationPrompt(history)}
@@ -63,27 +63,30 @@ ${buildConversationPrompt(history)}
63
63
  ${attachmentPaths.map((p) => `- ${p}`).join("\n")}
64
64
 
65
65
  ` : "";
66
+ const closer = technical ? "Do the work now. Follow the requested output format exactly." : "Do the work now (or ask one short clarifying question if needed). Reply in English, non-technical and user-facing. If it's not resolving, offer a quick call.";
66
67
  return `${systemSection}${contextSection}${priorSection}${historyBlock}${attachmentsSection}Current user request:
67
68
  ${request || "(See the attached screenshot(s).)"}
68
69
 
69
- Do the work now (or ask one short clarifying question if needed). Reply in English, non-technical and user-facing. If it's not resolving, offer a quick call.`;
70
+ ${closer}`;
70
71
  }
71
- function buildCursorPrompt(systemPrompt, messages, context, attachmentPaths, priorConversationsContext) {
72
+ function buildCursorPrompt(systemPrompt, messages, context, attachmentPaths, priorConversationsContext, technical) {
72
73
  return buildUserFacingPrompt(
73
74
  systemPrompt,
74
75
  messages,
75
76
  context,
76
77
  attachmentPaths,
77
- priorConversationsContext
78
+ priorConversationsContext,
79
+ technical
78
80
  );
79
81
  }
80
- function buildClaudeUserPrompt(messages, context, attachmentPaths, priorConversationsContext) {
82
+ function buildClaudeUserPrompt(messages, context, attachmentPaths, priorConversationsContext, technical) {
81
83
  return buildUserFacingPrompt(
82
84
  null,
83
85
  messages,
84
86
  context,
85
87
  attachmentPaths,
86
- priorConversationsContext
88
+ priorConversationsContext,
89
+ technical
87
90
  );
88
91
  }
89
92
 
@@ -196,7 +199,8 @@ async function callClaudeCli(command, messages, context, options) {
196
199
  messages,
197
200
  context,
198
201
  options.attachmentPaths,
199
- options.priorConversationsContext
202
+ options.priorConversationsContext,
203
+ options.technical
200
204
  );
201
205
  const resolved = resolveCliCommand("claude", command);
202
206
  const workspace = options.workspaceDir ?? process.env.AI_CLI_WORKSPACE ?? process.cwd();
@@ -235,7 +239,8 @@ async function callCursorCli(command, messages, context, options) {
235
239
  messages,
236
240
  context,
237
241
  options.attachmentPaths,
238
- options.priorConversationsContext
242
+ options.priorConversationsContext,
243
+ options.technical
239
244
  );
240
245
  const resolved = resolveCliCommand("cursor", command);
241
246
  const workspace = options.workspaceDir ?? process.env.AI_CLI_WORKSPACE ?? process.cwd();
@@ -276,7 +281,8 @@ async function callAntigravityCli(command, messages, context, options) {
276
281
  messages,
277
282
  context,
278
283
  options.attachmentPaths,
279
- options.priorConversationsContext
284
+ options.priorConversationsContext,
285
+ options.technical
280
286
  );
281
287
  const prompt = `You are operating as a coding agent with full permission to read and edit files in this workspace. Do not introduce yourself. Do not ask what you are. Execute the user's latest request now (edit files and/or emit runtime tool JSON as instructed).
282
288
 
@@ -533,7 +539,9 @@ async function buildPriorConversationsContext(db, options) {
533
539
  ).slice(0, maxConversations) : ranked;
534
540
  const blocks = selected.map((c) => {
535
541
  const slice = c.messages.length > maxMessages ? c.messages.slice(-maxMessages) : c.messages;
536
- const lines = slice.filter((m) => m.role === "user" || m.role === "assistant").map(
542
+ const lines = slice.filter(
543
+ (m) => (m.role === "user" || m.role === "assistant") && m.provider !== "working" && Boolean(m.content?.trim())
544
+ ).map(
537
545
  (m) => `${m.role === "user" ? "Human" : "Assistant"}: ${truncate(m.content)}`
538
546
  );
539
547
  const when = c.updatedAt ? ` (updated ${c.updatedAt.slice(0, 10)})` : "";
@@ -566,6 +574,18 @@ function createToolValidator(schemas) {
566
574
  }
567
575
 
568
576
  // src/http/handler.ts
577
+ var WORKING_PROVIDER = "working";
578
+ async function setWorkingMessage(db, conversationId) {
579
+ await db.ensureConversation(conversationId);
580
+ await db.clearWorkingMessages?.(conversationId);
581
+ await db.saveMessage({
582
+ conversationId,
583
+ role: "assistant",
584
+ content: "",
585
+ provider: WORKING_PROVIDER,
586
+ senderType: "ai"
587
+ });
588
+ }
569
589
  var SHARED_CONVERSATION_ID = "shared";
570
590
  var conversationTurnSeq = /* @__PURE__ */ new Map();
571
591
  function beginConversationTurn(conversationId) {
@@ -638,6 +658,8 @@ function createChatHandler(options) {
638
658
  }
639
659
  },
640
660
  async POST(request) {
661
+ let trackedConversationId;
662
+ let trackedTurn = 0;
641
663
  try {
642
664
  const body = await request.json();
643
665
  const conversationId = await resolveSharedConversationId(
@@ -645,21 +667,16 @@ function createChatHandler(options) {
645
667
  options,
646
668
  body.conversationId
647
669
  );
648
- if (body.type === "status") {
649
- const content = body.content?.trim();
650
- if (!conversationId || !content) {
670
+ trackedConversationId = conversationId;
671
+ if (body.type === "working-clear") {
672
+ if (!conversationId) {
651
673
  return Response.json(
652
- { error: "conversationId and content are required" },
674
+ { error: "conversationId is required" },
653
675
  { status: 400 }
654
676
  );
655
677
  }
656
- if (options.db) {
657
- await options.db.ensureConversation(conversationId);
658
- await options.db.saveMessage({
659
- conversationId,
660
- role: "assistant",
661
- content
662
- });
678
+ if (options.db?.clearWorkingMessages) {
679
+ await options.db.clearWorkingMessages(conversationId);
663
680
  }
664
681
  return Response.json({ ok: true, conversationId });
665
682
  }
@@ -707,7 +724,8 @@ function createChatHandler(options) {
707
724
  );
708
725
  persistAttachmentPaths = attachmentPaths;
709
726
  }
710
- const turn = beginConversationTurn(conversationId);
727
+ const turn = beginConversationTurn(conversationId ?? SHARED_CONVERSATION_ID);
728
+ trackedTurn = turn;
711
729
  const signal = request.signal;
712
730
  if (options.db && conversationId) {
713
731
  const latest = messages[messages.length - 1];
@@ -723,13 +741,26 @@ function createChatHandler(options) {
723
741
  senderName: body.senderName?.trim() || void 0
724
742
  });
725
743
  }
744
+ await setWorkingMessage(options.db, conversationId);
726
745
  }
727
746
  const latestUser = [...messages].reverse().find((m) => m.role === "user");
728
747
  const priorConversationsContext = options.db ? await buildPriorConversationsContext(options.db, {
729
748
  excludeConversationId: conversationId,
730
749
  currentRequest: latestUser?.content ?? ""
731
750
  }) : "";
732
- if (signal.aborted || !isActiveConversationTurn(conversationId, turn)) {
751
+ if (!isActiveConversationTurn(
752
+ conversationId ?? SHARED_CONVERSATION_ID,
753
+ turn
754
+ )) {
755
+ return Response.json({
756
+ superseded: true,
757
+ conversationId: conversationId ?? null
758
+ });
759
+ }
760
+ if (signal.aborted) {
761
+ if (options.db?.clearWorkingMessages && conversationId) {
762
+ await options.db.clearWorkingMessages(conversationId);
763
+ }
733
764
  return Response.json({
734
765
  superseded: true,
735
766
  conversationId: conversationId ?? null
@@ -740,7 +771,10 @@ function createChatHandler(options) {
740
771
  attachmentPaths,
741
772
  priorConversationsContext: priorConversationsContext || void 0
742
773
  });
743
- if (!isActiveConversationTurn(conversationId, turn)) {
774
+ if (!isActiveConversationTurn(
775
+ conversationId ?? SHARED_CONVERSATION_ID,
776
+ turn
777
+ )) {
744
778
  return Response.json({
745
779
  superseded: true,
746
780
  conversationId: conversationId ?? null
@@ -752,6 +786,7 @@ function createChatHandler(options) {
752
786
  }
753
787
  if (options.db && conversationId) {
754
788
  await options.db.ensureConversation(conversationId);
789
+ await options.db.clearWorkingMessages?.(conversationId);
755
790
  await options.db.saveMessage({
756
791
  conversationId,
757
792
  role: "assistant",
@@ -775,6 +810,11 @@ function createChatHandler(options) {
775
810
  });
776
811
  } catch (err) {
777
812
  console.error("Chat API error:", err);
813
+ if (trackedConversationId && options.db?.clearWorkingMessages && isActiveConversationTurn(trackedConversationId, trackedTurn)) {
814
+ await options.db.clearWorkingMessages(trackedConversationId).catch(
815
+ () => void 0
816
+ );
817
+ }
778
818
  return Response.json(
779
819
  {
780
820
  error: "Sorry about that \u2014 I couldn't finish this right now. Happy to jump on a quick call if you'd like help \u2014 just let us know when works."
@@ -844,6 +884,16 @@ function createLocalDirectoryStore(baseDir) {
844
884
  await writeConversation(filePath, existing);
845
885
  return message;
846
886
  },
887
+ async clearWorkingMessages(conversationId) {
888
+ const filePath = fileFor(conversationId);
889
+ const existing = await readConversation(filePath);
890
+ if (!existing) return;
891
+ const next = existing.messages.filter((m) => m.provider !== "working");
892
+ if (next.length === existing.messages.length) return;
893
+ existing.messages = next;
894
+ existing.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
895
+ await writeConversation(filePath, existing);
896
+ },
847
897
  async listMessages(conversationId) {
848
898
  const existing = await readConversation(fileFor(conversationId));
849
899
  return existing?.messages ?? [];
@@ -950,6 +1000,15 @@ function createMaintainerProStore(options) {
950
1000
  );
951
1001
  return data.message;
952
1002
  },
1003
+ async clearWorkingMessages(conversationId) {
1004
+ await mpFetch(
1005
+ baseUrl,
1006
+ apiKey,
1007
+ `/api/v1/store/conversations/${encodeURIComponent(conversationId)}/messages?provider=working`,
1008
+ { method: "DELETE" },
1009
+ fetchImpl
1010
+ );
1011
+ },
953
1012
  async listMessages(conversationId) {
954
1013
  const data = await mpFetch(
955
1014
  baseUrl,
@@ -1029,7 +1088,114 @@ function createMaintainerProStoreFromEnv() {
1029
1088
  if (!baseUrl || !apiKey) return null;
1030
1089
  return createMaintainerProStore({ baseUrl, apiKey });
1031
1090
  }
1091
+
1092
+ // src/workspace-inspect.ts
1093
+ import { z } from "zod";
1094
+ var inspectJsonSchema = z.object({
1095
+ kind: z.enum(["next", "vite", "html", "empty", "other"]).optional(),
1096
+ name: z.string().max(200).optional(),
1097
+ summary: z.string().max(800).optional(),
1098
+ scripts: z.object({
1099
+ ui: z.string().min(1).max(64).optional(),
1100
+ backend: z.string().min(1).max(64).optional(),
1101
+ app: z.string().min(1).max(64).optional()
1102
+ }).optional(),
1103
+ ports: z.object({
1104
+ ui: z.number().int().min(1).max(65535).optional(),
1105
+ backend: z.number().int().min(1).max(65535).optional(),
1106
+ app: z.number().int().min(1).max(65535).optional()
1107
+ }).optional(),
1108
+ issues: z.array(z.string().max(500)).max(20).optional(),
1109
+ fixes: z.array(z.string().max(500)).max(20).optional(),
1110
+ ready: z.boolean().optional()
1111
+ });
1112
+ function extractInspectJson(text) {
1113
+ const fence = text.match(/```json\s*([\s\S]*?)```/i);
1114
+ const raw = fence?.[1]?.trim() || text.match(/\{[\s\S]*\}/)?.[0];
1115
+ if (!raw) return null;
1116
+ try {
1117
+ const parsed = inspectJsonSchema.safeParse(JSON.parse(raw));
1118
+ return parsed.success ? parsed.data : null;
1119
+ } catch {
1120
+ return null;
1121
+ }
1122
+ }
1123
+ function setupSystemPrompt(input) {
1124
+ return `You are Maintainer Pro's local setup assistant on the developer's machine.
1125
+
1126
+ Workspace: ${input.workspaceDir}
1127
+ App name: ${input.appName || "the app"}
1128
+
1129
+ Inspect this repository (package.json, README, lockfiles, existing env files, how the UI and API start).
1130
+ You MAY edit files to fix setup problems that would block local run or the chat widget (env keys, widget mount, missing config).
1131
+ Do NOT start long-lived dev servers, tunnels, or install global tools.
1132
+ Do NOT run \`npm run dev\` or similar.
1133
+
1134
+ Script names you report MUST already exist in package.json. Omit chat/ai-server scripts.
1135
+
1136
+ End with a short human summary AND one \`\`\`json fence with:
1137
+
1138
+ {
1139
+ "kind": "next" | "vite" | "html" | "empty" | "other",
1140
+ "name": "package or folder name",
1141
+ "summary": "one sentence about this project",
1142
+ "scripts": { "ui": "dev:client", "backend": "dev:server" },
1143
+ "ports": { "ui": 5173, "backend": 4100 },
1144
+ "issues": ["remaining problems"],
1145
+ "fixes": ["what you changed"],
1146
+ "ready": true
1147
+ }`;
1148
+ }
1149
+ function setupUserMessage(input) {
1150
+ if (input.problem?.trim()) {
1151
+ return `The local bridge failed to keep this project running:
1152
+
1153
+ ${input.problem.trim()}
1154
+
1155
+ ${input.extraContext ? `${input.extraContext.trim()}
1156
+
1157
+ ` : ""}Inspect the repo, fix the cause if you can, and return the JSON report.`;
1158
+ }
1159
+ return `${input.extraContext ? `${input.extraContext.trim()}
1160
+
1161
+ ` : ""}Inspect this project, fix any setup gaps you can, and return the JSON report.`;
1162
+ }
1163
+ async function inspectAndRepairWorkspace(input) {
1164
+ const workspaceDir = input.workspaceDir;
1165
+ const provider = await resolveProvider({ preference: "auto" });
1166
+ const response = await callAi(
1167
+ [{ role: "user", content: setupUserMessage(input) }],
1168
+ {
1169
+ route: "/local-setup",
1170
+ pageTitle: input.appName || "Local setup",
1171
+ relevantFiles: ["package.json", "README.md", ".env", ".env.example"],
1172
+ data: {
1173
+ workspaceDir,
1174
+ problem: input.problem || null
1175
+ }
1176
+ },
1177
+ {
1178
+ systemPrompt: setupSystemPrompt(input),
1179
+ workspaceDir,
1180
+ technical: true
1181
+ }
1182
+ );
1183
+ const parsed = extractInspectJson(response.text);
1184
+ return {
1185
+ kind: parsed?.kind ?? "other",
1186
+ name: parsed?.name?.trim() || input.appName || "",
1187
+ summary: parsed?.summary?.trim() || response.text.replace(/```json[\s\S]*```/i, "").trim().slice(0, 400),
1188
+ scripts: parsed?.scripts ?? {},
1189
+ ports: parsed?.ports ?? {},
1190
+ issues: parsed?.issues ?? [],
1191
+ fixes: parsed?.fixes ?? [],
1192
+ ready: parsed?.ready ?? parsed?.issues?.length === 0,
1193
+ provider: response.provider || provider.id,
1194
+ rawText: response.text
1195
+ };
1196
+ }
1032
1197
  export {
1198
+ WORKING_PROVIDER,
1033
1199
  buildClaudeUserPrompt,
1034
1200
  buildConversationPrompt,
1035
1201
  buildCursorPrompt,
@@ -1048,6 +1214,7 @@ export {
1048
1214
  createToolValidator,
1049
1215
  formatClientContext,
1050
1216
  getProviderPreference,
1217
+ inspectAndRepairWorkspace,
1051
1218
  parseAiResponse,
1052
1219
  providerLabel,
1053
1220
  resolveCliBinary,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@maintainer-pro/ai-cli",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "description": "Maintainer Pro server SDK: Claude/Cursor CLI, chat HTTP handlers, and optional SQL persistence",
5
5
  "keywords": [
6
6
  "maintainer-pro",