@vellumai/assistant 0.11.7-dev.202609012016.14eabff → 0.11.7-dev.202609012114.f9ebd01
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.
- package/package.json +1 -1
- package/src/__tests__/skill-load-tool.test.ts +21 -0
- package/src/__tests__/skills.test.ts +9 -1
- package/src/cli/commands/db/__tests__/status.test.ts +22 -0
- package/src/cli/commands/db/index.help.ts +1 -1
- package/src/cli/commands/db/status.ts +172 -1
- package/src/config/bundled-skills/acp/SKILL.md +10 -3
- package/src/persistence/schema-contract.test.ts +78 -0
- package/src/persistence/schema-contract.ts +96 -0
- package/src/tools/skills/load.ts +1 -1
package/package.json
CHANGED
|
@@ -127,6 +127,14 @@ describe("skill_load tool", () => {
|
|
|
127
127
|
);
|
|
128
128
|
});
|
|
129
129
|
|
|
130
|
+
test("description routes Claude Code and Codex mentions to acp", () => {
|
|
131
|
+
// skill_load is always in the tool list. Naming the agents here is what
|
|
132
|
+
// makes a mention load ACP instead of treating Claude Code as a competitor.
|
|
133
|
+
expect(skillLoadTool.description).toContain("Claude Code");
|
|
134
|
+
expect(skillLoadTool.description).toContain("Codex");
|
|
135
|
+
expect(skillLoadTool.description).toContain('skill: "acp"');
|
|
136
|
+
});
|
|
137
|
+
|
|
130
138
|
test("routes recurring monitoring and reminders to the schedule skill", () => {
|
|
131
139
|
const description = skillLoadTool.description;
|
|
132
140
|
expect(description).toContain('skill: "schedule"');
|
|
@@ -399,6 +407,19 @@ describe("skill_load tool", () => {
|
|
|
399
407
|
expect(result.content).toContain('<loaded_skill id="app-builder"');
|
|
400
408
|
});
|
|
401
409
|
|
|
410
|
+
test("bundled acp skill offers a one-time connect when the user names an agent", async () => {
|
|
411
|
+
const result = await executeSkillLoad({ skill: "acp" });
|
|
412
|
+
|
|
413
|
+
expect(result.isError).toBe(false);
|
|
414
|
+
expect(result.content).toContain("Skill: ACP");
|
|
415
|
+
expect(result.content).toContain(
|
|
416
|
+
"When the user names Claude Code or Codex",
|
|
417
|
+
);
|
|
418
|
+
expect(result.content).toContain("offer once, in one short sentence");
|
|
419
|
+
expect(result.content).toContain("Do not spawn unless they accept");
|
|
420
|
+
expect(result.content).toContain('<loaded_skill id="acp"');
|
|
421
|
+
});
|
|
422
|
+
|
|
402
423
|
test("bundled phone-calls loads when setup includes are unavailable", async () => {
|
|
403
424
|
const result = await executeSkillLoad({ skill: "phone-calls" });
|
|
404
425
|
|
|
@@ -898,12 +898,20 @@ describe("always-candidate frontmatter parsing", () => {
|
|
|
898
898
|
// A "configure Claude Code" or "let's do the auth" turn only reaches this
|
|
899
899
|
// skill if the card actually says it covers setup and authentication;
|
|
900
900
|
// delegation-only wording routes those turns to a terminal skill instead.
|
|
901
|
+
// "names" keeps a mention ("claude code will do it") from falling through
|
|
902
|
+
// as a competitor reference.
|
|
901
903
|
const { buildSkillContent } =
|
|
902
904
|
await import("../plugins/defaults/memory/substrate/skill-content.js");
|
|
903
905
|
const acp = loadSkillCatalog().find((skill) => skill.id === "acp");
|
|
904
906
|
const card = buildSkillContent(acp!).toLowerCase();
|
|
905
907
|
|
|
906
|
-
for (const term of [
|
|
908
|
+
for (const term of [
|
|
909
|
+
"names",
|
|
910
|
+
"set up",
|
|
911
|
+
"install",
|
|
912
|
+
"authenticate",
|
|
913
|
+
"connect",
|
|
914
|
+
]) {
|
|
907
915
|
expect(card).toContain(term);
|
|
908
916
|
}
|
|
909
917
|
});
|
|
@@ -169,6 +169,8 @@ describe("assistant db status — happy path", () => {
|
|
|
169
169
|
// (memory_checkpoints has 3 rows.)
|
|
170
170
|
expect(stdout).toContain("Largest tables");
|
|
171
171
|
expect(stdout.indexOf("messages")).toBeLessThan(stdout.indexOf("small"));
|
|
172
|
+
expect(stdout).toContain("Schema contract");
|
|
173
|
+
expect(stdout).toContain("missing");
|
|
172
174
|
});
|
|
173
175
|
|
|
174
176
|
test("reports the latest 'completed' migration, ignoring 'started'", async () => {
|
|
@@ -212,6 +214,26 @@ describe("assistant db status — happy path", () => {
|
|
|
212
214
|
expect(parsed.db.largest.length).toBeGreaterThan(0);
|
|
213
215
|
// Either 'bytes' (if dbstat is available) or 'rows' fallback.
|
|
214
216
|
expect(["bytes", "rows"]).toContain(parsed.db.sizingMethod);
|
|
217
|
+
expect(Array.isArray(parsed.schemaContract.missingTables)).toBe(true);
|
|
218
|
+
expect(Array.isArray(parsed.schemaContract.missingColumns)).toBe(true);
|
|
219
|
+
// The seed is a 3-table stub, so the drizzle catalog reports misses.
|
|
220
|
+
expect(parsed.schemaContract.missingTables.length).toBeGreaterThan(0);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
test("reports conversations.fork_strategy when the table exists without that column", async () => {
|
|
224
|
+
seedDb();
|
|
225
|
+
const db = new Database(dbPath);
|
|
226
|
+
try {
|
|
227
|
+
db.exec(`CREATE TABLE conversations (id TEXT PRIMARY KEY, title TEXT)`);
|
|
228
|
+
} finally {
|
|
229
|
+
db.close();
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const { stdout, exitCode } = await runStatus(["status"]);
|
|
233
|
+
|
|
234
|
+
expect(exitCode).toBe(0);
|
|
235
|
+
expect(stdout).toContain("Schema contract");
|
|
236
|
+
expect(stdout).toContain("conversations.fork_strategy");
|
|
215
237
|
});
|
|
216
238
|
});
|
|
217
239
|
|
|
@@ -12,7 +12,7 @@ export const dbHelp: CliCommandHelp = {
|
|
|
12
12
|
{
|
|
13
13
|
name: "status",
|
|
14
14
|
description:
|
|
15
|
-
"Show database path, size, key pragmas, and
|
|
15
|
+
"Show database path, size, key pragmas, largest tables, and drizzle schema misses",
|
|
16
16
|
},
|
|
17
17
|
{
|
|
18
18
|
name: "repair",
|
|
@@ -16,6 +16,8 @@
|
|
|
16
16
|
* callers will see the row-count path today)
|
|
17
17
|
* - latest applied workspace migration (proxy for schema version, read
|
|
18
18
|
* from the `memory_checkpoints` rows the migration runner writes)
|
|
19
|
+
* - drizzle-vs-SQLite schema contract (columns the current binary
|
|
20
|
+
* selects that no live database file has)
|
|
19
21
|
* - file mtime + age, for "is this DB live?" triage at a glance
|
|
20
22
|
*
|
|
21
23
|
* If the DB file is missing, exits with code 1 after a loud error. If the file
|
|
@@ -28,6 +30,7 @@ import { Database } from "bun:sqlite";
|
|
|
28
30
|
|
|
29
31
|
import type { Command } from "commander";
|
|
30
32
|
|
|
33
|
+
import type { SchemaContractReport } from "../../../persistence/schema-contract.js";
|
|
31
34
|
import { getLogsDbPath } from "../../../util/logs-db-path.js";
|
|
32
35
|
import { getMemoryDbPath } from "../../../util/memory-db-path.js";
|
|
33
36
|
import { getDbPath } from "../../../util/platform.js";
|
|
@@ -110,6 +113,12 @@ interface StatusReport {
|
|
|
110
113
|
*/
|
|
111
114
|
telemetryFile?: FileFacts;
|
|
112
115
|
telemetryDb?: DbFacts | null;
|
|
116
|
+
/**
|
|
117
|
+
* Drizzle source catalog vs the union of columns in every live SQLite
|
|
118
|
+
* file (main, logs, memory, telemetry). Extra SQLite-only columns are
|
|
119
|
+
* ignored. Omitted when the main file could not be opened.
|
|
120
|
+
*/
|
|
121
|
+
schemaContract?: SchemaContractReport;
|
|
113
122
|
}
|
|
114
123
|
|
|
115
124
|
interface MigrationSummary {
|
|
@@ -260,6 +269,83 @@ function readDbFacts(path: string): DbFacts {
|
|
|
260
269
|
}
|
|
261
270
|
}
|
|
262
271
|
|
|
272
|
+
/** Quote a SQLite identifier that came from sqlite_master (never user input). */
|
|
273
|
+
function quoteIdent(name: string): string {
|
|
274
|
+
return `"${name.replaceAll('"', '""')}"`;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Read table → column names from one SQLite file. Skips `sqlite_*`
|
|
279
|
+
* internals and tables whose `PRAGMA table_info` fails.
|
|
280
|
+
*/
|
|
281
|
+
function readSqliteColumns(path: string): Map<string, Set<string>> {
|
|
282
|
+
const db = new Database(path, { readonly: true });
|
|
283
|
+
try {
|
|
284
|
+
const tables = db
|
|
285
|
+
.query<{ name: string }, []>(
|
|
286
|
+
`SELECT name FROM sqlite_master
|
|
287
|
+
WHERE type='table' AND name NOT LIKE 'sqlite_%'`,
|
|
288
|
+
)
|
|
289
|
+
.all();
|
|
290
|
+
const out = new Map<string, Set<string>>();
|
|
291
|
+
for (const table of tables) {
|
|
292
|
+
try {
|
|
293
|
+
const columns = db
|
|
294
|
+
.query<{ name: string }, []>(
|
|
295
|
+
`PRAGMA table_info(${quoteIdent(table.name)})`,
|
|
296
|
+
)
|
|
297
|
+
.all();
|
|
298
|
+
out.set(table.name, new Set(columns.map((column) => column.name)));
|
|
299
|
+
} catch {
|
|
300
|
+
// Virtual or unreadable table. Skip so one failure does not hide
|
|
301
|
+
// the rest of the contract.
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
return out;
|
|
305
|
+
} finally {
|
|
306
|
+
db.close();
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/** Union column sets from several files. Same table name keeps every column. */
|
|
311
|
+
function mergeSqliteColumns(
|
|
312
|
+
files: Array<Map<string, Set<string>>>,
|
|
313
|
+
): Map<string, Set<string>> {
|
|
314
|
+
const merged = new Map<string, Set<string>>();
|
|
315
|
+
for (const file of files) {
|
|
316
|
+
for (const [table, columns] of file) {
|
|
317
|
+
const existing = merged.get(table);
|
|
318
|
+
if (!existing) {
|
|
319
|
+
merged.set(table, new Set(columns));
|
|
320
|
+
continue;
|
|
321
|
+
}
|
|
322
|
+
for (const column of columns) {
|
|
323
|
+
existing.add(column);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
return merged;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
async function readSchemaContract(
|
|
331
|
+
paths: string[],
|
|
332
|
+
): Promise<SchemaContractReport> {
|
|
333
|
+
// Lazy so the drizzle catalog stays out of the CLI's static graph
|
|
334
|
+
// (cli/no-daemon-internals).
|
|
335
|
+
const { diffDrizzleSchemaContract } =
|
|
336
|
+
await import("../../../persistence/schema-contract.js");
|
|
337
|
+
const live: Array<Map<string, Set<string>>> = [];
|
|
338
|
+
for (const path of paths) {
|
|
339
|
+
try {
|
|
340
|
+
live.push(readSqliteColumns(path));
|
|
341
|
+
} catch {
|
|
342
|
+
// File vanished or is unreadable. The main-file open already
|
|
343
|
+
// succeeded; skip this sidecar rather than fail the report.
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
return diffDrizzleSchemaContract(mergeSqliteColumns(live));
|
|
347
|
+
}
|
|
348
|
+
|
|
263
349
|
// ---------------------------------------------------------------------------
|
|
264
350
|
// Rendering
|
|
265
351
|
// ---------------------------------------------------------------------------
|
|
@@ -350,6 +436,7 @@ function renderHuman(report: StatusReport): string {
|
|
|
350
436
|
// Cross-database migration summary — the checkpoint ledger lives only in
|
|
351
437
|
// the main DB, so this is a single block at the end covering all three.
|
|
352
438
|
out += renderSummary(report.migration);
|
|
439
|
+
out += renderSchemaContract(report.schemaContract);
|
|
353
440
|
|
|
354
441
|
return out;
|
|
355
442
|
}
|
|
@@ -469,6 +556,72 @@ function renderSummary(migration: MigrationSummary | undefined): string {
|
|
|
469
556
|
return out;
|
|
470
557
|
}
|
|
471
558
|
|
|
559
|
+
const HUMAN_COLUMN_MISS_LIMIT = 40;
|
|
560
|
+
const HUMAN_TABLE_MISS_LIMIT = 15;
|
|
561
|
+
|
|
562
|
+
function schemaContractIsClean(report: SchemaContractReport): boolean {
|
|
563
|
+
return (
|
|
564
|
+
report.missingTables.length === 0 && report.missingColumns.length === 0
|
|
565
|
+
);
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
function renderMissList(labels: string[], limit: number): string {
|
|
569
|
+
let out = "";
|
|
570
|
+
const shown = labels.slice(0, limit);
|
|
571
|
+
for (const label of shown) {
|
|
572
|
+
out += ` ${label}\n`;
|
|
573
|
+
}
|
|
574
|
+
if (labels.length > limit) {
|
|
575
|
+
out += ` … and ${formatCount(labels.length - limit)} more\n`;
|
|
576
|
+
}
|
|
577
|
+
return out;
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
function renderSchemaContract(
|
|
581
|
+
contract: SchemaContractReport | undefined,
|
|
582
|
+
): string {
|
|
583
|
+
if (!contract) {
|
|
584
|
+
return "";
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
let out = "\nSchema contract (drizzle vs SQLite)\n";
|
|
588
|
+
if (schemaContractIsClean(contract)) {
|
|
589
|
+
out += row("Status", "ok");
|
|
590
|
+
return out;
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
const parts: string[] = [];
|
|
594
|
+
if (contract.missingColumns.length > 0) {
|
|
595
|
+
parts.push(
|
|
596
|
+
`${formatCount(contract.missingColumns.length)} missing column${
|
|
597
|
+
contract.missingColumns.length === 1 ? "" : "s"
|
|
598
|
+
}`,
|
|
599
|
+
);
|
|
600
|
+
}
|
|
601
|
+
if (contract.missingTables.length > 0) {
|
|
602
|
+
parts.push(
|
|
603
|
+
`${formatCount(contract.missingTables.length)} missing table${
|
|
604
|
+
contract.missingTables.length === 1 ? "" : "s"
|
|
605
|
+
}`,
|
|
606
|
+
);
|
|
607
|
+
}
|
|
608
|
+
out += row("Status", red(parts.join(", ")));
|
|
609
|
+
|
|
610
|
+
if (contract.missingColumns.length > 0) {
|
|
611
|
+
out += "Missing columns\n";
|
|
612
|
+
out += renderMissList(
|
|
613
|
+
contract.missingColumns.map((miss) => `${miss.table}.${miss.column}`),
|
|
614
|
+
HUMAN_COLUMN_MISS_LIMIT,
|
|
615
|
+
);
|
|
616
|
+
}
|
|
617
|
+
if (contract.missingTables.length > 0) {
|
|
618
|
+
out += "Missing tables\n";
|
|
619
|
+
out += renderMissList(contract.missingTables, HUMAN_TABLE_MISS_LIMIT);
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
return out;
|
|
623
|
+
}
|
|
624
|
+
|
|
472
625
|
function renderMissing(path: string): string {
|
|
473
626
|
return (
|
|
474
627
|
`${red("ERROR")} Database not found at ${path}\n\n` +
|
|
@@ -487,7 +640,7 @@ function renderMissing(path: string): string {
|
|
|
487
640
|
// ---------------------------------------------------------------------------
|
|
488
641
|
|
|
489
642
|
export function registerDbStatus(parent: Command): void {
|
|
490
|
-
subcommand(parent, "status").action(function (this: Command) {
|
|
643
|
+
subcommand(parent, "status").action(async function (this: Command) {
|
|
491
644
|
const file = readFileFacts(getDbPath());
|
|
492
645
|
|
|
493
646
|
if (!file.exists) {
|
|
@@ -561,10 +714,28 @@ export function registerDbStatus(parent: Command): void {
|
|
|
561
714
|
migration = null;
|
|
562
715
|
}
|
|
563
716
|
|
|
717
|
+
const schemaPaths = [file.path];
|
|
718
|
+
if (logsFile.exists) {
|
|
719
|
+
schemaPaths.push(logsFile.path);
|
|
720
|
+
}
|
|
721
|
+
if (memoryFile.exists) {
|
|
722
|
+
schemaPaths.push(memoryFile.path);
|
|
723
|
+
}
|
|
724
|
+
if (telemetryFile.exists) {
|
|
725
|
+
schemaPaths.push(telemetryFile.path);
|
|
726
|
+
}
|
|
727
|
+
let schemaContract: SchemaContractReport | undefined;
|
|
728
|
+
try {
|
|
729
|
+
schemaContract = await readSchemaContract(schemaPaths);
|
|
730
|
+
} catch {
|
|
731
|
+
schemaContract = undefined;
|
|
732
|
+
}
|
|
733
|
+
|
|
564
734
|
const report: StatusReport = {
|
|
565
735
|
file,
|
|
566
736
|
db,
|
|
567
737
|
migration: migration ?? undefined,
|
|
738
|
+
schemaContract,
|
|
568
739
|
logsFile,
|
|
569
740
|
logsDb,
|
|
570
741
|
memoryFile,
|
|
@@ -8,12 +8,11 @@ metadata:
|
|
|
8
8
|
display-name: "ACP"
|
|
9
9
|
category: "development"
|
|
10
10
|
activation-hints:
|
|
11
|
-
- "User wants to set up, install, configure, authenticate, or
|
|
12
|
-
- "User asks to use Claude Code or Codex, or delegate a coding task to an ACP agent"
|
|
11
|
+
- "User names Claude Code or Codex, or wants to set up, install, configure, authenticate, connect, use, or delegate work to them"
|
|
13
12
|
- "User wants an agent to work autonomously and report back later"
|
|
14
13
|
- "User mentions ACP, claude-agent-acp, or codex-acp"
|
|
15
14
|
avoid-when:
|
|
16
|
-
- "The task is small enough to do inline"
|
|
15
|
+
- "The task is small enough to do inline and the user did not name Claude Code or Codex"
|
|
17
16
|
---
|
|
18
17
|
|
|
19
18
|
ACP agent orchestration - spawn external coding agents (Claude Code, Codex) to work on tasks via the Agent Client Protocol. Each agent runs as its own subprocess speaking ACP over stdio and streams results back into the conversation.
|
|
@@ -24,6 +23,14 @@ Use `acp_spawn` to delegate a coding task to an external agent. The agent runs a
|
|
|
24
23
|
|
|
25
24
|
Users can refer to agents by natural names: "claude code", "codex cli", and "openai codex" all resolve to the canonical `claude` and `codex` ids (unless the user's config defines an agent literally keyed by that name, which always wins).
|
|
26
25
|
|
|
26
|
+
## When the user names Claude Code or Codex
|
|
27
|
+
|
|
28
|
+
If they name Claude Code or Codex without asking you to run it here (for example they say that tool will do the work), offer once, in one short sentence, that you can connect and run it in this conversation. Then continue with whatever they were doing.
|
|
29
|
+
|
|
30
|
+
- Do not spawn unless they accept.
|
|
31
|
+
- Skip if you already offered this conversation, they declined, or they are already connected.
|
|
32
|
+
- This is not the missing-token card path. Do not invent setup steps.
|
|
33
|
+
|
|
27
34
|
## First-time setup
|
|
28
35
|
|
|
29
36
|
ACP is always available - default profiles for `claude` and `codex` ship out-of-box, so no config edit is needed to start. First-time setup is just making the adapter binary available, then spawning:
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
diffDrizzleSchemaContract,
|
|
5
|
+
listDrizzleTables,
|
|
6
|
+
schemaContractIsClean,
|
|
7
|
+
} from "./schema-contract.js";
|
|
8
|
+
|
|
9
|
+
describe("listDrizzleTables", () => {
|
|
10
|
+
test("includes conversations.fork_strategy from the source catalog", () => {
|
|
11
|
+
const conversations = listDrizzleTables().find(
|
|
12
|
+
(table) => table.name === "conversations",
|
|
13
|
+
);
|
|
14
|
+
expect(conversations).toBeDefined();
|
|
15
|
+
expect(conversations!.columns).toContain("fork_strategy");
|
|
16
|
+
expect(conversations!.columns).toContain("id");
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test("de-duplicates aliased table exports", () => {
|
|
20
|
+
const names = listDrizzleTables().map((table) => table.name);
|
|
21
|
+
expect(names.filter((name) => name === "cron_jobs")).toEqual(["cron_jobs"]);
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
describe("diffDrizzleSchemaContract", () => {
|
|
26
|
+
test("reports a missing column on a table that exists", () => {
|
|
27
|
+
const conversations = listDrizzleTables().find(
|
|
28
|
+
(table) => table.name === "conversations",
|
|
29
|
+
);
|
|
30
|
+
expect(conversations).toBeDefined();
|
|
31
|
+
const liveColumns = new Set(
|
|
32
|
+
conversations!.columns.filter((column) => column !== "fork_strategy"),
|
|
33
|
+
);
|
|
34
|
+
const live = new Map<string, Set<string>>(
|
|
35
|
+
listDrizzleTables().map((table) => [
|
|
36
|
+
table.name,
|
|
37
|
+
table.name === "conversations" ? liveColumns : new Set(table.columns),
|
|
38
|
+
]),
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
const report = diffDrizzleSchemaContract(live);
|
|
42
|
+
expect(schemaContractIsClean(report)).toBe(false);
|
|
43
|
+
expect(report.missingTables).toEqual([]);
|
|
44
|
+
expect(report.missingColumns).toEqual([
|
|
45
|
+
{ table: "conversations", column: "fork_strategy" },
|
|
46
|
+
]);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("ignores extra SQLite columns that Drizzle does not own", () => {
|
|
50
|
+
const live = new Map<string, Set<string>>(
|
|
51
|
+
listDrizzleTables().map((table) => {
|
|
52
|
+
const columns = new Set(table.columns);
|
|
53
|
+
if (table.name === "conversations") {
|
|
54
|
+
columns.add("group_id");
|
|
55
|
+
}
|
|
56
|
+
return [table.name, columns];
|
|
57
|
+
}),
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
const report = diffDrizzleSchemaContract(live);
|
|
61
|
+
expect(schemaContractIsClean(report)).toBe(true);
|
|
62
|
+
expect(report.missingColumns).toEqual([]);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("reports a drizzle table that is absent from every live file", () => {
|
|
66
|
+
const live = new Map<string, Set<string>>(
|
|
67
|
+
listDrizzleTables()
|
|
68
|
+
.filter((table) => table.name !== "conversations")
|
|
69
|
+
.map((table) => [table.name, new Set(table.columns)]),
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
const report = diffDrizzleSchemaContract(live);
|
|
73
|
+
expect(report.missingTables).toEqual(["conversations"]);
|
|
74
|
+
expect(
|
|
75
|
+
report.missingColumns.some((miss) => miss.table === "conversations"),
|
|
76
|
+
).toBe(false);
|
|
77
|
+
});
|
|
78
|
+
});
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Compare the Drizzle table/column catalog in source to a live SQLite schema.
|
|
3
|
+
*
|
|
4
|
+
* Used by `assistant db status` to surface a binary that selects a
|
|
5
|
+
* column the live database never received. Extra SQLite tables or
|
|
6
|
+
* columns are ignored. Those are often raw-SQL fields (`group_id`) that
|
|
7
|
+
* Drizzle does not own.
|
|
8
|
+
*
|
|
9
|
+
* The catalog is the union of every `sqliteTable` exported from
|
|
10
|
+
* `schema/index.ts`. Tables live across the main, logs, memory, and
|
|
11
|
+
* telemetry files; callers merge those files before calling
|
|
12
|
+
* {@link diffDrizzleSchemaContract}.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { getTableColumns, getTableName, is, Table } from "drizzle-orm";
|
|
16
|
+
|
|
17
|
+
import * as schema from "./schema/index.js";
|
|
18
|
+
|
|
19
|
+
export interface SchemaColumnMiss {
|
|
20
|
+
table: string;
|
|
21
|
+
column: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface SchemaContractReport {
|
|
25
|
+
/** Drizzle tables with no matching SQLite table in the live files. */
|
|
26
|
+
missingTables: string[];
|
|
27
|
+
/** Drizzle columns whose table exists in SQLite but the column does not. */
|
|
28
|
+
missingColumns: SchemaColumnMiss[];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface DrizzleTableColumns {
|
|
32
|
+
name: string;
|
|
33
|
+
columns: string[];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Every Drizzle table name and its SQL column names, de-duplicated. */
|
|
37
|
+
export function listDrizzleTables(): DrizzleTableColumns[] {
|
|
38
|
+
const byName = new Map<string, string[]>();
|
|
39
|
+
for (const value of Object.values(schema)) {
|
|
40
|
+
if (!is(value, Table)) {
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
const name = getTableName(value);
|
|
44
|
+
if (!name) {
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
const columns = Object.values(getTableColumns(value)).map(
|
|
48
|
+
(column) => column.name,
|
|
49
|
+
);
|
|
50
|
+
byName.set(name, columns);
|
|
51
|
+
}
|
|
52
|
+
return [...byName.entries()]
|
|
53
|
+
.map(([name, columns]) => ({ name, columns: [...columns].sort() }))
|
|
54
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Diff Drizzle's catalog against a merged live schema (table name → columns).
|
|
59
|
+
* Tables and columns present only in SQLite are not reported.
|
|
60
|
+
*/
|
|
61
|
+
export function diffDrizzleSchemaContract(
|
|
62
|
+
liveColumnsByTable: Map<string, ReadonlySet<string>>,
|
|
63
|
+
): SchemaContractReport {
|
|
64
|
+
const missingTables: string[] = [];
|
|
65
|
+
const missingColumns: SchemaColumnMiss[] = [];
|
|
66
|
+
|
|
67
|
+
for (const table of listDrizzleTables()) {
|
|
68
|
+
const live = liveColumnsByTable.get(table.name);
|
|
69
|
+
if (!live) {
|
|
70
|
+
missingTables.push(table.name);
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
for (const column of table.columns) {
|
|
74
|
+
if (!live.has(column)) {
|
|
75
|
+
missingColumns.push({ table: table.name, column });
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
missingTables.sort();
|
|
81
|
+
missingColumns.sort((a, b) => {
|
|
82
|
+
const tableCmp = a.table.localeCompare(b.table);
|
|
83
|
+
if (tableCmp !== 0) {
|
|
84
|
+
return tableCmp;
|
|
85
|
+
}
|
|
86
|
+
return a.column.localeCompare(b.column);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
return { missingTables, missingColumns };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function schemaContractIsClean(report: SchemaContractReport): boolean {
|
|
93
|
+
return (
|
|
94
|
+
report.missingTables.length === 0 && report.missingColumns.length === 0
|
|
95
|
+
);
|
|
96
|
+
}
|
package/src/tools/skills/load.ts
CHANGED
|
@@ -158,7 +158,7 @@ export const skillLoadTool = {
|
|
|
158
158
|
name: "skill_load",
|
|
159
159
|
|
|
160
160
|
description:
|
|
161
|
-
'Load full instructions for a skill. Works for both bundled skills (listed in the catalog) and custom workspace skills. Bundled/first-party skills (like `app-builder`) are already installed: loading only activates their instructions for the current conversation, since skills unload between turns. Treat "load" as activation, not installation, and don\'t tell users a bundled skill needs installing. Loading can still fail (e.g. a feature-gated skill returns "currently unavailable", or the load errors). If it does, relay that specific error rather than claiming the skill is not installed or offering to install it. For app, website, dashboard, game, calculator, tracker, visualization, or interactive tool requests, load `app-builder` with `skill: "app-builder"`. For recurring monitoring of a page, dashboard, or status (check it later and notify), or for reminders and timed checks, load `schedule` with `skill: "schedule"`.',
|
|
161
|
+
'Load full instructions for a skill. Works for both bundled skills (listed in the catalog) and custom workspace skills. Bundled/first-party skills (like `app-builder`) are already installed: loading only activates their instructions for the current conversation, since skills unload between turns. Treat "load" as activation, not installation, and don\'t tell users a bundled skill needs installing. Loading can still fail (e.g. a feature-gated skill returns "currently unavailable", or the load errors). If it does, relay that specific error rather than claiming the skill is not installed or offering to install it. For app, website, dashboard, game, calculator, tracker, visualization, or interactive tool requests, load `app-builder` with `skill: "app-builder"`. For recurring monitoring of a page, dashboard, or status (check it later and notify), or for reminders and timed checks, load `schedule` with `skill: "schedule"`. When the user names Claude Code or Codex (including as a tool they will use elsewhere), load `acp` with `skill: "acp"`.',
|
|
162
162
|
|
|
163
163
|
category: "skills",
|
|
164
164
|
|