wizards 0.2.17 → 0.2.19

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 (68) 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-O6OTAY88.js} +1 -1
  26. package/apps/web/dist/assets/{ChangelogDialog-DM0KAoy6.js → ChangelogDialog-DLrXuyLL.js} +1 -1
  27. package/apps/web/dist/assets/{ChatPanel-CxOQmtv-.js → ChatPanel-BPD-iQK0.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-Dtu9j41h.js} +1 -1
  30. package/apps/web/dist/assets/{FlowPreview-v7or29ku.js → FlowPreview-DB-86F8z.js} +1 -1
  31. package/apps/web/dist/assets/{Harness-RZF-bBmb.js → Harness-D-DSa4ip.js} +1 -1
  32. package/apps/web/dist/assets/{HomePage-mxG-a_KU.js → HomePage-ZrCxIiJT.js} +1 -1
  33. package/apps/web/dist/assets/{ImportWizard-CiPyUt3E.js → ImportWizard-DtZH-PgS.js} +1 -1
  34. package/apps/web/dist/assets/{LocalRuntime-Dqp9wkN3.js → LocalRuntime-o1OAvp7c.js} +1 -1
  35. package/apps/web/dist/assets/NewWizardPage-Bbt-w8tE.js +2 -0
  36. package/apps/web/dist/assets/ProjectPage-MHxFqA98.js +5 -0
  37. package/apps/web/dist/assets/{PublicRunner-TGkOwbAP.js → PublicRunner-CYo2F8H4.js} +1 -1
  38. package/apps/web/dist/assets/{RunnerView-DzHGshZj.js → RunnerView-Bqp0paLi.js} +1 -1
  39. package/apps/web/dist/assets/{SettingsPage-CG3Xtfej.js → SettingsPage-XyRrZeMr.js} +1 -1
  40. package/apps/web/dist/assets/{SetupPage-O1GfPlqX.js → SetupPage-CdBYFmh1.js} +1 -1
  41. package/apps/web/dist/assets/{SharePage-CCNUhtry.js → SharePage-B7ON0pe1.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-B2U80Wtl.js +1 -0
  44. package/apps/web/dist/assets/{esm-3TT9i_rv.js → esm-DutMJAod.js} +1 -1
  45. package/apps/web/dist/assets/index-Bwyehu0k.css +1 -0
  46. package/apps/web/dist/assets/{index-CLe9Wrpu.js → index-hpNaR92X.js} +3 -3
  47. package/apps/web/dist/assets/{meta-CEsBCPfA.js → meta--kpiTOS-.js} +1 -1
  48. package/apps/web/dist/assets/{settings-menu-pOcjznGJ.js → settings-menu-BXpT5jOB.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/marketplace.d.ts +13 -1
  56. package/node_modules/@engenty-wizards/shared/dist/marketplace.js +3 -0
  57. package/node_modules/@engenty-wizards/shared/dist/projects.d.ts +1 -16
  58. package/node_modules/@engenty-wizards/shared/dist/projects.js +1 -1
  59. package/node_modules/@engenty-wizards/shared/dist/run.d.ts +8 -0
  60. package/node_modules/@engenty-wizards/shared/dist/run.js +5 -0
  61. package/node_modules/@engenty-wizards/shared/dist/space-data.d.ts +20 -0
  62. package/node_modules/@engenty-wizards/shared/dist/space-data.js +2 -1
  63. package/package.json +1 -1
  64. package/apps/web/dist/assets/NewWizardPage-DHwRsi0_.js +0 -2
  65. package/apps/web/dist/assets/ProjectPage-Bb7jN_6n.js +0 -4
  66. package/apps/web/dist/assets/changelog-BkIJdZ7M.js +0 -1
  67. package/apps/web/dist/assets/index-BPpKz65x.css +0 -1
  68. package/apps/web/dist/assets/ui-I7QYMX0F.js +0 -1
@@ -0,0 +1,164 @@
1
+ import { and, eq } from "drizzle-orm";
2
+ import { db, schema } from "../db/client.js";
3
+ import { UnreadableDocument } from "../documents/parse.js";
4
+ import { ServiceError } from "./errors.js";
5
+ import { filePaths, pageTree, tablePaths } from "./knowledge.js";
6
+ import { convertDocument, fillCategories, summarizeValues } from "./knowledge-model.js";
7
+ import { putOriginFile } from "./project-files.js";
8
+ import { queueIndex } from "./project-index.js";
9
+ import { putCategory, setItemCategories } from "./space-categories.js";
10
+ import { listOrigin, putOriginPage, putOriginTable, removeOrigin } from "./space-data.js";
11
+ /**
12
+ * `server.spaceData` of a plugin: Wissen it writes under keys of its own, with Kategorien. The
13
+ * plugin's id is the origin of everything it writes; a person who changes an item keeps it.
14
+ */
15
+ export function originData(origin) {
16
+ return {
17
+ async putCategories({ space, categories, proposed }) {
18
+ for (const c of categories) {
19
+ let tableId = null;
20
+ if (c.column) {
21
+ const table = await db.query.spaceTable.findFirst({
22
+ where: and(eq(schema.spaceTable.projectId, space), eq(schema.spaceTable.origin, origin), eq(schema.spaceTable.originKey, c.column.table)),
23
+ columns: { id: true },
24
+ });
25
+ if (!table) {
26
+ throw new ServiceError("invalid", `There is no table with the key "${c.column.table}".`);
27
+ }
28
+ tableId = table.id;
29
+ }
30
+ await putCategory(space, {
31
+ name: c.name,
32
+ type: c.type,
33
+ unit: c.unit,
34
+ multiple: c.multiple,
35
+ ordered: c.ordered,
36
+ hint: c.hint,
37
+ values: c.values,
38
+ tableId,
39
+ columnId: c.column?.column ?? null,
40
+ }, { origin, proposed });
41
+ }
42
+ },
43
+ async putPage(input) {
44
+ const written = await putOriginPage(origin, {
45
+ ...input,
46
+ categories: input.categories,
47
+ });
48
+ if (input.fill && written.state === "written") {
49
+ const changed = await fillCategories(input.space, { pageId: written.id }, input.markdown, input.title).catch((err) => {
50
+ console.error(`[plugin ${origin}] Kategorien`, err);
51
+ return false;
52
+ });
53
+ if (changed) {
54
+ queueIndex(`P:${written.id}`);
55
+ }
56
+ }
57
+ return { ...written, path: (await pageTree(input.space)).get(written.id)?.path ?? "" };
58
+ },
59
+ async putTable(input) {
60
+ const written = await putOriginTable(origin, {
61
+ ...input,
62
+ categories: input.categories,
63
+ });
64
+ return { ...written, path: (await tablePaths(input.space)).get(written.id) ?? "" };
65
+ },
66
+ async putFile(input) {
67
+ const id = await putOriginFile(origin, {
68
+ ...input,
69
+ categories: input.categories,
70
+ });
71
+ return { id, path: (await filePaths(input.space)).get(id) ?? "" };
72
+ },
73
+ async putDocument(input) {
74
+ const id = await putOriginFile(origin, { ...input, categories: undefined });
75
+ const file = await db.query.projectFile.findFirst({ where: eq(schema.projectFile.id, id) });
76
+ if (!file) {
77
+ throw new ServiceError("not_found", "The document went while it was read.");
78
+ }
79
+ const path = (await filePaths(input.space)).get(id) ?? "";
80
+ const kept = (await db.query.spacePage.findFirst({
81
+ where: and(eq(schema.spacePage.fileId, id), eq(schema.spacePage.kept, true)),
82
+ columns: { id: true },
83
+ })) ??
84
+ (await db.query.spaceTable.findFirst({
85
+ where: and(eq(schema.spaceTable.fileId, id), eq(schema.spaceTable.kept, true)),
86
+ columns: { id: true },
87
+ }));
88
+ if (kept) {
89
+ return { id, path, state: "kept", items: [], review: null };
90
+ }
91
+ let converted;
92
+ try {
93
+ converted = await convertDocument(file, input.data);
94
+ }
95
+ catch (err) {
96
+ if (err instanceof UnreadableDocument) {
97
+ queueIndex(`f:${id}`);
98
+ return { id, path, state: "written", items: [], review: err.message };
99
+ }
100
+ throw err;
101
+ }
102
+ await db
103
+ .update(schema.projectFile)
104
+ .set({
105
+ textHash: converted.textHash,
106
+ pages: converted.pages,
107
+ chars: converted.chars,
108
+ updatedAt: new Date(),
109
+ })
110
+ .where(eq(schema.projectFile.id, id));
111
+ const [tree, tables] = await Promise.all([pageTree(input.space), tablePaths(input.space)]);
112
+ const items = await Promise.all(converted.items.map(async (key) => {
113
+ const itemId = key.slice(2);
114
+ if (key[0] === "p") {
115
+ const node = tree.get(itemId);
116
+ return {
117
+ kind: "page",
118
+ id: itemId,
119
+ path: node?.path ?? "",
120
+ title: node?.title ?? "",
121
+ };
122
+ }
123
+ const table = await db.query.spaceTable.findFirst({
124
+ where: eq(schema.spaceTable.id, itemId),
125
+ columns: { title: true },
126
+ });
127
+ return {
128
+ kind: "table",
129
+ id: itemId,
130
+ path: tables.get(itemId) ?? "",
131
+ title: table?.title ?? "",
132
+ };
133
+ }));
134
+ const first = items[0];
135
+ const ref = first
136
+ ? first.kind === "page"
137
+ ? { pageId: first.id }
138
+ : { tableId: first.id }
139
+ : { fileId: id };
140
+ if (input.categories) {
141
+ await setItemCategories(input.space, ref, input.categories, "origin");
142
+ }
143
+ if (input.fill) {
144
+ await fillCategories(input.space, ref, converted.text, input.name).catch((err) => {
145
+ console.error(`[plugin ${origin}] Kategorien`, err);
146
+ return false;
147
+ });
148
+ }
149
+ if (input.categories || input.fill) {
150
+ queueIndex(first ? `${first.kind === "page" ? "P" : "t"}:${first.id}` : `f:${id}`);
151
+ }
152
+ const review = first?.kind === "page"
153
+ ? ((await db.query.spacePage.findFirst({
154
+ where: eq(schema.spacePage.id, first.id),
155
+ columns: { review: true },
156
+ }))?.review ?? null)
157
+ : null;
158
+ return { id, path, state: "written", items, review };
159
+ },
160
+ list: (space) => listOrigin(origin, space),
161
+ remove: (space, key) => removeOrigin(origin, space, key),
162
+ summarize: (space, category, value) => summarizeValues(space, category, value),
163
+ };
164
+ }
@@ -1,5 +1,6 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { tableColumnsSchema } from "@engenty-wizards/shared/engenty/data-tables";
3
+ import { CATEGORY_TYPES } from "@engenty-wizards/shared/knowledge";
3
4
  import { PROJECT_LIMITS } from "@engenty-wizards/shared/projects";
4
5
  import { SPACE_DATA_LIMITS } from "@engenty-wizards/shared/space-data";
5
6
  import { WORKSPACE_LIMITS } from "@engenty-wizards/shared/workspace";
@@ -17,6 +18,7 @@ import { emitDraftChanged } from "./draft-events.js";
17
18
  import { notFound, ServiceError } from "./errors.js";
18
19
  import { keepFiles, replaceFiles } from "./files.js";
19
20
  import { addProjectFile, projectFiles, removeProjectFile } from "./project-files.js";
21
+ import { reindexSpace } from "./project-index.js";
20
22
  import { brandColorSchema, projectFactSchema, projectLimit, removeProject, } from "./projects.js";
21
23
  import { deleteWizard, draftIssues, parseDraft, rotateShareLink, shareUrl } from "./wizards.js";
22
24
  /**
@@ -411,11 +413,15 @@ export async function removeSyncedSpace(rawSpaceId) {
411
413
  // --- the space's own tables and pages --------------------------------------------
412
414
  // What the admin made under Space → Daten on the install, not what a wizard keeps: those stay
413
415
  // where their runs write them. Sent as a whole, it replaces what came before.
416
+ // Since Wissen: sub-pages, where an item came from, Kategorien and what has them. An install of
417
+ // before sends none of it.
414
418
  export const syncDataSchema = z.object({
415
419
  tables: z
416
420
  .array(z.object({
417
421
  id: syncedId,
418
422
  title: z.string().min(1).max(120),
423
+ format: z.enum(["faq"]).nullish(),
424
+ originLabel: z.string().max(120).nullish(),
419
425
  columns: tableColumnsSchema,
420
426
  rows: z
421
427
  .array(z.object({ id: syncedId, cells: z.record(z.string(), z.unknown()) }))
@@ -427,8 +433,48 @@ export const syncDataSchema = z.object({
427
433
  id: syncedId,
428
434
  title: z.string().min(1).max(200),
429
435
  markdown: z.string().max(SPACE_DATA_LIMITS.pageChars),
436
+ parentId: syncedId.nullish(),
437
+ position: z.number().int().min(0).optional(),
438
+ originLabel: z.string().max(120).nullish(),
430
439
  }))
431
440
  .max(SPACE_DATA_LIMITS.pages),
441
+ categories: z
442
+ .array(z.object({
443
+ id: syncedId,
444
+ name: z.string().min(1).max(80),
445
+ type: z.enum(CATEGORY_TYPES),
446
+ unit: z.string().max(20).nullish(),
447
+ multiple: z.boolean(),
448
+ ordered: z.boolean(),
449
+ hint: z.string().max(120).nullish(),
450
+ tableId: syncedId.nullish(),
451
+ columnId: z.string().max(64).nullish(),
452
+ position: z.number().int().min(0),
453
+ values: z
454
+ .array(z.object({
455
+ id: syncedId,
456
+ value: z.string().min(1).max(200),
457
+ position: z.number().int().min(0),
458
+ summary: z.string().max(20_000).nullish(),
459
+ }))
460
+ .max(2000),
461
+ }))
462
+ .max(60)
463
+ .optional(),
464
+ /** `p:<page>`, `t:<table>`, `r:<row>`: files stay with the install. */
465
+ assignments: z
466
+ .array(z.object({
467
+ categoryId: syncedId,
468
+ item: z.string().regex(/^[ptr]:[A-Za-z0-9_-]{8,40}$/),
469
+ valueId: syncedId.nullish(),
470
+ text: z.string().max(300).nullish(),
471
+ num: z.number().nullish(),
472
+ at: z.number().int().nullish(),
473
+ bool: z.boolean().nullish(),
474
+ by: z.enum(["person", "origin", "model"]),
475
+ }))
476
+ .max(200_000)
477
+ .optional(),
432
478
  });
