wizards 0.2.17 → 0.2.18

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.
Files changed (65) hide show
  1. package/apps/runtime/dist/agents/project-assistant.js +20 -2
  2. package/apps/runtime/dist/authoring/guide.js +6 -3
  3. package/apps/runtime/dist/db/migrations/tenant/0009_knowledge.sql +104 -0
  4. package/apps/runtime/dist/db/migrations/tenant/meta/0009_snapshot.json +2837 -0
  5. package/apps/runtime/dist/db/migrations/tenant/meta/_journal.json +7 -0
  6. package/apps/runtime/dist/db/schema.js +160 -8
  7. package/apps/runtime/dist/engine/steps.js +3 -11
  8. package/apps/runtime/dist/env.js +2 -0
  9. package/apps/runtime/dist/files/storage.js +15 -0
  10. package/apps/runtime/dist/models.js +38 -0
  11. package/apps/runtime/dist/plugins/loader.js +2 -0
  12. package/apps/runtime/dist/routes/studio.js +147 -6
  13. package/apps/runtime/dist/services/cloud.js +59 -1
  14. package/apps/runtime/dist/services/knowledge-model.js +558 -0
  15. package/apps/runtime/dist/services/knowledge.js +721 -0
  16. package/apps/runtime/dist/services/project-files.js +114 -6
  17. package/apps/runtime/dist/services/project-index.js +632 -94
  18. package/apps/runtime/dist/services/relevance.js +90 -0
  19. package/apps/runtime/dist/services/space-categories.js +882 -0
  20. package/apps/runtime/dist/services/space-data.js +466 -38
  21. package/apps/runtime/dist/services/space-origin.js +164 -0
  22. package/apps/runtime/dist/services/spaces.js +155 -1
  23. package/apps/runtime/dist/tools/pages.js +27 -22
  24. package/apps/runtime/dist/tools/project.js +130 -40
  25. package/apps/web/dist/assets/{Account-LVXhagaS.js → Account-Cp1cgqjV.js} +1 -1
  26. package/apps/web/dist/assets/{ChangelogDialog-DM0KAoy6.js → ChangelogDialog-Chke1qUe.js} +1 -1
  27. package/apps/web/dist/assets/{ChatPanel-CxOQmtv-.js → ChatPanel-DWrwewr6.js} +1 -1
  28. package/apps/web/dist/assets/{CreditsDialog-DkW3YT44.js → CreditsDialog-Ckema8Ss.js} +1 -1
  29. package/apps/web/dist/assets/{EditorPage-hbisE1Wf.js → EditorPage-XX6pfHk5.js} +1 -1
  30. package/apps/web/dist/assets/{FlowPreview-v7or29ku.js → FlowPreview-DS545n78.js} +1 -1
  31. package/apps/web/dist/assets/{Harness-RZF-bBmb.js → Harness-H3yXP-2L.js} +1 -1
  32. package/apps/web/dist/assets/{HomePage-mxG-a_KU.js → HomePage-Bds84ShK.js} +1 -1
  33. package/apps/web/dist/assets/{ImportWizard-CiPyUt3E.js → ImportWizard-0_NkA8aS.js} +1 -1
  34. package/apps/web/dist/assets/{LocalRuntime-Dqp9wkN3.js → LocalRuntime-DnABzTOe.js} +1 -1
  35. package/apps/web/dist/assets/{NewWizardPage-DHwRsi0_.js → NewWizardPage-BqP75V9K.js} +2 -2
  36. package/apps/web/dist/assets/ProjectPage-DbOvwOdZ.js +5 -0
  37. package/apps/web/dist/assets/{PublicRunner-TGkOwbAP.js → PublicRunner-CtZ7vxl_.js} +1 -1
  38. package/apps/web/dist/assets/{RunnerView-DzHGshZj.js → RunnerView-xV5bWclu.js} +1 -1
  39. package/apps/web/dist/assets/{SettingsPage-CG3Xtfej.js → SettingsPage-DKt55zFh.js} +1 -1
  40. package/apps/web/dist/assets/{SetupPage-O1GfPlqX.js → SetupPage-c6mMD9zN.js} +1 -1
  41. package/apps/web/dist/assets/{SharePage-CCNUhtry.js → SharePage-DTKIbMj-.js} +1 -1
  42. package/apps/web/dist/assets/{api-DB22_B7G.js → api-CVG-xDEn.js} +1 -1
  43. package/apps/web/dist/assets/changelog-CXXT0zsp.js +1 -0
  44. package/apps/web/dist/assets/{esm-3TT9i_rv.js → esm-DLM3jdbm.js} +1 -1
  45. package/apps/web/dist/assets/{index-CLe9Wrpu.js → index-1gmaxDSk.js} +3 -3
  46. package/apps/web/dist/assets/index-Bwyehu0k.css +1 -0
  47. package/apps/web/dist/assets/{meta-CEsBCPfA.js → meta-m947x6uG.js} +1 -1
  48. package/apps/web/dist/assets/{settings-menu-pOcjznGJ.js → settings-menu-DUY6__C3.js} +1 -1
  49. package/apps/web/dist/assets/ui-BPY2pIh0.js +1 -0
  50. package/apps/web/dist/index.html +4 -4
  51. package/apps/web/dist/mcp/flow.html +2 -2
  52. package/node_modules/@engenty-wizards/plugin-sdk/dist/index.d.ts +164 -0
  53. package/node_modules/@engenty-wizards/shared/dist/knowledge.d.ts +199 -0
  54. package/node_modules/@engenty-wizards/shared/dist/knowledge.js +68 -0
  55. package/node_modules/@engenty-wizards/shared/dist/projects.d.ts +1 -16
  56. package/node_modules/@engenty-wizards/shared/dist/projects.js +1 -1
  57. package/node_modules/@engenty-wizards/shared/dist/run.d.ts +8 -0
  58. package/node_modules/@engenty-wizards/shared/dist/run.js +5 -0
  59. package/node_modules/@engenty-wizards/shared/dist/space-data.d.ts +20 -0
  60. package/node_modules/@engenty-wizards/shared/dist/space-data.js +2 -1
  61. package/package.json +1 -1
  62. package/apps/web/dist/assets/ProjectPage-Bb7jN_6n.js +0 -4
  63. package/apps/web/dist/assets/changelog-BkIJdZ7M.js +0 -1
  64. package/apps/web/dist/assets/index-BPpKz65x.css +0 -1
  65. package/apps/web/dist/assets/ui-I7QYMX0F.js +0 -1
