@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/assistant",
3
- "version": "0.11.7-dev.202609012016.14eabff",
3
+ "version": "0.11.7-dev.202609012114.f9ebd01",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -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 ["set up", "install", "authenticate", "connect"]) {
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 the 5 largest tables",
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 connect Claude Code or Codex"
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
+ }
@@ -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