433
479
  /**
434
480
  * Takes the space's own tables and pages a local install sent: those it no longer has go, the
@@ -491,12 +537,120 @@ export async function syncSpaceData(rawSpaceId, input) {
491
537
  }
492
538
  }
493
539
  for (const page of input.pages) {
494
- const values = { title: page.title, markdown: page.markdown, updatedAt: now };
540
+ const values = {
541
+ title: page.title,
542
+ markdown: page.markdown,
543
+ position: page.position ?? 0,
544
+ originLabel: page.originLabel ?? null,
545
+ // Its place comes after every page is there.
546
+ parentId: null,
547
+ slug: null,
548
+ updatedAt: now,
549
+ };
495
550
  await db
496
551
  .insert(schema.spacePage)
497
552
  .values({ id: page.id, tenantId: currentTenant(), projectId: spaceId, ...values })
498
553
  .onConflictDoUpdate({ target: schema.spacePage.id, set: values });
499
554
  }
555
+ const pagesThere = new Set(pageIds);
556
+ for (const page of input.pages) {
557
+ if (page.parentId && pagesThere.has(page.parentId) && page.parentId !== page.id) {
558
+ await db
559
+ .update(schema.spacePage)
560
+ .set({ parentId: page.parentId })
561
+ .where(eq(schema.spacePage.id, page.id));
562
+ }
563
+ }
564
+ for (const table of input.tables) {
565
+ await db
566
+ .update(schema.spaceTable)
567
+ .set({ format: table.format ?? null, originLabel: table.originLabel ?? null, slug: null })
568
+ .where(eq(schema.spaceTable.id, table.id));
569
+ }
570
+ if (input.categories) {
571
+ await syncCategories(spaceId, input.categories, input.assignments ?? []);
572
+ }
500
573
  });
574
+ await reindexSpace(spaceId);
501
575
  return { tables: input.tables.length, pages: input.pages.length };
502
576
  }
577
+ /** The Kategorien a local install sent, their values, and what has them: they replace these here. */
578
+ async function syncCategories(spaceId, categories, assignments) {
579
+ const ids = categories.map((c) => c.id);
580
+ const taken = ids.length
581
+ ? await db.query.spaceCategory.findMany({
582
+ where: inArray(schema.spaceCategory.id, ids),
583
+ columns: { id: true, projectId: true },
584
+ })
585
+ : [];
586
+ if (taken.some((c) => c.projectId !== spaceId)) {
587
+ throw new ServiceError("refused", "Eine Kennung ist hier schon vergeben.", {
588
+ reason: "id_taken",
589
+ });
590
+ }
591
+ await db
592
+ .delete(schema.spaceCategory)
593
+ .where(and(eq(schema.spaceCategory.projectId, spaceId), ids.length ? notInArray(schema.spaceCategory.id, ids) : undefined));
594
+ await db.delete(schema.spaceItemCategory).where(eq(schema.spaceItemCategory.projectId, spaceId));
595
+ const tables = new Set((await db.query.spaceTable.findMany({
596
+ where: eq(schema.spaceTable.projectId, spaceId),
597
+ columns: { id: true },
598
+ })).map((t) => t.id));
599
+ const values = new Set();
600
+ for (const c of categories) {
601
+ const row = {
602
+ name: c.name,
603
+ type: c.type,
604
+ unit: c.unit ?? null,
605
+ multiple: c.multiple,
606
+ ordered: c.ordered,
607
+ hint: c.hint ?? null,
608
+ proposed: false,
609
+ tableId: c.tableId && tables.has(c.tableId) ? c.tableId : null,
610
+ columnId: c.tableId && tables.has(c.tableId) ? (c.columnId ?? null) : null,
611
+ position: c.position,
612
+ updatedAt: new Date(),
613
+ };
614
+ await db
615
+ .insert(schema.spaceCategory)
616
+ .values({ id: c.id, tenantId: currentTenant(), projectId: spaceId, ...row })
617
+ .onConflictDoUpdate({ target: schema.spaceCategory.id, set: row });
618
+ const valueIds = c.values.map((v) => v.id);
619
+ await db
620
+ .delete(schema.spaceCategoryValue)
621
+ .where(and(eq(schema.spaceCategoryValue.categoryId, c.id), valueIds.length ? notInArray(schema.spaceCategoryValue.id, valueIds) : undefined));
622
+ for (const v of c.values) {
623
+ const set = {
624
+ value: v.value,
625
+ position: v.position,
626
+ summary: v.summary ?? null,
627
+ summaryAt: v.summary ? new Date() : null,
628
+ };
629
+ await db
630
+ .insert(schema.spaceCategoryValue)
631
+ .values({ id: v.id, categoryId: c.id, ...set })
632
+ .onConflictDoUpdate({ target: schema.spaceCategoryValue.id, set });
633
+ values.add(v.id);
634
+ }
635
+ }
636
+ const known = new Set(ids);
637
+ const rows = assignments.filter((a) => known.has(a.categoryId) && (!a.valueId || values.has(a.valueId)));
638
+ for (let i = 0; i < rows.length; i += 200) {
639
+ await db.insert(schema.spaceItemCategory).values(rows.slice(i, i + 200).map((a) => {
640
+ const id = a.item.slice(2);
641
+ return {
642
+ projectId: spaceId,
643
+ categoryId: a.categoryId,
644
+ pageId: a.item[0] === "p" ? id : null,
645
+ tableId: a.item[0] === "t" ? id : null,
646
+ rowId: a.item[0] === "r" ? id : null,
647
+ valueId: a.valueId ?? null,
648
+ text: a.text ?? null,
649
+ num: a.num ?? null,
650
+ at: a.at ?? null,
651
+ bool: a.bool ?? null,
652
+ by: a.by,
653
+ };
654
+ }));
655
+ }
656
+ }
@@ -1,11 +1,12 @@
1
1
  import { createTool } from "@mastra/core/tools";
