@pvidaal07/heydb 0.0.0-stage → 2.0.0-beta.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.
Files changed (51) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/LICENSE +21 -0
  3. package/README.md +153 -2
  4. package/extensions/commands.ts +732 -0
  5. package/extensions/context.ts +37 -0
  6. package/extensions/deps.ts +32 -0
  7. package/extensions/heydb.ts +20 -0
  8. package/extensions/secret-input.ts +120 -0
  9. package/extensions/tools.ts +402 -0
  10. package/lib/about.ts +7 -0
  11. package/lib/config/operations.ts +75 -0
  12. package/lib/config/store.ts +73 -0
  13. package/lib/config/types.ts +37 -0
  14. package/lib/config/validate.ts +136 -0
  15. package/lib/engines/index.ts +66 -0
  16. package/lib/engines/mysql/catalog.ts +371 -0
  17. package/lib/engines/mysql/introspect.ts +186 -0
  18. package/lib/engines/postgres/catalog.ts +390 -0
  19. package/lib/engines/postgres/introspect.ts +240 -0
  20. package/lib/knowledge/operations.ts +223 -0
  21. package/lib/knowledge/query.ts +129 -0
  22. package/lib/knowledge/types.ts +64 -0
  23. package/lib/knowledge/validate.ts +151 -0
  24. package/lib/model/diff.ts +218 -0
  25. package/lib/model/hash.ts +88 -0
  26. package/lib/model/names.ts +141 -0
  27. package/lib/model/order.ts +47 -0
  28. package/lib/model/references.ts +251 -0
  29. package/lib/model/snapshot.ts +128 -0
  30. package/lib/model/stats.ts +97 -0
  31. package/lib/model/validate.ts +240 -0
  32. package/lib/render/markdown.ts +508 -0
  33. package/lib/search.ts +530 -0
  34. package/lib/secrets.ts +149 -0
  35. package/lib/store/envfile.ts +124 -0
  36. package/lib/store/files.ts +57 -0
  37. package/lib/store/guards.ts +68 -0
  38. package/lib/store/knowledge.ts +139 -0
  39. package/lib/store/project.ts +28 -0
  40. package/lib/store/snapshots.ts +143 -0
  41. package/lib/usecases/author.ts +51 -0
  42. package/lib/usecases/code-scan.ts +435 -0
  43. package/lib/usecases/init.ts +111 -0
  44. package/lib/usecases/knowledge.ts +214 -0
  45. package/lib/usecases/project.ts +377 -0
  46. package/lib/usecases/resolve.ts +186 -0
  47. package/lib/usecases/sharing.ts +83 -0
  48. package/lib/usecases/status.ts +37 -0
  49. package/lib/usecases/sync.ts +33 -0
  50. package/package.json +69 -5
  51. package/skills/heydb/SKILL.md +67 -0
