os-dpt 0.1.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -4,9 +4,9 @@
4
4
  <meta charset="UTF-8" />
5
5
  <link rel="icon" type="image/svg+xml" href="/dpt.svg" />
6
6
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
- <title>dpt</title>
8
- <script type="module" crossorigin src="/assets/index-G634ntMY.js"></script>
9
- <link rel="stylesheet" crossorigin href="/assets/index-CsUxdPx_.css">
7
+ <title>Data Profile Tool</title>
8
+ <script type="module" crossorigin src="/assets/index-BejCAhC3.js"></script>
9
+ <link rel="stylesheet" crossorigin href="/assets/index-BbfkzqpQ.css">
10
10
  </head>
11
11
  <body>
12
12
  <div id="root"></div>
@@ -9,7 +9,7 @@ for (const envPath of [".env", "../.env"]) {
9
9
  // server/index.ts
10
10
  import { pathToFileURL } from "node:url";
11
11
  import { serve } from "@hono/node-server";
12
- import { Hono as Hono10 } from "hono";
12
+ import { Hono as Hono11 } from "hono";
13
13
  import { HTTPException as HTTPException4 } from "hono/http-exception";
14
14
 
15
15
  // server/api/ai-providers.ts
@@ -221,6 +221,7 @@ var CONTEXT_DOC_NAMES = CONTEXT_DOCS.map((d) => d.name);
221
221
  // server/workspace.ts
222
222
  var exec = promisify(execFile);
223
223
  var WORKSHEETS_DIR = "worksheets";
224
+ var DASHBOARDS_DIR = "dashboards";
224
225
  var CONTEXT_DIR = "context";
225
226
  var CONTEXT_BY_SOURCE = "by-source";
226
227
  var OSDPT_DIR = ".os-dpt";
@@ -244,6 +245,7 @@ function resolveWorkspace(argv) {
244
245
  async function initWorkspace(argv = process.argv.slice(2)) {
245
246
  const root = resolveWorkspace(argv);
246
247
  await fs4.mkdir(path4.join(root, WORKSHEETS_DIR), { recursive: true });
248
+ await fs4.mkdir(path4.join(root, DASHBOARDS_DIR), { recursive: true });
247
249
  await fs4.mkdir(path4.join(root, DRAFTS_DIR), { recursive: true });
248
250
  await fs4.mkdir(path4.join(root, CHATS_DIR), { recursive: true });
249
251
  await fs4.mkdir(path4.join(root, CONTEXT_DIR), { recursive: true });
@@ -300,6 +302,8 @@ function workspacePath(...segments) {
300
302
  var paths = {
301
303
  worksheets: () => workspacePath(WORKSHEETS_DIR),
302
304
  worksheet: (slug) => workspacePath(WORKSHEETS_DIR, `${slug}.sql`),
305
+ dashboards: () => workspacePath(DASHBOARDS_DIR),
306
+ dashboard: (slug) => workspacePath(DASHBOARDS_DIR, `${slug}.json`),
303
307
  drafts: () => workspacePath(DRAFTS_DIR),
304
308
  draft: (slug) => workspacePath(DRAFTS_DIR, `${slug}.sql`),
305
309
  schemas: () => workspacePath(SCHEMAS_DIR),
@@ -634,6 +638,7 @@ async function introspect(pool) {
634
638
 
635
639
  // server/db/postgres.ts
636
640
  import pg from "pg";
641
+ pg.types.setTypeParser(pg.types.builtins.DATE, (value) => value);
637
642
  function clientConfig(conn, password) {
638
643
  return {
639
644
  host: conn.host,
@@ -1183,9 +1188,9 @@ import { promises as fs9 } from "node:fs";
1183
1188
  import path8 from "node:path";
1184
1189
 
1185
1190
  // server/lib/slug.ts
1186
- function slugify(name) {
1191
+ function slugify(name, fallback = "worksheet") {
1187
1192
  const base = name.trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
1188
- return base || "worksheet";
1193
+ return base || fallback;
1189
1194
  }
1190
1195
  function dedupeSlug(slug, existing) {
1191
1196
  if (!existing.has(slug)) return slug;
@@ -1969,6 +1974,14 @@ import { Hono as Hono8 } from "hono";
1969
1974
  import { streamSSE } from "hono/streaming";
1970
1975
 
1971
1976
  // shared/agent.ts
1977
+ var CHART_TYPES = [
1978
+ "bar",
1979
+ "stacked-bar",
1980
+ "line",
1981
+ "area",
1982
+ "pie",
1983
+ "funnel"
1984
+ ];
1972
1985
  function emptyTotals() {
1973
1986
  return {
1974
1987
  inputTokens: 0,
@@ -2063,7 +2076,7 @@ function buildSystemPrompt(session) {
2063
2076
  "- run_sql results are capped to 50 rows by default; add LIMIT or aggregation if you need a broader view.",
2064
2077
  "- 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.",
2065
2078
  "- ask_user_question pauses the loop entirely \u2014 only one question per call, and use it sparingly.",
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, 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."
2079
+ "- 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."
2067
2080
  );
2068
2081
  const ctx = [];
2069
2082
  if (worksheetSlug) ctx.push(`- Active worksheet: ${worksheetSlug}`);
@@ -2080,16 +2093,17 @@ function buildSystemPrompt(session) {
2080
2093
  }
2081
2094
  function buildQuickEditPrompt(session) {
2082
2095
  const { worksheetSlug, connectionId } = session.meta;
2096
+ const noun = worksheetSlug ? "worksheet" : "query";
2083
2097
  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.",
2098
+ worksheetSlug ? "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." : "You are os-dpt's inline SQL-editing agent. The user is editing a standalone SQL query (e.g. a saved chart's source query). Your ONLY deliverable is SQL staged back into their editor via write_sql \u2014 the user never reads chat output in this mode, they watch the editor update.",
2085
2099
  "",
2086
2100
  "Output rules (hard requirements):",
2087
2101
  "- Emit NO prose. No preamble, no explanation, no closing summary \u2014 no text blocks at all.",
2088
2102
  "- 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.",
2103
+ `- Always finish by calling write_sql with the COMPLETE ${noun} contents (writes are overwrites, not patches), then end the turn.`,
2090
2104
  "",
2091
2105
  "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.",
2106
+ `- Each user message ends with the ${noun}'s current contents. Treat the request as an edit to that SQL unless it clearly asks for something new; preserve parts of the ${noun} the request doesn't touch.`,
2093
2107
  "- 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
2108
  "- 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
2109
  "- If no connection is bound or verification is impossible, stage your best SQL with a leading '-- not verified' comment instead of stopping.",
@@ -2290,6 +2304,32 @@ import { promises as fs16 } from "node:fs";
2290
2304
  function isContextFile(v) {
2291
2305
  return CONTEXT_FILES.includes(v);
2292
2306
  }
2307
+ var DETAIL_CONTEXT_LINES = 8;
2308
+ function toLines(text) {
2309
+ return text === "" ? [] : text.replace(/\n$/, "").split("\n");
2310
+ }
2311
+ function detailSnapshots(before, after) {
2312
+ const a = toLines(before);
2313
+ const b = toLines(after);
2314
+ let prefix = 0;
2315
+ while (prefix < a.length && prefix < b.length && a[prefix] === b[prefix]) prefix++;
2316
+ let suffix = 0;
2317
+ while (suffix < a.length - prefix && suffix < b.length - prefix && a[a.length - 1 - suffix] === b[b.length - 1 - suffix]) {
2318
+ suffix++;
2319
+ }
2320
+ const leading = Math.max(0, prefix - DETAIL_CONTEXT_LINES);
2321
+ const trailing = Math.max(0, suffix - DETAIL_CONTEXT_LINES);
2322
+ if (leading === 0 && trailing === 0) return { before, after };
2323
+ let fences = 0;
2324
+ for (let i = 0; i < leading; i++) {
2325
+ if (/^\s{0,3}(```|~~~)/.test(a[i])) fences++;
2326
+ }
2327
+ return {
2328
+ before: a.slice(leading, a.length - trailing).join("\n"),
2329
+ after: b.slice(leading, b.length - trailing).join("\n"),
2330
+ trimmed: { leading, trailing, ...fences % 2 === 1 ? { inFence: true } : {} }
2331
+ };
2332
+ }
2293
2333
  var updateContextTool = {
2294
2334
  name: "update_context",
2295
2335
  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.",
@@ -2338,16 +2378,16 @@ var updateContextTool = {
2338
2378
  };
2339
2379
  }
2340
2380
  const target = paths.contextFile(input.file, connectionId);
2381
+ let current = "";
2382
+ try {
2383
+ current = await fs16.readFile(target, "utf8");
2384
+ } catch (err) {
2385
+ if (err.code !== "ENOENT") throw err;
2386
+ }
2341
2387
  let next;
2342
2388
  if (input.mode === "replace") {
2343
2389
  next = input.content.endsWith("\n") ? input.content : input.content + "\n";
2344
2390
  } else {
2345
- let current = "";
2346
- try {
2347
- current = await fs16.readFile(target, "utf8");
2348
- } catch (err) {
2349
- if (err.code !== "ENOENT") throw err;
2350
- }
2351
2391
  const stamp = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
2352
2392
  const block = `## ${stamp}
2353
2393
 
@@ -2359,7 +2399,8 @@ ${input.content.trim()}
2359
2399
  return {
2360
2400
  toolResult: `Wrote ${next.length} bytes to context/${input.file}.md (${input.mode})`,
2361
2401
  isError: false,
2362
- uiSummary: `Updated context/${input.file}.md (${input.mode})`
2402
+ uiSummary: `Updated context/${input.file}.md (${input.mode})`,
2403
+ detail: { file: `${input.file}.md`, mode: input.mode, ...detailSnapshots(current, next) }
2363
2404
  };
2364
2405
  }
2365
2406
  };
@@ -2504,7 +2545,7 @@ var askUserQuestionTool = {
2504
2545
  // server/agent/tools/write_sql.ts
2505
2546
  var writeSqlTool = {
2506
2547
  name: "write_sql",
2507
- description: "Stage SQL into the editor. Writes to the worksheet's draft (autosave channel), so the user sees the SQL appear in the editor without losing their saved version until they hit Cmd+S. Defaults to the chat's bound worksheet. Pass the COMPLETE worksheet contents \u2014 drafts are overwrites, not patches.",
2548
+ description: "Stage SQL into the editor. Writes to the worksheet's draft (autosave channel), so the user sees the SQL appear in the editor without losing their saved version until they hit Cmd+S. Defaults to the chat's bound worksheet. Pass the COMPLETE worksheet contents \u2014 drafts are overwrites, not patches. When no worksheet is bound, the SQL streams straight back to the editor that launched this session.",
2508
2549
  input_schema: {
2509
2550
  type: "object",
2510
2551
  required: ["sql"],
@@ -2530,6 +2571,14 @@ var writeSqlTool = {
2530
2571
  }
2531
2572
  const slug = input.worksheet_slug ?? ctx.session.meta.worksheetSlug;
2532
2573
  if (!slug) {
2574
+ if (ctx.session.meta.mode === "quick-edit") {
2575
+ return {
2576
+ toolResult: `Staged ${input.sql.length} bytes back to the editor.`,
2577
+ isError: false,
2578
+ uiSummary: `Staged ${input.sql.length} chars into the editor`,
2579
+ events: [{ type: "sql_written", worksheetSlug: null, sql: input.sql }]
2580
+ };
2581
+ }
2533
2582
  return {
2534
2583
  toolResult: "No worksheet bound. Ask the user which worksheet to write to, or set worksheet_slug.",
2535
2584
  isError: true,
@@ -2675,7 +2724,6 @@ var runSqlTool = {
2675
2724
  };
2676
2725
 
2677
2726
  // server/agent/tools/render_chart.ts
2678
- var CHART_TYPES = ["bar", "line", "area", "pie"];
2679
2727
  var MAX_ROWS2 = 500;
2680
2728
  var MAX_SERIES = 8;
2681
2729
  function parseSeries(raw) {
@@ -2716,7 +2764,7 @@ function parseData(raw) {
2716
2764
  }
2717
2765
  var renderChartTool = {
2718
2766
  name: "render_chart",
2719
- 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.`,
2767
+ 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.`,
2720
2768
  input_schema: {
2721
2769
  type: "object",
2722
2770
  required: ["type", "x", "series", "data"],
@@ -2724,7 +2772,7 @@ var renderChartTool = {
2724
2772
  type: {
2725
2773
  type: "string",
2726
2774
  enum: CHART_TYPES,
2727
- description: "Chart kind: bar, line, area, or pie."
2775
+ description: "Chart kind: bar, stacked-bar, line, area, pie, or funnel."
2728
2776
  },
2729
2777
  title: { type: "string", description: "Optional title shown above the chart." },
2730
2778
  x: {
@@ -2747,6 +2795,10 @@ var renderChartTool = {
2747
2795
  type: "array",
2748
2796
  description: "Row objects to chart, keyed by column name.",
2749
2797
  items: { type: "object" }
2798
+ },
2799
+ sourceQuery: {
2800
+ type: "string",
2801
+ description: "The `name` of the run_sql call whose results this chart plots. Lets the UI link the chart back to its source query."
2750
2802
  }
2751
2803
  }
2752
2804
  },
@@ -2779,7 +2831,8 @@ var renderChartTool = {
2779
2831
  x: input.x,
2780
2832
  series,
2781
2833
  data,
2782
- ...typeof input.title === "string" && input.title.trim() !== "" ? { title: input.title } : {}
2834
+ ...typeof input.title === "string" && input.title.trim() !== "" ? { title: input.title } : {},
2835
+ ...typeof input.sourceQuery === "string" && input.sourceQuery.trim() !== "" ? { sourceQuery: input.sourceQuery.trim() } : {}
2783
2836
  };
2784
2837
  const seriesLabel = series.map((s) => s.label ?? s.key).join(", ");
2785
2838
  return {
@@ -2888,7 +2941,10 @@ async function runTurn(opts) {
2888
2941
  }
2889
2942
  const system = buildSystemPrompt(session);
2890
2943
  const tools = anthropicToolDefs({
2891
- worksheetBound: !!session.meta.worksheetSlug,
2944
+ // Quick-edit always keeps write_sql — it's the mode's only deliverable.
2945
+ // Without a worksheet bound the tool skips the draft write and streams
2946
+ // the SQL back to the launching editor via the sql_written event.
2947
+ worksheetBound: !!session.meta.worksheetSlug || session.meta.mode === "quick-edit",
2892
2948
  mode: session.meta.mode
2893
2949
  });
2894
2950
  for (let step = 0; step < MAX_STEPS; step += 1) {
@@ -3013,7 +3069,8 @@ async function runTurn(opts) {
3013
3069
  toolUseId: block.id,
3014
3070
  name: tool.name,
3015
3071
  ok: !execution.isError,
3016
- summary: execution.uiSummary
3072
+ summary: execution.uiSummary,
3073
+ ...execution.detail ? { detail: execution.detail } : {}
3017
3074
  });
3018
3075
  resultBlocks.push({
3019
3076
  type: "tool_result",
@@ -3363,10 +3420,201 @@ app7.put("/:name", async (c) => {
3363
3420
  });
3364
3421
  var context_default = app7;
3365
3422
 
3423
+ // server/api/dashboards.ts
3424
+ import { Hono as Hono10 } from "hono";
3425
+ import { createHash as createHash2, randomUUID } from "node:crypto";
3426
+ import { promises as fs18 } from "node:fs";
3427
+ var app8 = new Hono10();
3428
+ var MAX_NAME_LEN2 = 80;
3429
+ var MAX_TITLE_LEN = 120;
3430
+ async function readDashboard(slug) {
3431
+ try {
3432
+ const raw = await fs18.readFile(paths.dashboard(slug), "utf8");
3433
+ const parsed = JSON.parse(raw);
3434
+ return normalize2(slug, parsed);
3435
+ } catch {
3436
+ return null;
3437
+ }
3438
+ }
3439
+ function normalize2(slug, d) {
3440
+ return {
3441
+ slug,
3442
+ name: typeof d.name === "string" && d.name.trim() !== "" ? d.name : slug,
3443
+ createdAt: d.createdAt ?? (/* @__PURE__ */ new Date(0)).toISOString(),
3444
+ updatedAt: d.updatedAt ?? (/* @__PURE__ */ new Date(0)).toISOString(),
3445
+ charts: (Array.isArray(d.charts) ? d.charts : []).flatMap((chart, i) => {
3446
+ const parsed = parseChart(chart);
3447
+ if (typeof parsed === "string") return [];
3448
+ const c = chart;
3449
+ return [
3450
+ {
3451
+ ...parsed,
3452
+ id: typeof c.id === "string" && c.id !== "" ? c.id : deriveChartId(slug, i, chart),
3453
+ position: typeof c.position === "number" ? c.position : i
3454
+ }
3455
+ ];
3456
+ })
3457
+ };
3458
+ }
3459
+ function deriveChartId(slug, index, chart) {
3460
+ const hash = createHash2("sha256").update(`${slug}
3461
+ ${index}
3462
+ ${JSON.stringify(chart)}`).digest("hex");
3463
+ return `gen-${hash.slice(0, 12)}`;
3464
+ }
3465
+ async function persist2(dashboard) {
3466
+ dashboard.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
3467
+ await writeAtomic(paths.dashboard(dashboard.slug), JSON.stringify(dashboard, null, 2));
3468
+ return dashboard;
3469
+ }
3470
+ async function listSlugs() {
3471
+ const entries = await fs18.readdir(paths.dashboards()).catch(() => []);
3472
+ return entries.filter((f) => f.endsWith(".json")).map((f) => f.slice(0, -5));
3473
+ }
3474
+ var mutationTails = /* @__PURE__ */ new Map();
3475
+ function withDashboardLock(slug, fn) {
3476
+ const prev = mutationTails.get(slug) ?? Promise.resolve();
3477
+ const result = prev.then(fn);
3478
+ const tail = result.then(
3479
+ () => void 0,
3480
+ () => void 0
3481
+ );
3482
+ mutationTails.set(slug, tail);
3483
+ void tail.then(() => {
3484
+ if (mutationTails.get(slug) === tail) mutationTails.delete(slug);
3485
+ });
3486
+ return result;
3487
+ }
3488
+ function parseChart(raw) {
3489
+ if (!raw || typeof raw !== "object") return "chart must be an object";
3490
+ const c = raw;
3491
+ if (!CHART_TYPES.includes(c.type)) {
3492
+ return `type must be one of: ${CHART_TYPES.join(", ")}`;
3493
+ }
3494
+ if (typeof c.x !== "string" || c.x.trim() === "") return "x must be a non-empty string";
3495
+ if (typeof c.sql !== "string" || c.sql.trim() === "") return "sql must be a non-empty string";
3496
+ if (!Array.isArray(c.series) || c.series.length === 0) {
3497
+ return "series must be a non-empty array of { key, label? }";
3498
+ }
3499
+ const series = [];
3500
+ for (const item of c.series) {
3501
+ if (!item || typeof item !== "object" || typeof item.key !== "string") {
3502
+ return "each series must be an object with a string `key`";
3503
+ }
3504
+ const s = item;
3505
+ series.push(typeof s.label === "string" && s.label !== "" ? { key: s.key, label: s.label } : { key: s.key });
3506
+ }
3507
+ const title = typeof c.title === "string" && c.title.trim() !== "" ? c.title.trim() : "Untitled chart";
3508
+ const connectionId = typeof c.connectionId === "string" && c.connectionId !== "" ? c.connectionId : null;
3509
+ return {
3510
+ title: title.length > MAX_TITLE_LEN ? title.slice(0, MAX_TITLE_LEN) : title,
3511
+ type: c.type,
3512
+ x: c.x,
3513
+ series,
3514
+ sql: c.sql,
3515
+ connectionId
3516
+ };
3517
+ }
3518
+ app8.get("/", async (c) => {
3519
+ const slugs = await listSlugs();
3520
+ const metas = [];
3521
+ for (const slug of slugs) {
3522
+ const d = await readDashboard(slug);
3523
+ if (!d) continue;
3524
+ metas.push({ slug, name: d.name, updatedAt: d.updatedAt, chartCount: d.charts.length });
3525
+ }
3526
+ metas.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
3527
+ return c.json({ dashboards: metas });
3528
+ });
3529
+ app8.post("/", async (c) => {
3530
+ const body = await c.req.json().catch(() => ({}));
3531
+ const raw = (body.name ?? "").trim();
3532
+ const name = raw === "" ? "Dashboard" : raw.length > MAX_NAME_LEN2 ? raw.slice(0, MAX_NAME_LEN2) : raw;
3533
+ const existing = new Set(await listSlugs());
3534
+ const slug = dedupeSlug(slugify(name, "dashboard"), existing);
3535
+ const now = (/* @__PURE__ */ new Date()).toISOString();
3536
+ const dashboard = { slug, name, createdAt: now, updatedAt: now, charts: [] };
3537
+ await writeAtomic(paths.dashboard(slug), JSON.stringify(dashboard, null, 2));
3538
+ return c.json(dashboard, 201);
3539
+ });
3540
+ app8.get("/:slug", async (c) => {
3541
+ const slug = c.req.param("slug");
3542
+ assertSafeSlug(slug);
3543
+ const dashboard = await readDashboard(slug);
3544
+ if (!dashboard) return c.json({ error: "not_found" }, 404);
3545
+ return c.json(dashboard);
3546
+ });
3547
+ app8.patch("/:slug", async (c) => {
3548
+ const slug = c.req.param("slug");
3549
+ assertSafeSlug(slug);
3550
+ const body = await c.req.json().catch(() => ({}));
3551
+ const raw = (body.name ?? "").trim();
3552
+ if (!raw) return c.json({ error: "empty_name" }, 400);
3553
+ return withDashboardLock(slug, async () => {
3554
+ const dashboard = await readDashboard(slug);
3555
+ if (!dashboard) return c.json({ error: "not_found" }, 404);
3556
+ dashboard.name = raw.length > MAX_NAME_LEN2 ? raw.slice(0, MAX_NAME_LEN2) : raw;
3557
+ return c.json(await persist2(dashboard));
3558
+ });
3559
+ });
3560
+ app8.delete("/:slug", async (c) => {
3561
+ const slug = c.req.param("slug");
3562
+ assertSafeSlug(slug);
3563
+ return withDashboardLock(slug, async () => {
3564
+ await fs18.rm(paths.dashboard(slug), { force: true });
3565
+ return c.json({ ok: true });
3566
+ });
3567
+ });
3568
+ app8.post("/:slug/charts", async (c) => {
3569
+ const slug = c.req.param("slug");
3570
+ assertSafeSlug(slug);
3571
+ const body = await c.req.json().catch(() => null);
3572
+ const parsed = parseChart(body);
3573
+ if (typeof parsed === "string") return c.json({ error: parsed }, 400);
3574
+ return withDashboardLock(slug, async () => {
3575
+ const dashboard = await readDashboard(slug);
3576
+ if (!dashboard) return c.json({ error: "not_found" }, 404);
3577
+ const position = dashboard.charts.reduce((max, ch) => Math.max(max, ch.position), -1) + 1;
3578
+ const chart = { ...parsed, id: randomUUID(), position };
3579
+ dashboard.charts.push(chart);
3580
+ return c.json(await persist2(dashboard));
3581
+ });
3582
+ });
3583
+ app8.put("/:slug/charts/:chartId", async (c) => {
3584
+ const slug = c.req.param("slug");
3585
+ assertSafeSlug(slug);
3586
+ const chartId = c.req.param("chartId");
3587
+ const body = await c.req.json().catch(() => ({}));
3588
+ return withDashboardLock(slug, async () => {
3589
+ const dashboard = await readDashboard(slug);
3590
+ if (!dashboard) return c.json({ error: "not_found" }, 404);
3591
+ const chart = dashboard.charts.find((ch) => ch.id === chartId);
3592
+ if (!chart) return c.json({ error: "not_found" }, 404);
3593
+ const parsed = parseChart({ ...chart, ...body });
3594
+ if (typeof parsed === "string") return c.json({ error: parsed }, 400);
3595
+ Object.assign(chart, parsed);
3596
+ return c.json(await persist2(dashboard));
3597
+ });
3598
+ });
3599
+ app8.delete("/:slug/charts/:chartId", async (c) => {
3600
+ const slug = c.req.param("slug");
3601
+ assertSafeSlug(slug);
3602
+ const chartId = c.req.param("chartId");
3603
+ return withDashboardLock(slug, async () => {
3604
+ const dashboard = await readDashboard(slug);
3605
+ if (!dashboard) return c.json({ error: "not_found" }, 404);
3606
+ const before = dashboard.charts.length;
3607
+ dashboard.charts = dashboard.charts.filter((ch) => ch.id !== chartId);
3608
+ if (dashboard.charts.length === before) return c.json({ error: "not_found" }, 404);
3609
+ return c.json(await persist2(dashboard));
3610
+ });
3611
+ });
3612
+ var dashboards_default = app8;
3613
+
3366
3614
  // server/history/watcher.ts
3367
- import { promises as fs18, watch } from "node:fs";
3615
+ import { promises as fs19, watch } from "node:fs";
3368
3616
  import path10 from "node:path";
3369
- import { createHash as createHash2 } from "node:crypto";
3617
+ import { createHash as createHash3 } from "node:crypto";
3370
3618
  var SLUG_RE2 = /^[a-z0-9][a-z0-9_-]*$/i;
3371
3619
  var watcher = null;
3372
3620
  function startWorksheetsWatcher() {
@@ -3385,11 +3633,11 @@ function startWorksheetsWatcher() {
3385
3633
  async function handleEvent(slug, filePath2) {
3386
3634
  let content;
3387
3635
  try {
3388
- content = await fs18.readFile(filePath2, "utf8");
3636
+ content = await fs19.readFile(filePath2, "utf8");
3389
3637
  } catch {
3390
3638
  return;
3391
3639
  }
3392
- const sha = createHash2("sha256").update(content, "utf8").digest("hex");
3640
+ const sha = createHash3("sha256").update(content, "utf8").digest("hex");
3393
3641
  if (isRecentWrite(slug, sha)) return;
3394
3642
  recordHistory({ worksheet: slug, source: "external", content });
3395
3643
  }
@@ -3431,10 +3679,10 @@ function contentType(file) {
3431
3679
  function cacheControl(urlPath) {
3432
3680
  return urlPath.startsWith("/assets/") ? "public, max-age=31536000, immutable" : "no-cache";
3433
3681
  }
3434
- function serveClient(app8, clientDir = defaultClientDir()) {
3682
+ function serveClient(app9, clientDir = defaultClientDir()) {
3435
3683
  if (!existsSync(path11.join(clientDir, "index.html"))) return false;
3436
3684
  const indexHtml = path11.join(clientDir, "index.html");
3437
- app8.get("*", async (c) => {
3685
+ app9.get("*", async (c) => {
3438
3686
  if (c.req.path.startsWith("/api")) return c.notFound();
3439
3687
  const rel = decodeURIComponent(c.req.path).replace(/^\/+/, "");
3440
3688
  const candidate = path11.resolve(clientDir, rel);
@@ -3478,11 +3726,11 @@ function openBrowser(url) {
3478
3726
 
3479
3727
  // server/index.ts
3480
3728
  var DEFAULT_PORT = 3756;
3481
- function listen(app8, port) {
3729
+ function listen(app9, port) {
3482
3730
  return new Promise((resolve, reject) => {
3483
3731
  let retried = false;
3484
3732
  const attempt = (p) => {
3485
- const server = serve({ fetch: app8.fetch, port: p, hostname: "127.0.0.1" });
3733
+ const server = serve({ fetch: app9.fetch, port: p, hostname: "127.0.0.1" });
3486
3734
  server.once("listening", () => resolve(server));
3487
3735
  server.once("error", (err) => {
3488
3736
  if (err.code === "EADDRINUSE" && !retried) {
@@ -3502,25 +3750,26 @@ async function startServer(argv = process.argv.slice(2)) {
3502
3750
  openHistoryDb();
3503
3751
  startWorksheetsWatcher();
3504
3752
  void autoConnectAll(workspace);
3505
- const app8 = new Hono10();
3506
- app8.get("/api/health", (c) => c.json({ ok: true, workspace }));
3507
- app8.route("/api/connections", connectionsRouter(workspace));
3508
- app8.route("/api/ai-providers", aiProvidersRouter(workspace));
3509
- app8.route("/api/worksheets", worksheets_default);
3510
- app8.route("/api/drafts", drafts_default);
3511
- app8.route("/api/session", session_default);
3512
- app8.route("/api/schema", schema_default);
3513
- app8.route("/api/history", history_default);
3514
- app8.route("/api/agent", agent_default);
3515
- app8.route("/api/context", context_default);
3516
- app8.onError((err, c) => {
3753
+ const app9 = new Hono11();
3754
+ app9.get("/api/health", (c) => c.json({ ok: true, workspace }));
3755
+ app9.route("/api/connections", connectionsRouter(workspace));
3756
+ app9.route("/api/ai-providers", aiProvidersRouter(workspace));
3757
+ app9.route("/api/worksheets", worksheets_default);
3758
+ app9.route("/api/drafts", drafts_default);
3759
+ app9.route("/api/session", session_default);
3760
+ app9.route("/api/schema", schema_default);
3761
+ app9.route("/api/history", history_default);
3762
+ app9.route("/api/agent", agent_default);
3763
+ app9.route("/api/context", context_default);
3764
+ app9.route("/api/dashboards", dashboards_default);
3765
+ app9.onError((err, c) => {
3517
3766
  if (err instanceof HTTPException4) return err.getResponse();
3518
3767
  console.error("[os-dpt]", err);
3519
3768
  return c.json({ error: err.message }, 500);
3520
3769
  });
3521
- serveClient(app8);
3770
+ serveClient(app9);
3522
3771
  const preferredPort = Number(process.env.PORT ?? DEFAULT_PORT);
3523
- const server = await listen(app8, preferredPort);
3772
+ const server = await listen(app9, preferredPort);
3524
3773
  const addr = server.address();
3525
3774
  const port = typeof addr === "object" && addr ? addr.port : preferredPort;
3526
3775
  const url = `http://127.0.0.1:${port}`;