@@ -0,0 +1,721 @@
1
+ import { formatTableCell } from "@engenty-wizards/shared/engenty/data-tables";
2
+ import { slugOf, } from "@engenty-wizards/shared/knowledge";
3
+ import { and, asc, count, eq, inArray, isNotNull, isNull } from "drizzle-orm";
4
+ import { db, schema } from "../db/client.js";
5
+ import { embeddingModel, systemOneAccess } from "../models.js";
6
+ import { notFound, ServiceError } from "./errors.js";
7
+ import { ownedProject } from "./projects.js";
8
+ import { categoryLines, categoryRows, dateOf, filterItems, itemCategoriesOf, listCategories, numberOf, } from "./space-categories.js";
9
+ /** Gives each item without a slug one, unique among its siblings, and keeps it. */
10
+ async function slugsFor(rows, siblingsOf, save) {
11
+ const taken = new Map();
12
+ const out = new Map();
13
+ for (const row of rows) {
14
+ if (row.slug) {
15
+ const set = taken.get(siblingsOf(row)) ?? new Set();
16
+ set.add(row.slug);
17
+ taken.set(siblingsOf(row), set);
18
+ out.set(row.id, row.slug);
19
+ }
20
+ }
21
+ for (const row of rows) {
22
+ if (row.slug) {
23
+ continue;
24
+ }
25
+ const set = taken.get(siblingsOf(row)) ?? new Set();
26
+ const base = slugOf(row.title);
27
+ let slug = base;
28
+ for (let i = 2; set.has(slug); i++) {
29
+ slug = `${base}-${i}`;
30
+ }
31
+ set.add(slug);
32
+ taken.set(siblingsOf(row), set);
33
+ out.set(row.id, slug);
34
+ await save(row.id, slug);
35
+ }
36
+ return out;
37
+ }
38
+ /** A slug for a new page among its siblings, or a new table among the space's tables. */
39
+ export async function freeSlug(projectId, title, of, except) {
40
+ const rows = of === "table"
41
+ ? await db.query.spaceTable.findMany({
42
+ where: and(eq(schema.spaceTable.projectId, projectId), isNull(schema.spaceTable.wizardId)),
43
+ columns: { id: true, slug: true },
44
+ })
45
+ : await db.query.spacePage.findMany({
46
+ where: and(eq(schema.spacePage.projectId, projectId), isNull(schema.spacePage.wizardId), of.parentId
47
+ ? eq(schema.spacePage.parentId, of.parentId)
48
+ : isNull(schema.spacePage.parentId)),
49
+ columns: { id: true, slug: true },
50
+ });
51
+ const taken = new Set(rows.filter((r) => r.id !== except).map((r) => r.slug));
52
+ const base = slugOf(title);
53
+ let slug = base;
54
+ for (let i = 2; taken.has(slug); i++) {
55
+ slug = `${base}-${i}`;
56
+ }
57
+ return slug;
58
+ }
59
+ /** The space's own pages with their paths, by id. */
60
+ export async function pageTree(projectId) {
61
+ const rows = await db.query.spacePage.findMany({
62
+ where: and(eq(schema.spacePage.projectId, projectId), isNull(schema.spacePage.wizardId)),
63
+ columns: { id: true, parentId: true, title: true, slug: true, position: true },
64
+ orderBy: [asc(schema.spacePage.position), asc(schema.spacePage.title)],
65
+ });
66
+ const slugs = await slugsFor(rows, (r) => r.parentId ?? "", (id, slug) => db.update(schema.spacePage).set({ slug }).where(eq(schema.spacePage.id, id)));
67
+ const byId = new Map(rows.map((r) => [r.id, r]));
68
+ const out = new Map();
69
+ const walk = (id, seen) => {
70
+ const known = out.get(id);
71
+ if (known) {
72
+ return known;
73
+ }
74
+ const row = byId.get(id);
75
+ if (!row || seen.has(id)) {
76
+ return undefined;
77
+ }
78
+ seen.add(id);
79
+ const parent = row.parentId ? walk(row.parentId, seen) : undefined;
80
+ const slug = slugs.get(id);
81
+ const node = {
82
+ id,
83
+ parentId: parent ? row.parentId : null,
84
+ title: row.title,
85
+ slug,
86
+ position: row.position,
87
+ path: parent ? `${parent.path}/${slug}` : `pages/${slug}`,
88
+ titles: [...(parent?.titles ?? []), row.title],
89
+ };
90
+ out.set(id, node);
91
+ return node;
92
+ };
93
+ for (const row of rows) {
94
+ walk(row.id, new Set());
95
+ }
96
+ return out;
97
+ }
98
+ /** The space's own tables with their paths, by id. */
99
+ export async function tablePaths(projectId) {
100
+ const rows = await db.query.spaceTable.findMany({
101
+ where: and(eq(schema.spaceTable.projectId, projectId), isNull(schema.spaceTable.wizardId)),
102
+ columns: { id: true, slug: true, title: true },
103
+ orderBy: [asc(schema.spaceTable.createdAt)],
104
+ });
105
+ const slugs = await slugsFor(rows, () => "", (id, slug) => db.update(schema.spaceTable).set({ slug }).where(eq(schema.spaceTable.id, id)));
106
+ return new Map(rows.map((r) => [r.id, `tables/${slugs.get(r.id)}`]));
107
+ }
108
+ /** The space's documents with their paths, by id: their names, made unique. */
109
+ export async function filePaths(projectId) {
110
+ const rows = await db.query.projectFile.findMany({
111
+ where: and(eq(schema.projectFile.projectId, projectId), eq(schema.projectFile.kind, "document")),
112
+ columns: { id: true, name: true },
113
+ orderBy: [asc(schema.projectFile.createdAt)],
114
+ });
115
+ const taken = new Set();
116
+ const out = new Map();
117
+ for (const row of rows) {
118
+ const base = row.name.replace(/[/\\]/g, "-");
119
+ let name = base;
120
+ for (let i = 2; taken.has(name.toLowerCase()); i++) {
121
+ const dot = base.lastIndexOf(".");
122
+ name = dot > 0 ? `${base.slice(0, dot)}-${i}${base.slice(dot)}` : `${base}-${i}`;
123
+ }
124
+ taken.add(name.toLowerCase());
125
+ out.set(row.id, `files/${name}`);
126
+ }
127
+ return out;
128
+ }
129
+ const cleanPath = (path) => path
130
+ .trim()
131
+ .replace(/^\/+|\/+$/g, "")
132
+ .replace(/\.(md|csv)$/i, "")
133
+ .toLowerCase();
134
+ /** What a path names; null when nothing in the space has it. */
135
+ export async function resolvePath(projectId, path) {
136
+ const want = cleanPath(path);
137
+ if (want.startsWith("pages/")) {
138
+ for (const node of (await pageTree(projectId)).values()) {
139
+ if (node.path === want) {
140
+ return { kind: "page", id: node.id, path: node.path };
141
+ }
142
+ }
143
+ }
144
+ else if (want.startsWith("tables/")) {
145
+ for (const [id, p] of await tablePaths(projectId)) {
146
+ if (p === want) {
147
+ return { kind: "table", id, path: p };
148
+ }
149
+ }
150
+ }
151
+ else if (want.startsWith("files/")) {
152
+ for (const [id, p] of await filePaths(projectId)) {
153
+ if (p.toLowerCase() === want) {
154
+ return { kind: "file", id, path: p };
155
+ }
156
+ }
157
+ }
158
+ return null;
159
+ }
160
+ // --- what the studio lists ---------------------------------------------------------
161
+ /** Files a page or a table was read from: they stand behind it, not on their own. */
162
+ async function convertedFiles(projectId) {
163
+ const [pages, tables] = await Promise.all([
164
+ db
165
+ .selectDistinct({ id: schema.spacePage.fileId })
166
+ .from(schema.spacePage)
167
+ .where(and(eq(schema.spacePage.projectId, projectId), isNotNull(schema.spacePage.fileId))),
168
+ db
169
+ .selectDistinct({ id: schema.spaceTable.fileId })
170
+ .from(schema.spaceTable)
171
+ .where(and(eq(schema.spaceTable.projectId, projectId), isNotNull(schema.spaceTable.fileId))),
172
+ ]);
173
+ return new Set([...pages, ...tables].map((r) => r.id));
174
+ }
175
+ /** Everything Wissen holds: pages (sub-pages counted under them), tables, files, Kategorien. */
176
+ export async function listKnowledge(userId, projectId) {
177
+ const project = await ownedProject(userId, projectId);
178
+ const [tree, tables, rowCounts, files, converted, categories, embeddings, checker] = await Promise.all([
179
+ pageTree(project.id),
180
+ db.query.spaceTable.findMany({
181
+ where: and(eq(schema.spaceTable.projectId, project.id), isNull(schema.spaceTable.wizardId)),
182
+ }),
183
+ db
184
+ .select({ tableId: schema.spaceTableRow.tableId, n: count() })
185
+ .from(schema.spaceTableRow)
186
+ .innerJoin(schema.spaceTable, eq(schema.spaceTable.id, schema.spaceTableRow.tableId))
187
+ .where(and(eq(schema.spaceTable.projectId, project.id), isNull(schema.spaceTable.wizardId)))
188
+ .groupBy(schema.spaceTableRow.tableId),
189
+ db.query.projectFile.findMany({
190
+ where: and(eq(schema.projectFile.projectId, project.id), eq(schema.projectFile.kind, "document")),
191
+ }),
192
+ convertedFiles(project.id),
193
+ listCategories(project.id),
194
+ embeddingModel()
195
+ .then(Boolean)
196
+ .catch(() => false),
197
+ systemOneAccess()
198
+ .then(Boolean)
199
+ .catch(() => false),
200
+ ]);
201
+ const tPaths = await tablePaths(project.id);
202
+ const fPaths = await filePaths(project.id);
203
+ const pages = await db.query.spacePage.findMany({
204
+ where: and(eq(schema.spacePage.projectId, project.id), isNull(schema.spacePage.wizardId), isNull(schema.spacePage.parentId)),
205
+ columns: { markdown: false },
206
+ });
207
+ const children = new Map();
208
+ for (const node of tree.values()) {
209
+ if (node.parentId) {
210
+ children.set(node.parentId, (children.get(node.parentId) ?? 0) + 1);
211
+ }
212
+ }
213
+ const loose = files.filter((f) => !converted.has(f.id));
214
+ const cats = await itemCategoriesOf([
215
+ ...pages.map((p) => ({ pageId: p.id })),
216
+ ...tables.map((t) => ({ tableId: t.id })),
217
+ ...loose.map((f) => ({ fileId: f.id })),
218
+ ]);
219
+ const items = [
220
+ ...pages.map((p) => ({
221
+ key: `p:${p.id}`,
222
+ kind: "page",
223
+ id: p.id,
224
+ title: p.title,
225
+ path: tree.get(p.id)?.path ?? "",
226
+ children: children.get(p.id) ?? 0,
227
+ rows: null,
228
+ format: null,
229
+ origin: p.origin,
230
+ originLabel: p.originLabel,
231
+ kept: p.kept,
232
+ review: p.review,
233
+ fileId: p.fileId,
234
+ status: "ready",
235
+ categories: cats.get(`p:${p.id}`) ?? [],
236
+ updatedAt: p.updatedAt.toISOString(),
237
+ })),
238
+ ...tables.map((t) => ({
239
+ key: `t:${t.id}`,
240
+ kind: "table",
241
+ id: t.id,
242
+ title: t.title,
243
+ path: tPaths.get(t.id) ?? "",
244
+ children: 0,
245
+ rows: rowCounts.find((r) => r.tableId === t.id)?.n ?? 0,
246
+ format: t.format,
247
+ origin: t.origin,
248
+ originLabel: t.originLabel,
249
+ kept: t.kept,
250
+ review: t.review,
251
+ fileId: t.fileId,
252
+ status: "ready",
253
+ categories: cats.get(`t:${t.id}`) ?? [],
254
+ updatedAt: t.updatedAt.toISOString(),
255
+ })),
256
+ ...loose.map((f) => ({
257
+ key: `f:${f.id}`,
258
+ kind: "file",
259
+ id: f.id,
260
+ title: f.name,
261
+ path: fPaths.get(f.id) ?? "",
262
+ children: 0,
263
+ rows: null,
264
+ format: null,
265
+ origin: f.origin,
266
+ originLabel: f.originLabel,
267
+ kept: false,
268
+ review: null,
269
+ fileId: f.id,
270
+ status: f.status === "pending" && Date.now() - f.updatedAt.getTime() > 15 * 60_000
271
+ ? "failed"
272
+ : f.status,
273
+ categories: cats.get(`f:${f.id}`) ?? [],
274
+ updatedAt: f.updatedAt.toISOString(),
275
+ })),
276
+ ];
277
+ items.sort((a, b) => a.title.localeCompare(b.title, "de"));
278
+ return { items, categories, embeddings, checked: checker };
279
+ }
280
+ /** A value opened in the studio: its Übersicht, and the items and rows that have it. */
281
+ export async function valueContents(userId, valueId) {
282
+ const value = await db.query.spaceCategoryValue.findFirst({
283
+ where: eq(schema.spaceCategoryValue.id, valueId),
284
+ });
285
+ const category = value
286
+ ? await db.query.spaceCategory.findFirst({
287
+ where: eq(schema.spaceCategory.id, value.categoryId),
288
+ })
289
+ : undefined;
290
+ if (!(value && category)) {
291
+ throw notFound();
292
+ }
293
+ const project = await ownedProject(userId, category.projectId);
294
+ const having = await db.query.spaceItemCategory.findMany({
295
+ where: eq(schema.spaceItemCategory.valueId, value.id),
296
+ });
297
+ return {
298
+ id: value.id,
299
+ value: value.value,
300
+ category: { id: category.id, name: category.name },
301
+ summary: value.summary,
302
+ summaryAt: value.summaryAt?.toISOString() ?? null,
303
+ ...(await itemsHaving(userId, project.id, having)),
304
+ };
305
+ }
306
+ /** Everything that has a Kategorie, whatever its value: what the Kategorie's page lists. */
307
+ export async function categoryContents(userId, categoryId) {
308
+ const category = await db.query.spaceCategory.findFirst({
309
+ where: eq(schema.spaceCategory.id, categoryId),
310
+ });
311
+ if (!category) {
312
+ throw notFound();
313
+ }
314
+ const project = await ownedProject(userId, category.projectId);
315
+ const having = await db.query.spaceItemCategory.findMany({
316
+ where: eq(schema.spaceItemCategory.categoryId, category.id),
317
+ });
318
+ return itemsHaving(userId, project.id, having);
319
+ }
320
+ /** The items rows of `space_item_category` name, as the studio lists them, and rows by table. */
321
+ async function itemsHaving(userId, projectId, having) {
322
+ const project = { id: projectId };
323
+ const pageIds = having.flatMap((h) => (h.pageId ? [h.pageId] : []));
324
+ const tableIds = having.flatMap((h) => (h.tableId ? [h.tableId] : []));
325
+ const fileIds = having.flatMap((h) => (h.fileId ? [h.fileId] : []));
326
+ const rowIds = having.flatMap((h) => (h.rowId ? [h.rowId] : []));
327
+ const all = await listKnowledge(userId, project.id);
328
+ const tree = await pageTree(project.id);
329
+ const subPages = pageIds.filter((id) => !all.items.some((i) => i.key === `p:${id}`));
330
+ const subItems = [];
331
+ if (subPages.length) {
332
+ const cats = await itemCategoriesOf(subPages.map((pageId) => ({ pageId })));
333
+ for (const page of await db.query.spacePage.findMany({
334
+ where: inArray(schema.spacePage.id, subPages),
335
+ columns: { markdown: false },
336
+ })) {
337
+ subItems.push({
338
+ key: `p:${page.id}`,
339
+ kind: "page",
340
+ id: page.id,
341
+ title: tree.get(page.id)?.titles.join(" › ") ?? page.title,
342
+ path: tree.get(page.id)?.path ?? "",
343
+ children: [...tree.values()].filter((n) => n.parentId === page.id).length,
344
+ rows: null,
345
+ format: null,
346
+ origin: page.origin,
347
+ originLabel: page.originLabel,
348
+ kept: page.kept,
349
+ review: page.review,
350
+ fileId: page.fileId,
351
+ status: "ready",
352
+ categories: cats.get(`p:${page.id}`) ?? [],
353
+ updatedAt: page.updatedAt.toISOString(),
354
+ });
355
+ }
356
+ }
357
+ const rowTables = new Map();
358
+ for (let i = 0; i < rowIds.length; i += 500) {
359
+ for (const r of await db.query.spaceTableRow.findMany({
360
+ where: inArray(schema.spaceTableRow.id, rowIds.slice(i, i + 500)),
361
+ columns: { tableId: true },
362
+ })) {
363
+ rowTables.set(r.tableId, (rowTables.get(r.tableId) ?? 0) + 1);
364
+ }
365
+ }
366
+ return {
367
+ items: [
368
+ ...all.items.filter((i) => (i.kind === "page" && pageIds.includes(i.id)) ||
369
+ (i.kind === "table" && tableIds.includes(i.id)) ||
370
+ (i.kind === "file" && fileIds.includes(i.id))),
371
+ ...subItems,
372
+ ],
373
+ rows: [...rowTables].map(([tableId, n]) => ({
374
+ tableId,
375
+ title: all.items.find((i) => i.key === `t:${tableId}`)?.title ?? "",
376
+ count: n,
377
+ })),
378
+ };
379
+ }
380
+ /** What the studio shows around a page of Wissen: where it stands, its sub-pages, its origin. */
381
+ export async function knowledgePage(page) {
382
+ const tree = await pageTree(page.projectId);
383
+ const node = tree.get(page.id);
384
+ const parents = [];
385
+ for (let at = node?.parentId ? tree.get(node.parentId) : undefined; at;) {
386
+ parents.unshift({ id: at.id, title: at.title });
387
+ at = at.parentId ? tree.get(at.parentId) : undefined;
388
+ }
389
+ const children = [...tree.values()]
390
+ .filter((n) => n.parentId === page.id)
391
+ .sort((a, b) => a.position - b.position || a.title.localeCompare(b.title, "de"))
392
+ .map((n) => ({ id: n.id, title: n.title, path: n.path }));
393
+ const file = page.fileId
394
+ ? await db.query.projectFile.findFirst({
395
+ where: eq(schema.projectFile.id, page.fileId),
396
+ columns: { id: true, name: true, mime: true },
397
+ })
398
+ : undefined;
399
+ return {
400
+ id: page.id,
401
+ path: node?.path ?? "",
402
+ parents,
403
+ children,
404
+ categories: (await itemCategoriesOf([{ pageId: page.id }])).get(`p:${page.id}`) ?? [],
405
+ origin: page.origin,
406
+ originLabel: page.originLabel,
407
+ kept: page.kept,
408
+ review: page.review,
409
+ file: file ?? null,
410
+ };
411
+ }
412
+ // --- what a step reads ---------------------------------------------------------------
413
+ const PART = 20_000;
414
+ function yamlValue(v) {
415
+ return typeof v === "string" && /[:#\n"']|^\s|\s$/.test(v) ? JSON.stringify(v) : String(v);
416
+ }
417
+ /** The headings of a page, outermost first, for a step that wants one section. */
418
+ function sectionOf(markdown, wanted) {
419
+ const lines = markdown.split("\n");
420
+ const want = wanted
421
+ .replace(/^#+\s*/, "")
422
+ .trim()
423
+ .toLowerCase();
424
+ let start = -1;
425
+ let level = 0;
426
+ let fence = false;
427
+ for (let i = 0; i < lines.length; i++) {
428
+ if (/^\s*(```|~~~)/.test(lines[i])) {
429
+ fence = !fence;
430
+ }
431
+ const h = fence ? null : /^(#{1,6})\s+(.*)$/.exec(lines[i]);
432
+ if (!h) {
433
+ continue;
434
+ }
435
+ if (start >= 0 && h[1].length <= level) {
436
+ return lines.slice(start, i).join("\n").trim();
437
+ }
438
+ if (start < 0 && h[2].trim().toLowerCase().includes(want)) {
439
+ start = i;
440
+ level = h[1].length;
441
+ }
442
+ }
443
+ return start >= 0 ? lines.slice(start).join("\n").trim() : null;
444
+ }
445
+ /**
446
+ * A page as a step reads it: front matter (title, path, Kategorien, where it came from, its
447
+ * original, its parent and sub-pages) and its Markdown, or one section of it. Long pages come in
448
+ * parts: `from` continues where the last part ended.
449
+ */
450
+ export async function readPage(projectId, path, opts = {}) {
451
+ const found = await resolvePath(projectId, path);
452
+ if (found?.kind !== "page") {
453
+ throw new ServiceError("not_found", `No page "${path}". project_list or project_search name the pages there are.`);
454
+ }
455
+ const page = await db.query.spacePage.findFirst({ where: eq(schema.spacePage.id, found.id) });
456
+ if (!page) {
457
+ throw notFound();
458
+ }
459
+ const tree = await pageTree(projectId);
460
+ const node = tree.get(page.id);
461
+ const categories = await categoryRows(projectId);
462
+ const lines = categoryLines(categories, (await itemCategoriesOf([{ pageId: page.id }])).get(`p:${page.id}`));
463
+ const children = [...tree.values()]
464
+ .filter((n) => n.parentId === page.id)
465
+ .sort((a, b) => a.position - b.position)
466
+ .map((n) => n.path);
467
+ const original = page.fileId ? (await filePaths(projectId)).get(page.fileId) : undefined;
468
+ const front = [
469
+ "---",
470
+ `title: ${yamlValue(page.title)}`,
471
+ `path: ${found.path}`,
472
+ ...(node?.parentId ? [`parent: ${tree.get(node.parentId)?.path}`] : []),
473
+ ...(lines.length
474
+ ? [
475
+ "kategorien:",
476
+ ...lines.map((l) => ` ${l.replace(/^([^:]+): (.*)$/, (_m, k, v) => `${yamlValue(k)}: ${yamlValue(v)}`)}`),
477
+ ]
478
+ : []),
479
+ ...(page.originLabel ? [`source: ${yamlValue(page.originLabel)}`] : []),
480
+ ...(original ? [`original: ${original}`] : []),
481
+ ...(children.length ? ["sub-pages:", ...children.slice(0, 200).map((c) => ` - ${c}`)] : []),
482
+ ...(children.length > 200 ? [` # … ${children.length - 200} more, see project_list`] : []),
483
+ "---",
484
+ ].join("\n");
485
+ let text = page.markdown;
486
+ if (opts.section) {
487
+ const section = sectionOf(text, opts.section);
488
+ if (section === null) {
489
+ throw new ServiceError("not_found", `No section "${opts.section}" on ${found.path}.`);
490
+ }
491
+ text = section;
492
+ }
493
+ const start = Math.min(Math.max(0, opts.from ?? 0), text.length);
494
+ const part = text.slice(start, start + PART);
495
+ return {
496
+ path: found.path,
497
+ page: `${front}\n\n${part}`,
498
+ next: start + part.length < text.length ? start + part.length : null,
499
+ length: text.length,
500
+ };
501
+ }
502
+ /** A cell as a table's filter compares it. */
503
+ function matchesCell(column, cell, w) {
504
+ const shown = formatTableCell(column, cell, "de").toLowerCase();
505
+ if (Array.isArray(w)) {
506
+ return w.some((v) => matchesCell(column, cell, v));
507
+ }
508
+ if (typeof w === "object" && w !== null) {
509
+ if (w.contains !== undefined) {
510
+ return shown.includes(w.contains.toLowerCase());
511
+ }
512
+ if (w.gte !== undefined || w.lte !== undefined) {
513
+ const n = typeof cell === "number" ? cell : numberOf(String(cell ?? ""));
514
+ return (n !== null && (w.gte === undefined || n >= w.gte) && (w.lte === undefined || n <= w.lte));
515
+ }
516
+ if (w.after !== undefined || w.before !== undefined) {
517
+ const at = dateOf(cell);
518
+ const after = w.after ? dateOf(w.after) : null;
519
+ const before = w.before ? dateOf(w.before) : null;
520
+ return (at !== null &&
521
+ (after === null || at >= after) &&
522
+ (before === null || at < before + 86_400_000));
523
+ }
524
+ return false;
525
+ }
526
+ if (typeof w === "number") {
527
+ return (typeof cell === "number" ? cell : numberOf(String(cell ?? ""))) === w;
528
+ }
529
+ if (typeof w === "boolean") {
530
+ return cell === w;
531
+ }
532
+ return (shown === String(w).toLowerCase() ||
533
+ String(cell ?? "").toLowerCase() === String(w).toLowerCase());
534
+ }
535
+ const csvCell = (value) => /[",\n;]/.test(value) ? `"${value.replace(/"/g, '""')}"` : value;
536
+ /**
537
+ * A table as a step reads it: its rows as CSV, filtered by its columns (by their names), a few
538
+ * hundred at a time.
539
+ */
540
+ export async function readTable(projectId, path, opts = {}) {
541
+ const found = await resolvePath(projectId, path);
542
+ if (found?.kind !== "table") {
543
+ throw new ServiceError("not_found", `No table "${path}". project_list or project_search name the tables there are.`);
544
+ }
545
+ const table = await db.query.spaceTable.findFirst({ where: eq(schema.spaceTable.id, found.id) });
546
+ if (!table) {
547
+ throw notFound();
548
+ }
549
+ const byName = (name) => table.columns.find((c) => c.name.toLowerCase() === name.trim().toLowerCase() || c.id === name.trim());
550
+ const filters = Object.entries(opts.where ?? {}).map(([name, w]) => {
551
+ const column = byName(name);
552
+ if (!column) {
553
+ throw new ServiceError("invalid", `No column "${name}" in ${found.path}. It has: ${table.columns.map((c) => c.name).join(", ")}.`);
554
+ }
555
+ return { column, w };
556
+ });
557
+ const shown = opts.columns?.length
558
+ ? opts.columns.map((name) => {
559
+ const column = byName(name);
560
+ if (!column) {
561
+ throw new ServiceError("invalid", `No column "${name}" in ${found.path}.`);
562
+ }
563
+ return column;
564
+ })
565
+ : table.columns;
566
+ const rows = (await db.query.spaceTableRow.findMany({
567
+ where: eq(schema.spaceTableRow.tableId, table.id),
568
+ orderBy: [asc(schema.spaceTableRow.createdAt), asc(schema.spaceTableRow.id)],
569
+ })).filter((row) => filters.every((f) => matchesCell(f.column, row.cells[f.column.id], f.w)));
570
+ const limit = Math.min(Math.max(1, opts.limit ?? 200), 1000);
571
+ const offset = Math.max(0, opts.offset ?? 0);
572
+ const part = rows.slice(offset, offset + limit);
573
+ const csv = [
574
+ shown.map((c) => csvCell(c.name)).join(","),
575
+ ...part.map((row) => shown.map((c) => csvCell(formatTableCell(c, row.cells[c.id], "de"))).join(",")),
576
+ ].join("\n");
577
+ return {
578
+ path: found.path,
579
+ title: table.title,
580
+ format: table.format,
581
+ columns: table.columns.map((c) => `${c.name} (${c.type})`),
582
+ rows: rows.length,
583
+ shown: part.length,
584
+ next: offset + part.length < rows.length ? offset + part.length : null,
585
+ csv,
586
+ };
587
+ }
588
+ /**
589
+ * For project_list: without a filter, the Kategorien with their values and how many items each
590
+ * has; with one, the items it leaves, with their paths and Kategorien.
591
+ */
592
+ export async function listForStep(projectId, where) {
593
+ const categories = await listCategories(projectId);
594
+ const taken = categories.filter((c) => !c.proposed);
595
+ if (!Object.keys(where ?? {}).length) {
596
+ return {
597
+ kategorien: taken.map((c) => ({
598
+ name: c.name,
599
+ type: c.type,
600
+ ...(c.unit ? { unit: c.unit } : {}),
601
+ items: c.count,
602
+ ...(c.type === "choice"
603
+ ? { values: c.values.slice(0, 60).map((v) => `${v.value} (${v.count})`) }
604
+ : {}),
605
+ ...(c.range ? { range: `${c.range.min} … ${c.range.max}` } : {}),
606
+ })),
607
+ hint: 'Pass `where` to list the items of values, e.g. { "Baustelle": "Graz Süd" }.',
608
+ };
609
+ }
610
+ const filtered = await filterItems(projectId, where);
611
+ const [tree, tPaths, fPaths] = await Promise.all([
612
+ pageTree(projectId),
613
+ tablePaths(projectId),
614
+ filePaths(projectId),
615
+ ]);
616
+ const refs = [
617
+ ...[...(filtered?.pages ?? [])].map((pageId) => ({ pageId })),
618
+ ...[...(filtered?.tables ?? [])].map((tableId) => ({ tableId })),
619
+ ...[...(filtered?.files ?? [])].map((fileId) => ({ fileId })),
620
+ ];
621
+ const cats = await itemCategoriesOf(refs);
622
+ // Rows whose table is not left as a whole: by their table, with how many.
623
+ const rowTables = new Map();
624
+ const looseRows = [...(filtered?.rows ?? [])];
625
+ for (let i = 0; i < looseRows.length; i += 500) {
626
+ for (const r of await db.query.spaceTableRow.findMany({
627
+ where: inArray(schema.spaceTableRow.id, looseRows.slice(i, i + 500)),
628
+ columns: { tableId: true },
629
+ })) {
630
+ if (!filtered?.tables.has(r.tableId)) {
631
+ rowTables.set(r.tableId, (rowTables.get(r.tableId) ?? 0) + 1);
632
+ }
633
+ }
634
+ }
635
+ const items = [
636
+ ...[...(filtered?.pages ?? [])]
637
+ .map((id) => tree.get(id))
638
+ .filter((n) => Boolean(n))
639
+ .sort((a, b) => a.path.localeCompare(b.path))
640
+ .map((n) => ({
641
+ path: n.path,
642
+ title: n.title,
643
+ kategorien: categoryLines(taken, cats.get(`p:${n.id}`)),
644
+ })),
645
+ ...[...(filtered?.tables ?? [])].map((id) => ({
646
+ path: tPaths.get(id) ?? "",
647
+ title: "",
648
+ kategorien: categoryLines(taken, cats.get(`t:${id}`)),
649
+ })),
650
+ ...[...rowTables].map(([id, n]) => ({
651
+ path: tPaths.get(id) ?? "",
652
+ title: `${n} rows match: read them with table_read and the same values as its columns`,
653
+ kategorien: [],
654
+ })),
655
+ ...[...(filtered?.files ?? [])].map((id) => ({
656
+ path: fPaths.get(id) ?? "",
657
+ title: "",
658
+ kategorien: categoryLines(taken, cats.get(`f:${id}`)),
659
+ })),
660
+ ];
661
+ return { items: items.slice(0, 200), total: items.length };
662
+ }
663
+ /** How much Wissen a space holds: what the block of the steps' instructions says. */
664
+ export async function knowledgeCounts(projectId) {
665
+ const [pages, tables, files] = await Promise.all([
666
+ db
667
+ .select({ n: count() })
668
+ .from(schema.spacePage)
669
+ .where(and(eq(schema.spacePage.projectId, projectId), isNull(schema.spacePage.wizardId))),
670
+ db
671
+ .select({ n: count() })
672
+ .from(schema.spaceTable)
673
+ .where(and(eq(schema.spaceTable.projectId, projectId), isNull(schema.spaceTable.wizardId))),
674
+ db
675
+ .select({ n: count() })
676
+ .from(schema.projectFile)
677
+ .where(and(eq(schema.projectFile.projectId, projectId), eq(schema.projectFile.kind, "document"))),
678
+ ]);
679
+ return { pages: pages[0].n, tables: tables[0].n, files: files[0].n };
680
+ }
681
+ /**
682
+ * What every step of the space is told about Wissen: how much there is, the Kategorien to filter
683
+ * by, the tools to find and read. Empty when Wissen is.
684
+ */
685
+ export async function knowledgeBlock(projectId) {
686
+ const counts = await knowledgeCounts(projectId);
687
+ if (!(counts.pages || counts.tables || counts.files)) {
688
+ return "";
689
+ }
690
+ const categories = (await listCategories(projectId)).filter((c) => !c.proposed && c.count);
691
+ const what = [
692
+ counts.pages ? `${counts.pages} pages` : "",
693
+ counts.tables ? `${counts.tables} tables` : "",
694
+ counts.files ? `${counts.files} files` : "",
695
+ ]
696
+ .filter(Boolean)
697
+ .join(", ");
698
+ const lines = categories.slice(0, 30).map((c) => {
699
+ const type = `${c.type}${c.unit ? `, ${c.unit}` : ""}`;
700
+ const values = c.type === "choice"
701
+ ? `: ${c.values
702
+ .slice(0, 12)
703
+ .map((v) => v.value)
704
+ .join(", ")}${c.values.length > 12 ? ` … (${c.values.length} values)` : ""}`
705
+ : c.range
706
+ ? `: ${c.range.min} … ${c.range.max}`
707
+ : "";
708
+ return `- ${c.name} (${type})${values}`;
709
+ });
710
+ return [
711
+ "# WISSEN — WHAT THE SPACE KNOWS",
712
+ `Ground truth the space's owner keeps for all wizards: ${what}. Use it when the task touches what it covers, and prefer it to what you remember.`,
713
+ ...(lines.length
714
+ ? [
715
+ "Kategorien, to filter with `where` in project_search and project_list before searching:",
716
+ ...lines,
717
+ ]
718
+ : []),
719
+ "Find with project_search (a question; `where` on Kategorien). List with project_list. Read whole with page_read and table_read by path. A page marks the pages of its original as <!-- S. n -->: quote them when the source matters.",
720
+ ].join("\n");
721
+ }