2
2
  import { z } from "zod";
3
3
  import { runPages, writeRunPage } from "../services/space-data.js";
4
+ import { pageReadInput, readKnowledgePage } from "./project.js";
4
5
  import { attempt, clip } from "./shared.js";
5
6
  /**
6
- * Pages of the space for a step that lists "pages": Markdown its wizard keeps for every run —
7
- * notes, a log, a summary that grows — and the space's own pages to read. They stand in the
8
- * studio under Space → Daten.
7
+ * Pages for a step that lists "pages": Markdown its wizard keeps for every run — notes, a log, a
8
+ * summary that grows — shown in the studio under Space → Daten. Its page_read also reads the
9
+ * pages of Wissen, by path.
9
10
  */
10
11
  export function pageTools(ctx) {
11
12
  const projectId = ctx.project.id;
@@ -13,29 +14,33 @@ export function pageTools(ctx) {
13
14
  return {
14
15
  page_read: createTool({
15
16
  id: "page_read",
16
- description: "Read a page of the space: one this wizard wrote, or one of the space's own. Without a title it lists the pages there are.",
17
- inputSchema: z.object({ title: z.string().optional() }),
18
- execute: ({ title }) => attempt(async () => {
19
- const pages = await runPages(projectId, wizardId);
20
- const of = (p) => (p.wizardId ? "wizard" : "space");
21
- if (!title?.trim()) {
22
- return {
23
- pages: pages.map((p) => ({ title: p.title, of: of(p), chars: p.markdown.length })),
24
- };
25
- }
26
- const page = pages.find((p) => p.title.toLowerCase() === title.trim().toLowerCase());
27
- if (!page) {
28
- return {
29
- error: `No page "${title}".`,
30
- pages: pages.map((p) => p.title),
31
- };
32
- }
33
- return { title: page.title, of: of(page), markdown: clip(page.markdown, 40_000) };
17
+ description: "Read a page: one this wizard wrote, by its title, or a page of Wissen, by its path (pages/…). Without either it lists the wizard's pages.",
18
+ inputSchema: z.object({
19
+ title: z.string().optional().describe("A page this wizard wrote"),
20
+ path: pageReadInput.path.optional(),
21
+ section: pageReadInput.section,
22
+ from: pageReadInput.from,
34
23
  }),
24
+ execute: ({ title, path, section, from }) => {
25
+ if (path?.trim()) {
26
+ return readKnowledgePage(ctx, { path, section, from });
27
+ }
28
+ return attempt(async () => {
29
+ const pages = await runPages(projectId, wizardId);
30
+ if (!title?.trim()) {
31
+ return { pages: pages.map((p) => ({ title: p.title, chars: p.markdown.length })) };
32
+ }
33
+ const page = pages.find((p) => p.title.toLowerCase() === title.trim().toLowerCase());
34
+ if (!page) {
35
+ return { error: `No page "${title}".`, pages: pages.map((p) => p.title) };
36
+ }
37
+ return { title: page.title, markdown: clip(page.markdown, 40_000) };
38
+ });
39
+ },
35
40
  }),
36
41
  page_write: createTool({
37
42
  id: "page_write",
38
- description: "Write a page of this wizard in Markdown, kept for every later run and shown to the admin in the studio. A page with the same title is replaced — or added to with append: true (a log, a list that grows). The space's own pages are read-only.",
43
+ description: "Write a page of this wizard in Markdown, kept for every later run and shown to the admin in the studio. A page with the same title is replaced — or added to with append: true (a log, a list that grows). Pages of Wissen are read-only.",
39
44
  inputSchema: z.object({
40
45
  title: z.string().min(1).max(200),
41
46
  markdown: z.string().max(200_000),
@@ -1,64 +1,154 @@
1
+ import { whereSchema } from "@engenty-wizards/shared/knowledge";
1
2
  import { createTool } from "@mastra/core/tools";
3
+ import { and, eq } from "drizzle-orm";
2
4
  import { z } from "zod";
3
- import { documentText, hasIndexEntries, searchProject } from "../services/project-index.js";
5
+ import { db, schema } from "../db/client.js";
6
+ import { filePaths, knowledgeCounts, listForStep, readPage, readTable, resolvePath, } from "../services/knowledge.js";
7
+ import { documentText, hasIndexEntries, searchKnowledge } from "../services/project-index.js";
4
8
  import { attempt, clip } from "./shared.js";
5
9
  /**
6
- * The project's documents, for every agent step of its wizards: search the index, read one
7
- * whole. The search also finds what the space's plugins put into the index.
10
+ * Wissen, for every agent step of the space's wizards: search it (a question, filtered by
11
+ * Kategorien first, checked by a classifier after), list it by Kategorien, read a page, a table
12
+ * or a file whole by its path. Hits and reads look like files — a path, front matter, Markdown or
13
+ * CSV — so a model reads them as it reads files. The search also finds what the space's plugins
14
+ * put into the index.
8
15
  */
16
+ const where = whereSchema
17
+ .optional()
18
+ .describe('Filter by Kategorien before searching, by their names: a value ("Baustelle": "Graz Süd"), a list of values, a range of numbers ({ "gte": 20000 }) or of days ({ "after": "2026-03-01", "before": "2026-03-31" }), { "contains": "…" } for a text, true/false. project_list without arguments names the Kategorien and their values.');
19
+ export const pageReadInput = {
20
+ path: z.string().describe("The page's path, as project_search or project_list give it: pages/…"),
21
+ section: z
22
+ .string()
23
+ .optional()
24
+ .describe("Only this section: a heading of the page, or the start of one"),
25
+ from: z.number().int().min(0).optional().describe("Character to continue a long page at"),
26
+ };
27
+ /** A page of Wissen by its path, for a step: its front matter and Markdown, in parts. */
28
+ export function readKnowledgePage(ctx, input) {
29
+ return attempt(async () => {
30
+ const read = await readPage(ctx.project.id, input.path, input);
31
+ await ctx.emit("tool", { code: "reads", params: { name: read.path } });
32
+ return read;
33
+ });
34
+ }
9
35
  export async function projectTools(ctx) {
10
- const docs = ctx.projectFiles.filter((f) => f.kind === "document");
11
- const entries = await hasIndexEntries(ctx.project.id);
12
- if (!(docs.length || entries)) {
36
+ const projectId = ctx.project.id;
37
+ const counts = await knowledgeCounts(projectId);
38
+ const entries = await hasIndexEntries(projectId);
39
+ if (!(counts.pages || counts.tables || counts.files || entries)) {
13
40
  return {};
14
41
  }
15
42
  const tools = {
16
43
  project_search: createTool({
17
44
  id: "project_search",
18
- description: "Search the project's documents and what its plugins keep for all wizards (wiki pages, questions and answers), and get the passages that fit best. Ask in their language, with the words they would use.",
45
+ description: "Search Wissen, the space's knowledge: its pages, tables and files, and what its plugins keep (wiki pages, questions and answers). Gives items with their path, Kategorien and best section, each checked for relevance. Ask in their language, with the words they would use; filter with `where` when the task names a Kategorie's value. Then read the items you need whole with page_read or table_read.",
19
46
  inputSchema: z.object({
20
47
  query: z.string().describe("What to look for: a question or keywords"),
21
- limit: z.number().int().min(1).max(12).optional(),
48
+ where,
49
+ limit: z.number().int().min(1).max(12).optional().describe("Items to get, 6 by default"),
22
50
  }),
23
- execute: ({ query, limit }) => attempt(async () => {
51
+ execute: (input) => attempt(async () => {
52
+ const found = await searchKnowledge(projectId, input.query, {
53
+ where: input.where,
54
+ limit: input.limit,
55
+ signal: ctx.signal,
56
+ });
24
57
  await ctx.emit("tool", {
25
- code: "searchesDocuments",
26
- params: { query: query.slice(0, 60) },
58
+ code: "searchesKnowledge",
59
+ params: {
60
+ query: input.query.slice(0, 60),
61
+ relevant: found.hits.length,
62
+ candidates: found.candidates,
63
+ },
27
64
  });
28
- const hits = await searchProject(ctx.project.id, query, limit ?? 6);
29
65
  return {
30
- passages: hits.map((h) => ({ document: h.name, part: h.index, text: h.text })),
66
+ hits: found.hits.map((h) => ({
67
+ ...(h.path ? { path: h.path } : {}),
68
+ title: h.title,
69
+ ...(h.categories.length ? { kategorien: h.categories } : {}),
70
+ ...(h.heading ? { section: h.heading } : {}),
71
+ ...(h.sheet ? { page: h.sheet } : {}),
72
+ ...(h.relevance !== null ? { relevance: h.relevance } : {}),
73
+ text: clip(h.text, 1500),
74
+ })),
75
+ checked: found.checked,
76
+ candidates: found.candidates,
77
+ ...(found.filtered !== null ? { filtered: found.filtered } : {}),
31
78
  };
32
79
  }),
33
80
  }),
81
+ project_list: createTool({
82
+ id: "project_list",
83
+ description: "List Wissen by its Kategorien. Without `where`: the Kategorien with their values and how many items each has. With `where`: the items it leaves, with their paths — all of them, where project_search gives the best few.",
84
+ inputSchema: z.object({ where }),
85
+ execute: (input) => attempt(async () => {
86
+ const listed = await listForStep(projectId, input.where);
87
+ await ctx.emit("tool", { code: "listsKnowledge" });
88
+ return listed;
89
+ }),
90
+ }),
34
91
  };
35
- if (!docs.length) {
36
- return tools;
92
+ if (counts.pages) {
93
+ tools.page_read = createTool({
94
+ id: "page_read",
95
+ description: "Read a page of Wissen whole by its path: front matter (title, Kategorien, its source and original, its parent and sub-pages) and its Markdown. Long pages come in parts: pass `from` to continue. `section` reads one section.",
96
+ inputSchema: z.object(pageReadInput),
97
+ execute: (input) => readKnowledgePage(ctx, input),
98
+ });
99
+ }
100
+ if (counts.tables) {
101
+ tools.table_read = createTool({
102
+ id: "table_read",
103
+ description: "Read a table of Wissen by its path: its rows as CSV, filtered by its columns (by their names, like `where` of project_search). A few hundred rows at a time: pass `offset` to continue.",
104
+ inputSchema: z.object({
105
+ path: z.string().describe("The table's path: tables/…"),
106
+ where: whereSchema.optional().describe("Filter by columns, by their names"),
107
+ columns: z.array(z.string()).max(40).optional().describe("Only these columns"),
108
+ limit: z.number().int().min(1).max(1000).optional(),
109
+ offset: z.number().int().min(0).optional(),
110
+ }),
111
+ execute: (input) => attempt(async () => {
112
+ const read = await readTable(projectId, input.path, input);
113
+ await ctx.emit("tool", { code: "reads", params: { name: read.path } });
114
+ return read;
115
+ }),
116
+ });
117
+ }
118
+ if (counts.files) {
119
+ tools.project_document = createTool({
120
+ id: "project_document",
121
+ description: "Read a file of Wissen as text by its path (files/…): a file that is not a page, or the original a page was read from. Long files come in parts: pass `from` to continue.",
122
+ inputSchema: z.object({
123
+ path: z
124
+ .string()
125
+ .describe("The file's path: files/…, as the page's front matter or a hit names it"),
126
+ from: z.number().int().min(0).optional().describe("Character to start at"),
127
+ }),
128
+ execute: ({ path, from }) => attempt(async () => {
129
+ const found = await resolvePath(projectId, path.startsWith("files/") ? path : `files/${path}`);
130
+ if (found?.kind !== "file") {
131
+ const there = [...(await filePaths(projectId)).values()].slice(0, 40);
132
+ throw new Error(`No file "${path}". There are: ${there.join(", ")}`);
133
+ }
134
+ const file = await db.query.projectFile.findFirst({
135
+ where: and(eq(schema.projectFile.id, found.id), eq(schema.projectFile.projectId, projectId)),
136
+ });
137
+ if (!file) {
138
+ throw new Error(`No file "${path}".`);
139
+ }
140
+ await ctx.emit("tool", { code: "reads", params: { name: file.name } });
141
+ const text = (await documentText(file)) || file.description;
142
+ const start = Math.min(from ?? 0, text.length);
143
+ const part = text.slice(start, start + 12_000);
144
+ return {
145
+ path: found.path,
146
+ text: clip(part, 12_000),
147
+ next: start + part.length < text.length ? start + part.length : null,
148
+ length: text.length,
149
+ };
150
+ }),
151
+ });
37
152
  }
38
- tools.project_document = createTool({
39
- id: "project_document",
40
- description: "Read one of the project's documents as text. Long documents come in parts: pass `from` to continue.",
41
- inputSchema: z.object({
42
- name: z.string().describe("The document's name, as listed"),
43
- from: z.number().int().min(0).optional().describe("Character to start at"),
44
- }),
45
- execute: ({ name, from }) => attempt(async () => {
46
- const doc = docs.find((d) => d.name === name) ??
47
- docs.find((d) => d.name.toLowerCase().includes(name.toLowerCase()));
48
- if (!doc) {
49
- throw new Error(`No document "${name}". There are: ${docs.map((d) => d.name).join(", ")}`);
50
- }
51
- await ctx.emit("tool", { code: "reads", params: { name: doc.name } });
52
- const text = await documentText(doc);
53
- const start = Math.min(from ?? 0, text.length);
54
- const part = text.slice(start, start + 12_000);
55
- return {
56
- document: doc.name,
57
- text: clip(part, 12_000),
58
- next: start + part.length < text.length ? start + part.length : null,
59
- length: text.length,
60
- };
61
- }),
62
- });
63
153
  return tools;
64
154
  }