os-dpt 0.0.1 → 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.
@@ -1531,17 +1531,20 @@ async function streamAssistantMessage(p) {
1531
1531
  }
1532
1532
  },
1533
1533
  async (span) => {
1534
- const stream = client.messages.stream({
1535
- model,
1536
- max_tokens: maxTokens,
1537
- system: cachedSystem(p.system),
1538
- messages: withConversationCacheBreakpoint(p.messages),
1539
- tools: p.tools,
1540
- // One tool_use per assistant turn so the loop can cleanly handle the
1541
- // ask_user_question pause without leaving sibling tool_use blocks
1542
- // without matching tool_results.
1543
- tool_choice: { type: "auto", disable_parallel_tool_use: true }
1544
- });
1534
+ const stream = client.messages.stream(
1535
+ {
1536
+ model,
1537
+ max_tokens: maxTokens,
1538
+ system: cachedSystem(p.system),
1539
+ messages: withConversationCacheBreakpoint(p.messages),
1540
+ tools: p.tools,
1541
+ // One tool_use per assistant turn so the loop can cleanly handle the
1542
+ // ask_user_question pause without leaving sibling tool_use blocks
1543
+ // without matching tool_results.
1544
+ tool_choice: { type: "auto", disable_parallel_tool_use: true }
1545
+ },
1546
+ { signal: p.signal }
1547
+ );
1545
1548
  if (p.onTextDelta) {
1546
1549
  stream.on("text", (delta) => p.onTextDelta?.(delta));
1547
1550
  }
@@ -2018,6 +2021,7 @@ function costFromUsage(model, usage) {
2018
2021
 
2019
2022
  // server/agent/prompt.ts
2020
2023
  function buildSystemPrompt(session) {
2024
+ if (session.meta.mode === "quick-edit") return buildQuickEditPrompt(session);
2021
2025
  const { worksheetSlug, connectionId } = session.meta;
2022
2026
  const bound = !!worksheetSlug;
2023
2027
  const lines = [
@@ -2059,7 +2063,7 @@ function buildSystemPrompt(session) {
2059
2063
  "- run_sql results are capped to 50 rows by default; add LIMIT or aggregation if you need a broader view.",
2060
2064
  "- run_sql executes whatever SQL you pass, including DDL and DML, against the role the user connected with. Treat exploration as read-only \u2014 use SELECT. Before any INSERT/UPDATE/DELETE/TRUNCATE/CREATE/DROP/ALTER, call ask_user_question to confirm.",
2061
2065
  "- ask_user_question pauses the loop entirely \u2014 only one question per call, and use it sparingly.",
2062
- "- render_chart draws a chart inline in the chat. After run_sql, when a picture beats a table (trends \u2192 line/area, comparisons \u2192 bar, proportions \u2192 pie), call it with pre-aggregated rows. Pass the data inline, shaped to small row objects (e.g. {month, revenue}); don't dump raw wide rows."
2066
+ "- render_chart draws a chart inline in the chat. After run_sql, when a picture beats a table (trends \u2192 line/area, comparisons \u2192 bar, part-to-whole \u2192 stacked-bar, proportions \u2192 pie, stage drop-off \u2192 funnel), call it with pre-aggregated rows. Pass the data inline, shaped to small row objects (e.g. {month, revenue}); don't dump raw wide rows."
2063
2067
  );
2064
2068
  const ctx = [];
2065
2069
  if (worksheetSlug) ctx.push(`- Active worksheet: ${worksheetSlug}`);
@@ -2074,6 +2078,33 @@ function buildSystemPrompt(session) {
2074
2078
  }
2075
2079
  return lines.join("\n");
2076
2080
  }
2081
+ function buildQuickEditPrompt(session) {
2082
+ const { worksheetSlug, connectionId } = session.meta;
2083
+ const lines = [
2084
+ "You are os-dpt's inline SQL-editing agent. The user prompts you from a small floating box inside their SQL editor. Your ONLY deliverable is SQL staged into their worksheet via write_sql \u2014 the user never reads chat output in this mode, they watch the editor update.",
2085
+ "",
2086
+ "Output rules (hard requirements):",
2087
+ "- Emit NO prose. No preamble, no explanation, no closing summary \u2014 no text blocks at all.",
2088
+ "- Anything worth flagging (an assumption you made, a caveat, a data-quality quirk) goes in a SQL comment inside the query itself.",
2089
+ "- Always finish by calling write_sql with the COMPLETE worksheet contents (drafts are overwrites, not patches), then end the turn.",
2090
+ "",
2091
+ "Method:",
2092
+ "- Each user message ends with the worksheet's current contents. Treat the request as an edit to that SQL unless it clearly asks for something new; preserve parts of the worksheet the request doesn't touch.",
2093
+ "- Check get_context first when the request depends on schema, business terms, or conventions you have not verified this session; call get_schema when tables or columns are uncertain. Do not guess names.",
2094
+ "- Verify your SQL with run_sql before staging it. If it errors, fix and re-run until it works. Treat verification as read-only \u2014 SELECT only; never run DDL or DML in this mode.",
2095
+ "- If no connection is bound or verification is impossible, stage your best SQL with a leading '-- not verified' comment instead of stopping.",
2096
+ "- You cannot ask the user questions in this mode. Make the most reasonable assumption and record it as a SQL comment.",
2097
+ "- Persist durable knowledge via update_context the moment you learn it: table/column meanings \u2192 schemas.md; corrections or errors that revealed a fact \u2192 feedback.md; standing rules and business terms \u2192 conventions.md.",
2098
+ "- run_sql results are capped to 50 rows by default; add LIMIT or aggregation if you need a broader view."
2099
+ ];
2100
+ const ctx = [];
2101
+ if (worksheetSlug) ctx.push(`- Active worksheet: ${worksheetSlug}`);
2102
+ if (connectionId) ctx.push(`- Active connection: ${connectionId}`);
2103
+ if (ctx.length > 0) {
2104
+ lines.push("", "Current bindings:", ...ctx);
2105
+ }
2106
+ return lines.join("\n");
2107
+ }
2077
2108
 
2078
2109
  // server/agent/session.ts
2079
2110
  import { promises as fs14 } from "node:fs";
@@ -2085,6 +2116,7 @@ function freshMeta(input) {
2085
2116
  const now = nowIso();
2086
2117
  return {
2087
2118
  id: crypto4.randomUUID(),
2119
+ mode: input.mode ?? "chat",
2088
2120
  createdAt: now,
2089
2121
  updatedAt: now,
2090
2122
  title: input.title ?? null,
@@ -2102,6 +2134,9 @@ function ensureUsageFields(session) {
2102
2134
  if (!Array.isArray(session.meta.usage)) session.meta.usage = [];
2103
2135
  if (!session.meta.totals) session.meta.totals = emptyTotals();
2104
2136
  if (typeof session.meta.standalone !== "boolean") session.meta.standalone = false;
2137
+ if (session.meta.mode !== "chat" && session.meta.mode !== "quick-edit") {
2138
+ session.meta.mode = "chat";
2139
+ }
2105
2140
  if (typeof session.meta.titleGenerated !== "boolean") {
2106
2141
  session.meta.titleGenerated = session.meta.title != null;
2107
2142
  }
@@ -2141,6 +2176,19 @@ async function deleteChat(id) {
2141
2176
  assertSafeChatId(id);
2142
2177
  await fs14.rm(paths.chat(id), { force: true });
2143
2178
  }
2179
+ var QUICK_EDIT_MAX_TURNS = 8;
2180
+ var QUICK_EDIT_KEEP_TURNS = 4;
2181
+ function trimQuickEditHistory(session) {
2182
+ if (session.meta.mode !== "quick-edit") return;
2183
+ const turnStarts = [];
2184
+ session.messages.forEach((m, i) => {
2185
+ if (m.role === "user" && typeof m.content === "string") turnStarts.push(i);
2186
+ });
2187
+ if (turnStarts.length <= QUICK_EDIT_MAX_TURNS) return;
2188
+ session.messages = session.messages.slice(
2189
+ turnStarts[turnStarts.length - QUICK_EDIT_KEEP_TURNS]
2190
+ );
2191
+ }
2144
2192
  async function appendMessage(session, message) {
2145
2193
  session.messages.push(message);
2146
2194
  session.meta.updatedAt = nowIso();
@@ -2242,6 +2290,32 @@ import { promises as fs16 } from "node:fs";
2242
2290
  function isContextFile(v) {
2243
2291
  return CONTEXT_FILES.includes(v);
2244
2292
  }
2293
+ var DETAIL_CONTEXT_LINES = 8;
2294
+ function toLines(text) {
2295
+ return text === "" ? [] : text.replace(/\n$/, "").split("\n");
2296
+ }
2297
+ function detailSnapshots(before, after) {
2298
+ const a = toLines(before);
2299
+ const b = toLines(after);
2300
+ let prefix = 0;
2301
+ while (prefix < a.length && prefix < b.length && a[prefix] === b[prefix]) prefix++;
2302
+ let suffix = 0;
2303
+ while (suffix < a.length - prefix && suffix < b.length - prefix && a[a.length - 1 - suffix] === b[b.length - 1 - suffix]) {
2304
+ suffix++;
2305
+ }
2306
+ const leading = Math.max(0, prefix - DETAIL_CONTEXT_LINES);
2307
+ const trailing = Math.max(0, suffix - DETAIL_CONTEXT_LINES);
2308
+ if (leading === 0 && trailing === 0) return { before, after };
2309
+ let fences = 0;
2310
+ for (let i = 0; i < leading; i++) {
2311
+ if (/^\s{0,3}(```|~~~)/.test(a[i])) fences++;
2312
+ }
2313
+ return {
2314
+ before: a.slice(leading, a.length - trailing).join("\n"),
2315
+ after: b.slice(leading, b.length - trailing).join("\n"),
2316
+ trimmed: { leading, trailing, ...fences % 2 === 1 ? { inFence: true } : {} }
2317
+ };
2318
+ }
2245
2319
  var updateContextTool = {
2246
2320
  name: "update_context",
2247
2321
  description: "Write to one of the agent's context files (schemas.md, conventions.md, feedback.md). Call this the moment you learn something durable \u2014 without waiting to be asked: a clarified schema fact or value set, a project convention, a user correction, a data-quality quirk, or an error pattern from running SQL. Saving is cheap and git-tracked; prefer over-saving to losing a finding. Prefer 'append' for new findings; use 'replace' only when restructuring an entire file. Writes are scoped to the data source bound to this chat; with no connection bound they go to the workspace-level (unassigned) docs.",
@@ -2290,16 +2364,16 @@ var updateContextTool = {
2290
2364
  };
2291
2365
  }
2292
2366
  const target = paths.contextFile(input.file, connectionId);
2367
+ let current = "";
2368
+ try {
2369
+ current = await fs16.readFile(target, "utf8");
2370
+ } catch (err) {
2371
+ if (err.code !== "ENOENT") throw err;
2372
+ }
2293
2373
  let next;
2294
2374
  if (input.mode === "replace") {
2295
2375
  next = input.content.endsWith("\n") ? input.content : input.content + "\n";
2296
2376
  } else {
2297
- let current = "";
2298
- try {
2299
- current = await fs16.readFile(target, "utf8");
2300
- } catch (err) {
2301
- if (err.code !== "ENOENT") throw err;
2302
- }
2303
2377
  const stamp = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
2304
2378
  const block = `## ${stamp}
2305
2379
 
@@ -2311,7 +2385,8 @@ ${input.content.trim()}
2311
2385
  return {
2312
2386
  toolResult: `Wrote ${next.length} bytes to context/${input.file}.md (${input.mode})`,
2313
2387
  isError: false,
2314
- uiSummary: `Updated context/${input.file}.md (${input.mode})`
2388
+ uiSummary: `Updated context/${input.file}.md (${input.mode})`,
2389
+ detail: { file: `${input.file}.md`, mode: input.mode, ...detailSnapshots(current, next) }
2315
2390
  };
2316
2391
  }
2317
2392
  };
@@ -2550,9 +2625,13 @@ var runSqlTool = {
2550
2625
  description: "Execute SQL against the active database connection and return the result. Use this to validate queries you've written and to inspect data. If the query errors, capture what you learned by calling update_context against feedback.md so future runs avoid the same mistake. Row results are capped (default 50, max 500) \u2014 add LIMIT or aggregation if you need a wider view. IMPORTANT: this tool executes the SQL verbatim against whatever role the connection uses. Connections default to read-only, which rejects anything but a single read statement (SELECT/WITH/SHOW/EXPLAIN/TABLE/VALUES). On a read-write connection it will also run DDL (CREATE/DROP/ALTER) and DML (INSERT/UPDATE/DELETE/TRUNCATE), so before running anything that mutates data or schema, call ask_user_question to confirm.",
2551
2626
  input_schema: {
2552
2627
  type: "object",
2553
- required: ["sql"],
2628
+ required: ["sql", "name"],
2554
2629
  properties: {
2555
2630
  sql: { type: "string", description: "SQL to execute." },
2631
+ name: {
2632
+ type: "string",
2633
+ description: 'A short human-readable name for this query (3\u20136 words) describing what it does, e.g. "Monthly revenue by region". Shown in the UI next to the query.'
2634
+ },
2556
2635
  connection_id: {
2557
2636
  type: "string",
2558
2637
  description: "Connection UUID. Defaults to the chat's bound connection."
@@ -2623,7 +2702,7 @@ var runSqlTool = {
2623
2702
  };
2624
2703
 
2625
2704
  // server/agent/tools/render_chart.ts
2626
- var CHART_TYPES = ["bar", "line", "area", "pie"];
2705
+ var CHART_TYPES = ["bar", "stacked-bar", "line", "area", "pie", "funnel"];
2627
2706
  var MAX_ROWS2 = 500;
2628
2707
  var MAX_SERIES = 8;
2629
2708
  function parseSeries(raw) {
@@ -2664,7 +2743,7 @@ function parseData(raw) {
2664
2743
  }
2665
2744
  var renderChartTool = {
2666
2745
  name: "render_chart",
2667
- description: `Render a chart inline in the chat to visualize query results. Supply the data rows directly (shaped from your run_sql results) along with the chart spec \u2014 the chart is self-contained. Use this after run_sql when a visualization communicates the answer better than a table: trends over time (line/area), category comparisons (bar), or proportions (pie). Keep data small and pre-aggregated (cap ${MAX_ROWS2} rows). Each data row is an object keyed by column name, e.g. \`[{ "month": "Jan", "revenue": 120 }, \u2026]\`. \`x\` names the category column; \`series\` names the numeric column(s) to plot. For pie charts only the first series is used.`,
2746
+ description: `Render a chart inline in the chat to visualize query results. Supply the data rows directly (shaped from your run_sql results) along with the chart spec \u2014 the chart is self-contained. Use this after run_sql when a visualization communicates the answer better than a table: trends over time (line/area), category comparisons (bar), part-to-whole across categories (stacked-bar), proportions (pie), or stage-by-stage drop-off like a conversion pipeline (funnel). Keep data small and pre-aggregated (cap ${MAX_ROWS2} rows). Each data row is an object keyed by column name, e.g. \`[{ "month": "Jan", "revenue": 120 }, \u2026]\`. \`x\` names the category column; \`series\` names the numeric column(s) to plot. For pie and funnel charts only the first series is used; for funnel, order rows from the widest stage to the narrowest. Pass \`sourceQuery\` \u2014 the \`name\` you gave the run_sql call the data came from \u2014 so the chart links back to its query.`,
2668
2747
  input_schema: {
2669
2748
  type: "object",
2670
2749
  required: ["type", "x", "series", "data"],
@@ -2672,7 +2751,7 @@ var renderChartTool = {
2672
2751
  type: {
2673
2752
  type: "string",
2674
2753
  enum: CHART_TYPES,
2675
- description: "Chart kind: bar, line, area, or pie."
2754
+ description: "Chart kind: bar, stacked-bar, line, area, pie, or funnel."
2676
2755
  },
2677
2756
  title: { type: "string", description: "Optional title shown above the chart." },
2678
2757
  x: {
@@ -2695,6 +2774,10 @@ var renderChartTool = {
2695
2774
  type: "array",
2696
2775
  description: "Row objects to chart, keyed by column name.",
2697
2776
  items: { type: "object" }
2777
+ },
2778
+ sourceQuery: {
2779
+ type: "string",
2780
+ description: "The `name` of the run_sql call whose results this chart plots. Lets the UI link the chart back to its source query."
2698
2781
  }
2699
2782
  }
2700
2783
  },
@@ -2727,7 +2810,8 @@ var renderChartTool = {
2727
2810
  x: input.x,
2728
2811
  series,
2729
2812
  data,
2730
- ...typeof input.title === "string" && input.title.trim() !== "" ? { title: input.title } : {}
2813
+ ...typeof input.title === "string" && input.title.trim() !== "" ? { title: input.title } : {},
2814
+ ...typeof input.sourceQuery === "string" && input.sourceQuery.trim() !== "" ? { sourceQuery: input.sourceQuery.trim() } : {}
2731
2815
  };
2732
2816
  const seriesLabel = series.map((s) => s.label ?? s.key).join(", ");
2733
2817
  return {
@@ -2756,8 +2840,14 @@ function findTool(name) {
2756
2840
  return TOOL_BY_NAME[name];
2757
2841
  }
2758
2842
  function anthropicToolDefs(opts = {}) {
2759
- const { worksheetBound = true } = opts;
2760
- return TOOLS.filter((t) => worksheetBound || t.name !== "write_sql").map((t) => ({
2843
+ const { worksheetBound = true, mode = "chat" } = opts;
2844
+ const withheld = /* @__PURE__ */ new Set();
2845
+ if (!worksheetBound) withheld.add("write_sql");
2846
+ if (mode === "quick-edit") {
2847
+ withheld.add("render_chart");
2848
+ withheld.add("ask_user_question");
2849
+ }
2850
+ return TOOLS.filter((t) => !withheld.has(t.name)).map((t) => ({
2761
2851
  name: t.name,
2762
2852
  description: t.description,
2763
2853
  input_schema: t.input_schema
@@ -2820,7 +2910,7 @@ async function runAgentTurn(opts) {
2820
2910
  );
2821
2911
  }
2822
2912
  async function runTurn(opts) {
2823
- const { session, emit } = opts;
2913
+ const { session, emit, signal } = opts;
2824
2914
  let apiKey;
2825
2915
  try {
2826
2916
  apiKey = await getAnthropicKey();
@@ -2829,8 +2919,12 @@ async function runTurn(opts) {
2829
2919
  return;
2830
2920
  }
2831
2921
  const system = buildSystemPrompt(session);
2832
- const tools = anthropicToolDefs({ worksheetBound: !!session.meta.worksheetSlug });
2922
+ const tools = anthropicToolDefs({
2923
+ worksheetBound: !!session.meta.worksheetSlug,
2924
+ mode: session.meta.mode
2925
+ });
2833
2926
  for (let step = 0; step < MAX_STEPS; step += 1) {
2927
+ if (signal?.aborted) return;
2834
2928
  let final;
2835
2929
  let model;
2836
2930
  let usage;
@@ -2841,6 +2935,7 @@ async function runTurn(opts) {
2841
2935
  system,
2842
2936
  tools,
2843
2937
  messages: session.messages,
2938
+ signal,
2844
2939
  onTextDelta: (delta) => {
2845
2940
  textEmitChain = textEmitChain.then(
2846
2941
  () => Promise.resolve(emit({ type: "text_delta", text: delta }))
@@ -2852,6 +2947,7 @@ async function runTurn(opts) {
2852
2947
  model = result.model;
2853
2948
  usage = result.usage;
2854
2949
  } catch (err) {
2950
+ if (signal?.aborted) return;
2855
2951
  await emit({ type: "error", message: err.message });
2856
2952
  return;
2857
2953
  }
@@ -2892,6 +2988,15 @@ async function runTurn(opts) {
2892
2988
  });
2893
2989
  continue;
2894
2990
  }
2991
+ if (signal?.aborted) {
2992
+ resultBlocks.push({
2993
+ type: "tool_result",
2994
+ tool_use_id: block.id,
2995
+ content: "Canceled: the user aborted this run before the tool executed.",
2996
+ is_error: true
2997
+ });
2998
+ continue;
2999
+ }
2895
3000
  await emit({
2896
3001
  type: "tool_start",
2897
3002
  toolUseId: block.id,
@@ -2940,7 +3045,8 @@ async function runTurn(opts) {
2940
3045
  toolUseId: block.id,
2941
3046
  name: tool.name,
2942
3047
  ok: !execution.isError,
2943
- summary: execution.uiSummary
3048
+ summary: execution.uiSummary,
3049
+ ...execution.detail ? { detail: execution.detail } : {}
2944
3050
  });
2945
3051
  resultBlocks.push({
2946
3052
  type: "tool_result",
@@ -2976,7 +3082,7 @@ async function runTurn(opts) {
2976
3082
  });
2977
3083
  }
2978
3084
  async function resumeWithAnswer(opts) {
2979
- const { session, userAnswer, emit } = opts;
3085
+ const { session, userAnswer, emit, signal } = opts;
2980
3086
  const pending = session.meta.pending;
2981
3087
  if (!pending) {
2982
3088
  await emit({ type: "error", message: "No pending question to resume." });
@@ -2993,7 +3099,7 @@ async function resumeWithAnswer(opts) {
2993
3099
  }
2994
3100
  await persistSession(session);
2995
3101
  await clearPending(session);
2996
- await runAgentTurn({ session, emit });
3102
+ await runAgentTurn({ session, emit, signal });
2997
3103
  }
2998
3104
 
2999
3105
  // server/api/agent.ts
@@ -3055,7 +3161,8 @@ app6.post("/sessions", async (c) => {
3055
3161
  worksheetSlug: body.worksheetSlug ?? null,
3056
3162
  connectionId: body.connectionId ?? null,
3057
3163
  title: body.title ?? null,
3058
- standalone: body.standalone ?? false
3164
+ standalone: body.standalone ?? false,
3165
+ mode: body.mode === "quick-edit" ? "quick-edit" : "chat"
3059
3166
  });
3060
3167
  return c.json(session, 201);
3061
3168
  });
@@ -3149,7 +3256,9 @@ app6.post("/sessions/:id/messages", async (c) => {
3149
3256
  );
3150
3257
  }
3151
3258
  return streamSSE(c, async (stream) => {
3259
+ const signal = c.req.raw.signal;
3152
3260
  const emit = async (event) => {
3261
+ if (signal.aborted) return;
3153
3262
  await stream.writeSSE({ data: JSON.stringify(event) });
3154
3263
  };
3155
3264
  try {
@@ -3166,11 +3275,12 @@ app6.post("/sessions/:id/messages", async (c) => {
3166
3275
  });
3167
3276
  return;
3168
3277
  }
3278
+ trimQuickEditHistory(session);
3169
3279
  await appendMessage(session, { role: "user", content: message });
3170
3280
  if (!session.meta.title) {
3171
3281
  await setTitle(session, message.slice(0, 60));
3172
3282
  }
3173
- await runAgentTurn({ session, emit });
3283
+ await runAgentTurn({ session, emit, signal });
3174
3284
  });
3175
3285
  } catch (err) {
3176
3286
  await emit({ type: "error", message: err.message });
@@ -3190,7 +3300,9 @@ app6.post("/sessions/:id/respond", async (c) => {
3190
3300
  return c.json({ error: "No pending question to answer." }, 409);
3191
3301
  }
3192
3302
  return streamSSE(c, async (stream) => {
3303
+ const signal = c.req.raw.signal;
3193
3304
  const emit = async (event) => {
3305
+ if (signal.aborted) return;
3194
3306
  await stream.writeSSE({ data: JSON.stringify(event) });
3195
3307
  };
3196
3308
  try {
@@ -3207,7 +3319,7 @@ app6.post("/sessions/:id/respond", async (c) => {
3207
3319
  });
3208
3320
  return;
3209
3321
  }
3210
- await resumeWithAnswer({ session, userAnswer: answer, emit });
3322
+ await resumeWithAnswer({ session, userAnswer: answer, emit, signal });
3211
3323
  });
3212
3324
  } catch (err) {
3213
3325
  await emit({ type: "error", message: err.message });