@@ -0,0 +1,37 @@
1
+ import type { BeforeAgentStartEvent, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+
3
+ import { projectConnections } from "../lib/usecases/project.ts";
4
+
5
+ // One line in the system prompt, only inside a heydb project, so the model
6
+ // knows the schema is a tool call away before it writes SQL from memory.
7
+ // It runs before every agent turn, so it stays cheap: it finds .heydb/ and
8
+ // reads config.json, never a snapshot. Anything unexpected adds nothing; the
9
+ // tools report problems when they are actually used.
10
+ //
11
+ // It sets a named prompt section instead of returning a new system prompt:
12
+ // Pi compares sections between turns and only sends what changed, so an
13
+ // unchanged line costs nothing after the first turn.
14
+
15
+ export const PROMPT_SECTION = "heydb";
16
+
17
+ export function heydbPromptLine(cwd: string): string | null {
18
+ let names: string[];
19
+ try {
20
+ names = projectConnections(cwd)?.names ?? [];
21
+ } catch {
22
+ return null;
23
+ }
24
+ if (names.length === 0) return null;
25
+ return (
26
+ `This project has a heydb schema snapshot (connections: ${names.join(", ")}). ` +
27
+ "Use heydb_overview, heydb_table and heydb_search before writing SQL."
28
+ );
29
+ }
30
+
31
+ export function createContextHandler() {
32
+ return (event: BeforeAgentStartEvent, ctx: ExtensionContext): undefined => {
33
+ const line = heydbPromptLine(ctx.cwd);
34
+ if (line !== null) event.systemPromptOptions.sections[PROMPT_SECTION] = line;
35
+ return undefined;
36
+ };
37
+ }
@@ -0,0 +1,32 @@
1
+ import { introspect } from "../lib/engines/index.ts";
2
+ import type { KnowledgeOptions } from "../lib/knowledge/operations.ts";
3
+ import type { CaptureOptions } from "../lib/usecases/project.ts";
4
+ import { resolveAuthor } from "../lib/usecases/author.ts";
5
+
6
+ // Everything the extension needs from the outside world, in one place. Pi
7
+ // gets the real ones; tests pass fakes, so commands and tools run without a
8
+ // database, git, the real clock or the process folder.
9
+
10
+ export interface HeydbDeps {
11
+ /** Where password references are looked up first (then .heydb/.env). */
12
+ readonly env: Readonly<Record<string, string | undefined>>;
13
+ /** Captures a live schema. Only /heydb:sync and /heydb:status call it; tools never do. */
14
+ readonly introspect: NonNullable<CaptureOptions["introspect"]>;
15
+ /** The person behind the session, for the author of new notes. */
16
+ readonly author: (cwd: string) => Promise<string>;
17
+ /** Clock and id generator for new notes. */
18
+ readonly knowledge: KnowledgeOptions;
19
+ /** The folder argument completion looks in; Pi passes completions no context. */
20
+ readonly cwd: () => string;
21
+ }
22
+
23
+ export function defaultDeps(): HeydbDeps {
24
+ return {
25
+ env: process.env,
26
+ // The engine module loads its driver on first use (ADR-0006), so this costs nothing until a sync.
27
+ introspect,
28
+ author: (cwd) => resolveAuthor({ cwd }),
29
+ knowledge: {},
30
+ cwd: () => process.cwd(),
31
+ };
32
+ }
@@ -0,0 +1,20 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+
3
+ import { registerHeydbCommands } from "./commands.ts";
4
+ import { createContextHandler } from "./context.ts";
5
+ import { type HeydbDeps, defaultDeps } from "./deps.ts";
6
+ import { registerHeydbTools } from "./tools.ts";
7
+
8
+ // Pi glue only: every behavior lives in lib/ so it stays testable and reusable
9
+ // outside Pi. This file wires the pieces; each piece takes its dependencies
10
+ // as an argument, so tests run them with fakes.
11
+
12
+ export default function heydb(pi: ExtensionAPI): void {
13
+ registerHeydb(pi, defaultDeps());
14
+ }
15
+
16
+ export function registerHeydb(pi: ExtensionAPI, deps: HeydbDeps): void {
17
+ registerHeydbCommands(pi, deps);
18
+ registerHeydbTools(pi, deps);
19
+ pi.on("before_agent_start", createContextHandler());
20
+ }
@@ -0,0 +1,120 @@
1
+ import type { ExtensionCommandContext, KeybindingsManager } from "@earendil-works/pi-coding-agent";
2
+ import { type Component, type TUI, decodeKittyPrintable, truncateToWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
3
+
4
+ // A one-line input that never shows what is typed, for passwords. Pi's
5
+ // ctx.ui.input() echoes every character, and it has no hidden mode, so this
6
+ // is a small custom component shown through ctx.ui.custom().
7
+ //
8
+ // The key handling is a pure function, reduceSecret(state, key) → state, so
9
+ // tests drive it key by key without a terminal; the component around it only
10
+ // draws and forwards. The value lives in the component's state and is handed
11
+ // to the caller on Enter: it is never rendered, logged or put in an error.
12
+
13
+ export interface SecretState {
14
+ readonly value: string;
15
+ /** A bracketed paste still arriving in chunks; null when not pasting. */
16
+ readonly paste: string | null;
17
+ readonly outcome: "editing" | "submitted" | "cancelled";
18
+ }
19
+
20
+ export const EMPTY_SECRET: SecretState = { value: "", paste: null, outcome: "editing" };
21
+
22
+ /** What reduceSecret needs from Pi's key table. */
23
+ export type KeyMatcher = Pick<KeybindingsManager, "matches">;
24
+
25
+ const PASTE_START = "\x1b[200~";
26
+ const PASTE_END = "\x1b[201~";
27
+ const segmenter = new Intl.Segmenter();
28
+
29
+ /** The next state after one chunk of terminal input. Never changes `state`. */
30
+ export function reduceSecret(state: SecretState, data: string, keys: KeyMatcher): SecretState {
31
+ if (state.outcome !== "editing") return state;
32
+
33
+ // A pasted password arrives between bracket markers, possibly split over
34
+ // several chunks, and may end with a line break; like Pi's own input, the
35
+ // breaks are dropped and the paste is taken whole.
36
+ if (state.paste !== null || data.includes(PASTE_START)) {
37
+ const buffer = state.paste ?? "";
38
+ const incoming = state.paste === null ? data.slice(data.indexOf(PASTE_START) + PASTE_START.length) : data;
39
+ const pasted = buffer + incoming;
40
+ const end = pasted.indexOf(PASTE_END);
41
+ if (end === -1) return { ...state, paste: pasted };
42
+ const next = { ...state, value: state.value + pasted.slice(0, end).replace(/[\r\n]/g, ""), paste: null };
43
+ const rest = pasted.slice(end + PASTE_END.length);
44
+ return rest === "" ? next : reduceSecret(next, rest, keys);
45
+ }
46
+
47
+ if (keys.matches(data, "tui.select.cancel")) return { ...state, outcome: "cancelled" };
48
+ if (keys.matches(data, "tui.input.submit") || data === "\n") return { ...state, outcome: "submitted" };
49
+ if (keys.matches(data, "tui.editor.deleteCharBackward")) {
50
+ const graphemes = [...segmenter.segment(state.value)];
51
+ return { ...state, value: graphemes.slice(0, -1).map((item) => item.segment).join("") };
52
+ }
53
+ // Words cannot be seen, so deleting "a word" would delete an unknown part: these clear everything.
54
+ if (keys.matches(data, "tui.editor.deleteToLineStart") || keys.matches(data, "tui.editor.deleteWordBackward")) {
55
+ return { ...state, value: "" };
56
+ }
57
+
58
+ // Terminals with the Kitty keyboard protocol send even plain letters as escape sequences.
59
+ const printable = decodeKittyPrintable(data) ?? (hasControlCharacters(data) ? undefined : data);
60
+ return printable === undefined ? state : { ...state, value: state.value + printable };
61
+ }
62
+
63
+ function hasControlCharacters(data: string): boolean {
64
+ return [...data].some((char) => {
65
+ const code = char.charCodeAt(0);
66
+ return code < 32 || code === 0x7f || (code >= 0x80 && code <= 0x9f);
67
+ });
68
+ }
69
+
70
+ interface Theme {
71
+ fg(color: "accent" | "dim", text: string): string;
72
+ }
73
+
74
+ /**
75
+ * The component: a title, one "•" per character typed and a key hint. It
76
+ * calls `done` with the value on Enter and with undefined on Escape.
77
+ */
78
+ export function createSecretInput(
79
+ title: string,
80
+ tui: Pick<TUI, "requestRender">,
81
+ theme: Theme,
82
+ keys: KeyMatcher,
83
+ done: (value: string | undefined) => void,
84
+ ): Component {
85
+ let state = EMPTY_SECRET;
86
+ return {
87
+ render(width: number): string[] {
88
+ const dots = "•".repeat([...segmenter.segment(state.value)].length);
89
+ // A long password shows its last dots, where the next one appears.
90
+ const visible = dots.length + 2 > width ? dots.slice(dots.length - Math.max(0, width - 3)) : dots;
91
+ return [
92
+ // The title says where the password goes, so it wraps instead of being cut.
93
+ ...wrapTextWithAnsi(theme.fg("accent", title), width),
94
+ truncateToWidth(`> ${visible}`, width),
95
+ truncateToWidth(theme.fg("dim", "enter saves · esc cancels"), width),
96
+ ];
97
+ },
98
+ handleInput(data: string): void {
99
+ state = reduceSecret(state, data, keys);
100
+ if (state.outcome === "submitted") return done(state.value);
101
+ if (state.outcome === "cancelled") return done(undefined);
102
+ tui.requestRender();
103
+ },
104
+ invalidate(): void {},
105
+ };
106
+ }
107
+
108
+ /**
109
+ * Asks for a secret with the masked input. Returns the value, undefined when
110
+ * the person pressed Escape, or null when Pi has no terminal UI to show it
111
+ * (RPC, print and JSON modes): the caller then explains how to set it instead.
112
+ */
113
+ export async function askSecret(ctx: ExtensionCommandContext, title: string): Promise<string | undefined | null> {
114
+ if (ctx.mode !== "tui") return null;
115
+ return ctx.ui.custom<string | undefined>(
116
+ (tui, theme, keybindings, done) => createSecretInput(title, tui, theme, keybindings, done),
117
+ // Terminals that show program status then mark Pi as waiting for an answer.
118
+ { blocked: { kind: "question", message: title } },
119
+ );
120
+ }
@@ -0,0 +1,402 @@
1
+ import type { AgentToolResult, ExtensionAPI, ToolDefinition } from "@earendil-works/pi-coding-agent";
2
+
3
+ import { knowledgeForTable } from "../lib/knowledge/query.ts";
4
+ import type { Annotation, RelationshipSide } from "../lib/knowledge/types.ts";
5
+ import { displayNameOf } from "../lib/model/names.ts";
6
+ import { incomingReferences } from "../lib/model/references.ts";
7
+ import { renderOverview, renderSearch, renderTable } from "../lib/render/markdown.ts";
8
+ import { searchSchema } from "../lib/search.ts";
9
+ import { PROJECT_TARGET } from "../lib/knowledge/types.ts";
10
+ import { annotate, annotateProject, editNote, projectNotes, relate, relationshipsFromOtherConnections } from "../lib/usecases/knowledge.ts";
11
+ import { ProjectError, forgetById, openConnection, openProjectRoot, openSnapshot, resolveTarget } from "../lib/usecases/project.ts";
12
+ import { resolveTable } from "../lib/usecases/resolve.ts";
13
+ import { SHARE_NOTES_HINT, knowledgeSharing } from "../lib/usecases/sharing.ts";
14
+ import type { HeydbDeps } from "./deps.ts";
15
+
16
+ // Tools are what the model calls. They read the stored snapshot and the
17
+ // knowledge files, and write knowledge; they never connect to a database.
18
+ // Connecting needs a password and may hit production, so only the human
19
+ // commands (/heydb:sync, /heydb:status) do it, when a person asks.
20
+ //
21
+ // Every failure comes back as text with isError, never as a thrown error: the
22
+ // model reads the message and can fix its call or tell the user what to run.
23
+ // Tools run one at a time ("sequential"): two notes saved in the same turn
24
+ // must not both read the file before either writes it.
25
+
26
+ interface ToolDetails {
27
+ /** The connection the call used; null when it failed before picking one. */
28
+ readonly connection: string | null;
29
+ }
30
+
31
+ type Result = AgentToolResult<ToolDetails>;
32
+
33
+ const CONNECTION = {
34
+ type: "string",
35
+ description: "Connection name from .heydb/config.json; omit to use the default.",
36
+ } as const;
37
+
38
+ /** The suffix that tells readers a note was written by an agent, not typed by a person. */
39
+ export const AGENT_AUTHOR_SUFFIX = " (via agent)";
40
+
41
+ export function registerHeydbTools(pi: ExtensionAPI, deps: HeydbDeps): void {
42
+ pi.registerTool(createOverviewTool());
43
+ pi.registerTool(createTableTool());
44
+ pi.registerTool(createSearchTool());
45
+ pi.registerTool(createAnnotateTool(deps));
46
+ pi.registerTool(createRelateTool(deps));
47
+ pi.registerTool(createEditNoteTool(deps));
48
+ pi.registerTool(createForgetTool());
49
+ }
50
+
51
+ const OVERVIEW_PARAMETERS = {
52
+ type: "object",
53
+ properties: { connection: CONNECTION },
54
+ additionalProperties: false,
55
+ } as const;
56
+
57
+ export function createOverviewTool(): ToolDefinition<typeof OVERVIEW_PARAMETERS, ToolDetails> {
58
+ return {
59
+ name: "heydb_overview",
60
+ label: "heydb overview",
61
+ description:
62
+ "Project notes, then a summary of the schema snapshot: per database comment coverage, name prefixes, most referenced tables; warnings; noted tables.",
63
+ promptSnippet: "Summarize the database schema of this project (stored snapshot)",
64
+ promptGuidelines: ["Use heydb_overview first when you do not know the database schema of a heydb project."],
65
+ parameters: OVERVIEW_PARAMETERS,
66
+ executionMode: "sequential",
67
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
68
+ return guarded(() => {
69
+ const opened = openSnapshot(ctx.cwd, params.connection);
70
+ const options = { connection: opened.name, projectNotes: projectNotes(opened.root) };
71
+ return ok(renderOverview(opened.snapshot, opened.knowledge, options), opened.name);
72
+ });
73
+ },
74
+ };
75
+ }
76
+
77
+ const TABLE_PARAMETERS = {
78
+ type: "object",
79
+ properties: {
80
+ table: { type: "string", description: 'Table or view: "orders", "shop.orders" or "shop.shop.orders".' },
81
+ columns: {
82
+ anyOf: [{ type: "string", const: "all" }, { type: "integer", minimum: 1 }],
83
+ description: 'How many columns to list (default 60, most telling first), or "all".',
84
+ },
85
+ column_filter: { type: "string", description: "Only columns whose name or comment contains this text; ignores case and accents." },
86
+ connection: CONNECTION,
87
+ },
88
+ required: ["table"],
89
+ additionalProperties: false,
90
+ } as const;
91
+
92
+ export function createTableTool(): ToolDefinition<typeof TABLE_PARAMETERS, ToolDetails> {
93
+ return {
94
+ name: "heydb_table",
95
+ label: "heydb table",
96
+ description:
97
+ "One table or view: comment, team notes (with ids), keys, relationships both ways (who references it), then its most relevant columns.",
98
+ promptSnippet: "Show what one database table means and its columns, keys, relationships and notes",
99
+ promptGuidelines: [
100
+ "Before writing or changing SQL in a heydb project, check heydb_table (or heydb_search) for the tables involved instead of guessing names, columns or joins.",
101
+ "Read the heydb_table header (comment, notes, referenced by) before concluding what a table is; when its meaning comes from inference (columns, references, name), say it is an inference and ask the user before saving it with heydb_annotate.",
102
+ ],
103
+ parameters: TABLE_PARAMETERS,
104
+ executionMode: "sequential",
105
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
106
+ return guarded(() => {
107
+ // Pi validates the schema, but the check is repeated so no other caller can pass 0 or 2.5.
108
+ const { columns } = params;
109
+ if (columns !== undefined && columns !== "all" && !(Number.isInteger(columns) && columns >= 1)) {
110
+ throw new Error('columns must be "all" or an integer of at least 1');
111
+ }
112
+ const opened = openSnapshot(ctx.cwd, params.connection);
113
+ const resolved = resolveTable(opened.snapshot, params.table);
114
+ const knowledge = knowledgeForTable(opened.knowledge, resolved.name);
115
+ const incoming = incomingReferences(opened.snapshot, opened.knowledge, resolved.name);
116
+ const other = relationshipsFromOtherConnections(opened.root, opened.name, resolved.name);
117
+ const options = {
118
+ ...(columns !== undefined && { columns }),
119
+ ...(params.column_filter !== undefined && { columnFilter: params.column_filter }),
120
+ connection: opened.name,
121
+ external: other.relationships,
122
+ unreadable: other.unreadable,
123
+ };
124
+ return ok(renderTable(resolved.table, resolved.name, knowledge, incoming, options), opened.name);
125
+ });
126
+ },
127
+ };
128
+ }
129
+
130
+ const MAX_SEARCH_LIMIT = 50;
131
+
132
+ const SEARCH_PARAMETERS = {
133
+ type: "object",
134
+ properties: {
135
+ query: { type: "string", description: "Text to find; case and accents are ignored." },
136
+ limit: { type: "integer", minimum: 1, maximum: MAX_SEARCH_LIMIT, description: "Maximum results (default 20)." },
137
+ database: { type: "string", description: 'Only this database, e.g. "shop" (PostgreSQL: "app.public").' },
138
+ connection: CONNECTION,
139
+ },
140
+ required: ["query"],
141
+ additionalProperties: false,
142
+ } as const;
143
+
144
+ export function createSearchTool(): ToolDefinition<typeof SEARCH_PARAMETERS, ToolDetails> {
145
+ return {
146
+ name: "heydb_search",
147
+ label: "heydb search",
148
+ description:
149
+ "Search tables by name, comment, team notes and columns, plus tables whose meaning is inferred from the columns that reference them; ignores case, accents and plurals.",
150
+ promptSnippet: "Search the database schema and its notes by keyword",
151
+ promptGuidelines: [
152
+ "Use heydb_search to find which tables or columns hold a concept before writing SQL.",
153
+ "A heydb_search result marked (inferred) matched only through the comments of columns that reference it: present it as an inference and confirm with the user before relying on it or saving it.",
154
+ ],
155
+ parameters: SEARCH_PARAMETERS,
156
+ executionMode: "sequential",
157
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
158
+ return guarded(() => {
159
+ // Pi validates the schema, but the check is repeated so no other caller can ask for 10,000 results.
160
+ const { limit = 20 } = params;
161
+ if (!Number.isInteger(limit) || limit < 1 || limit > MAX_SEARCH_LIMIT) {
162
+ throw new Error(`limit must be an integer from 1 to ${MAX_SEARCH_LIMIT}`);
163
+ }
164
+ const opened = openSnapshot(ctx.cwd, params.connection);
165
+ const options = { limit, ...(params.database !== undefined && { database: params.database }) };
166
+ return ok(renderSearch(searchSchema(opened.snapshot, opened.knowledge, params.query, options)), opened.name);
167
+ });
168
+ },
169
+ };
170
+ }
171
+
172
+ const ANNOTATE_PARAMETERS = {
173
+ type: "object",
174
+ properties: {
175
+ target: {
176
+ type: "string",
177
+ description: 'Database ("shop"), table ("orders", "shop.orders") or column ("orders.status"). Omit with scope "project".',
178
+ },
179
+ scope: { type: "string", const: "project", description: 'Set to "project", without target, for a note about the whole project.' },
180
+ text: { type: "string", description: "Short, factual note." },
181
+ connection: CONNECTION,
182
+ },
183
+ required: ["text"],
184
+ additionalProperties: false,
185
+ } as const;
186
+
187
+ export function createAnnotateTool(deps: HeydbDeps): ToolDefinition<typeof ANNOTATE_PARAMETERS, ToolDetails> {
188
+ return {
189
+ name: "heydb_annotate",
190
+ label: "heydb annotate",
191
+ description:
192
+ "Save a note on the project, a database, table or column in .heydb/knowledge; the team gets it when the project commits .heydb/.",
193
+ promptSnippet: "Record what a database, table or column means, for the whole team",
194
+ promptGuidelines: [
195
+ "When you learn what a table or column means (valid values, units, legacy quirks), save it with heydb_annotate: one or two factual sentences, never invent or guess.",
196
+ "Before heydb_annotate, read the existing notes with heydb_table; fix a wrong note with heydb_edit_note instead of adding a contradicting one.",
197
+ 'End a heydb_annotate note with its source: "(code: src/Models/Order.php)" when code shows it, "(told by the user)" when the user said it. Save an inference only after the user confirms it.',
198
+ 'For a fact about the whole project rather than one database (one database per client, which connection is central), use heydb_annotate with scope: "project" and no target.',
199
+ ],
200
+ parameters: ANNOTATE_PARAMETERS,
201
+ executionMode: "sequential",
202
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
203
+ return guarded(async () => {
204
+ // A separate scope, not a magic target: a table called "project" must stay reachable as target "project".
205
+ if (params.scope === "project") {
206
+ if (params.target !== undefined) throw new Error('a project note has no target: omit target with scope "project"');
207
+ const root = openProjectRoot(ctx.cwd);
208
+ const note = annotateProject(root, { text: params.text, author: await agentAuthor(deps, ctx.cwd) }, deps.knowledge);
209
+ return ok(`Saved project note ${note.id} (author: ${note.author}).${await sharingWarning(root, PROJECT_FILE_SHARING)}`, null);
210
+ }
211
+ if (params.target === undefined) throw new Error('target is required, unless scope is "project"');
212
+ const opened = openSnapshot(ctx.cwd, params.connection);
213
+ const target = resolveProjectHint(params.target, () => resolveTarget(opened.snapshot, params.target as string));
214
+ const author = await agentAuthor(deps, ctx.cwd);
215
+ const note = annotate(opened.root, opened.name, { target: target.name, text: params.text, author }, deps.knowledge);
216
+ const saved = `Saved note ${note.id} on ${target.kind} ${displayNameOf(note.target)} (author: ${note.author}).`;
217
+ return ok(saved + (await sharingWarning(opened.root, opened.name)), opened.name);
218
+ });
219
+ },
220
+ };
221
+ }
222
+
223
+ const COLUMNS = { type: "array", items: { type: "string" }, minItems: 1 } as const;
224
+
225
+ const RELATE_PARAMETERS = {
226
+ type: "object",
227
+ properties: {
228
+ from_table: { type: "string", description: "Table holding the reference, e.g. orders." },
229
+ from_columns: { ...COLUMNS, description: "Referencing columns, in order." },
230
+ to_table: { type: "string", description: "Referenced table, e.g. customers." },
231
+ to_columns: { ...COLUMNS, description: "Referenced columns, same order and count." },
232
+ to_connection: { type: "string", description: "Connection of to_table when it is not the from connection." },
233
+ note: { type: "string", description: "How you know, briefly." },
234
+ connection: CONNECTION,
235
+ },
236
+ required: ["from_table", "from_columns", "to_table", "to_columns"],
237
+ additionalProperties: false,
238
+ } as const;
239
+
240
+ export function createRelateTool(deps: HeydbDeps): ToolDefinition<typeof RELATE_PARAMETERS, ToolDetails> {
241
+ return {
242
+ name: "heydb_relate",
243
+ label: "heydb relate",
244
+ description: "Document an implicit relationship: columns that join two tables without a foreign key.",
245
+ promptSnippet: "Record a join between tables that has no foreign key",
246
+ promptGuidelines: [
247
+ "When you find tables joined without a foreign key (in code, queries or data), record it with heydb_relate and say how you know in the note.",
248
+ "When the referenced table is in another connection of the project (crm.clientes.cod_erp → erp's cus.cod), call heydb_relate from the referencing connection with to_connection.",
249
+ ],
250
+ parameters: RELATE_PARAMETERS,
251
+ executionMode: "sequential",
252
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
253
+ return guarded(async () => {
254
+ const opened = openSnapshot(ctx.cwd, params.connection);
255
+ const from = { table: resolveTable(opened.snapshot, params.from_table).name, columns: params.from_columns };
256
+ // The to side is resolved in its own connection's snapshot; a stored side names its connection only when it differs.
257
+ const target = params.to_connection === undefined ? opened : openSnapshot(ctx.cwd, params.to_connection);
258
+ const to = {
259
+ ...(target.name !== opened.name && { connection: target.name }),
260
+ table: resolveTable(target.snapshot, params.to_table).name,
261
+ columns: params.to_columns,
262
+ };
263
+ const author = await agentAuthor(deps, ctx.cwd);
264
+ const link = relate(opened.root, opened.name, { from, to, note: params.note ?? "", author }, deps.knowledge);
265
+ const saved = `Saved relationship ${link.id}: ${side(link.from)} → ${side(link.to)} (author: ${link.author}).`;
266
+ return ok(saved + (await sharingWarning(opened.root, opened.name)), opened.name);
267
+ });
268
+ },
269
+ };
270
+ }
271
+
272
+ const EDIT_PARAMETERS = {
273
+ type: "object",
274
+ properties: {
275
+ id: { type: "string", description: "Note id, as shown by heydb_table or heydb_overview." },
276
+ text: { type: "string", description: "The new text, replacing the old one." },
277
+ connection: CONNECTION,
278
+ },
279
+ required: ["id", "text"],
280
+ additionalProperties: false,
281
+ } as const;
282
+
283
+ export function createEditNoteTool(deps: HeydbDeps): ToolDefinition<typeof EDIT_PARAMETERS, ToolDetails> {
284
+ return {
285
+ name: "heydb_edit_note",
286
+ label: "heydb edit note",
287
+ description: "Replace the text of a heydb note by id; author and creation date are kept.",
288
+ promptSnippet: "Correct the text of an existing heydb note",
289
+ parameters: EDIT_PARAMETERS,
290
+ executionMode: "sequential",
291
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
292
+ return guarded(() => {
293
+ const opened = openConnection(ctx.cwd, params.connection);
294
+ const note = editNote(opened.root, opened.name, { id: params.id, text: params.text }, deps.knowledge);
295
+ return ok(isProjectNote(note) ? `Updated project note ${note.id}.` : `Updated note ${note.id} on ${displayNameOf(note.target)}.`, opened.name);
296
+ });
297
+ },
298
+ };
299
+ }
300
+
301
+ const FORGET_PARAMETERS = {
302
+ type: "object",
303
+ properties: {
304
+ id: { type: "string", description: "Id of a note or relationship, as shown by heydb_table or heydb_overview." },
305
+ connection: CONNECTION,
306
+ },
307
+ required: ["id"],
308
+ additionalProperties: false,
309
+ } as const;
310
+
311
+ export function createForgetTool(): ToolDefinition<typeof FORGET_PARAMETERS, ToolDetails> {
312
+ return {
313
+ name: "heydb_forget",
314
+ label: "heydb forget",
315
+ description: "Delete a heydb note or implicit relationship by id.",
316
+ promptSnippet: "Delete a wrong or obsolete heydb note or relationship",
317
+ promptGuidelines: ["Use heydb_forget only for notes that are wrong or obsolete, and say which one you removed."],
318
+ parameters: FORGET_PARAMETERS,
319
+ executionMode: "sequential",
320
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
321
+ return guarded(() => {
322
+ const opened = openConnection(ctx.cwd, params.connection);
323
+ const forgotten = forgetById(opened.root, opened.name, params.id);
324
+ const text =
325
+ forgotten.kind === "annotation"
326
+ ? isProjectNote(forgotten.annotation)
327
+ ? `Forgot project note ${forgotten.annotation.id}.`
328
+ : `Forgot note ${forgotten.annotation.id} on ${displayNameOf(forgotten.annotation.target)}.`
329
+ : `Forgot relationship ${forgotten.relationship.id}: ${side(forgotten.relationship.from)} → ${side(forgotten.relationship.to)}.`;
330
+ return ok(text, opened.name);
331
+ });
332
+ },
333
+ };
334
+ }
335
+
336
+ /**
337
+ * Said after saving only when git ignores the notes: the agent would
338
+ * otherwise tell the user the team has them. Saving already succeeded, so a
339
+ * failing git check adds nothing rather than turning it into an error.
340
+ */
341
+ async function sharingWarning(root: string, connection: string): Promise<string> {
342
+ try {
343
+ const sharing = await knowledgeSharing(root, { connection });
344
+ if (sharing.state !== "ignored") return "";
345
+ return `\nNote saved locally; git ignores it (${sharing.rule}), so the team will not get it. ${SHARE_NOTES_HINT}`;
346
+ } catch {
347
+ return "";
348
+ }
349
+ }
350
+
351
+ /** What knowledgeSharing checks for project notes: .heydb/knowledge/_project.json. */
352
+ const PROJECT_FILE_SHARING = "_project";
353
+
354
+ /** An agent that writes target "project" for a project note gets told about scope when nothing is called project. */
355
+ function resolveProjectHint<T>(target: string, resolve: () => T): T {
356
+ try {
357
+ return resolve();
358
+ } catch (error) {
359
+ if (target.trim().toLowerCase() !== PROJECT_TARGET || !(error instanceof Error)) throw error;
360
+ throw new Error(`${error.message}\nFor a note about the whole project, use scope: "project" without a target.`, { cause: error });
361
+ }
362
+ }
363
+
364
+ function isProjectNote(note: Annotation): boolean {
365
+ return note.target === PROJECT_TARGET;
366
+ }
367
+
368
+ async function agentAuthor(deps: HeydbDeps, cwd: string): Promise<string> {
369
+ return `${await deps.author(cwd)}${AGENT_AUTHOR_SUFFIX}`;
370
+ }
371
+
372
+ function side(value: RelationshipSide): string {
373
+ const connection = value.connection === undefined ? "" : `${value.connection}: `;
374
+ return `${connection}${displayNameOf(value.table)}(${value.columns.join(", ")})`;
375
+ }
376
+
377
+ function ok(text: string, connection: string | null): Result {
378
+ return { content: [{ type: "text", text }], details: { connection } };
379
+ }
380
+
381
+ async function guarded(work: () => Result | Promise<Result>): Promise<Result> {
382
+ try {
383
+ return await work();
384
+ } catch (error) {
385
+ return { content: [{ type: "text", text: forAgent(error) }], details: { connection: null }, isError: true };
386
+ }
387
+ }
388
+
389
+ // The agent cannot run slash commands; it has to ask the user to.
390
+ function forAgent(error: unknown): string {
391
+ if (error instanceof ProjectError) {
392
+ switch (error.code) {
393
+ case "not_initialized":
394
+ return "No heydb project in this folder or above. Ask the user to run /heydb:init, then /heydb:connect and /heydb:sync.";
395
+ case "no_connections":
396
+ return "This heydb project has no connections yet. Ask the user to run /heydb:connect, then /heydb:sync.";
397
+ case "never_synced":
398
+ return `Connection "${error.connection}" was never synced, so there is no schema snapshot yet. Ask the user to run /heydb:sync ${error.connection}.`;
399
+ }
400
+ }
401
+ return error instanceof Error ? error.message : String(error);
402
+ }
package/lib/about.ts ADDED
@@ -0,0 +1,7 @@
1
+ import pkg from "../package.json" with { type: "json" };
2
+
3
+ export const HEYDB_VERSION: string = pkg.version;
4
+
5
+ export function aboutMessage(): string {
6
+ return `heydb ${HEYDB_VERSION} (v2 in development)`;
7
+ }