membot 0.0.1 → 0.1.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 (78) hide show
  1. package/.claude/skills/membot.md +137 -0
  2. package/.cursor/rules/membot.mdc +137 -0
  3. package/README.md +131 -0
  4. package/package.json +83 -24
  5. package/patches/@huggingface%2Ftransformers@4.2.0.patch +137 -0
  6. package/scripts/apply-transformers-patch.sh +35 -0
  7. package/src/cli.ts +72 -0
  8. package/src/commands/check-update.ts +69 -0
  9. package/src/commands/mcpx.ts +112 -0
  10. package/src/commands/reindex.ts +53 -0
  11. package/src/commands/serve.ts +58 -0
  12. package/src/commands/skill.ts +131 -0
  13. package/src/commands/upgrade.ts +220 -0
  14. package/src/config/loader.ts +100 -0
  15. package/src/config/schemas.ts +39 -0
  16. package/src/constants.ts +42 -0
  17. package/src/context.ts +80 -0
  18. package/src/db/blobs.ts +53 -0
  19. package/src/db/chunks.ts +176 -0
  20. package/src/db/connection.ts +173 -0
  21. package/src/db/files.ts +325 -0
  22. package/src/db/migrations/001-init.ts +63 -0
  23. package/src/db/migrations/002-fts.ts +12 -0
  24. package/src/db/migrations.ts +45 -0
  25. package/src/errors.ts +87 -0
  26. package/src/ingest/chunker.ts +117 -0
  27. package/src/ingest/converter/docx.ts +15 -0
  28. package/src/ingest/converter/html.ts +20 -0
  29. package/src/ingest/converter/image.ts +71 -0
  30. package/src/ingest/converter/index.ts +119 -0
  31. package/src/ingest/converter/llm.ts +66 -0
  32. package/src/ingest/converter/ocr.ts +51 -0
  33. package/src/ingest/converter/pdf.ts +38 -0
  34. package/src/ingest/converter/text.ts +8 -0
  35. package/src/ingest/describer.ts +72 -0
  36. package/src/ingest/embedder.ts +98 -0
  37. package/src/ingest/fetcher.ts +280 -0
  38. package/src/ingest/ingest.ts +444 -0
  39. package/src/ingest/local-reader.ts +64 -0
  40. package/src/ingest/search-text.ts +18 -0
  41. package/src/ingest/source-resolver.ts +186 -0
  42. package/src/mcp/instructions.ts +34 -0
  43. package/src/mcp/server.ts +101 -0
  44. package/src/mount/commander.ts +174 -0
  45. package/src/mount/mcp.ts +111 -0
  46. package/src/mount/zod-to-cli.ts +158 -0
  47. package/src/operations/add.ts +69 -0
  48. package/src/operations/diff.ts +105 -0
  49. package/src/operations/index.ts +38 -0
  50. package/src/operations/info.ts +95 -0
  51. package/src/operations/list.ts +87 -0
  52. package/src/operations/move.ts +83 -0
  53. package/src/operations/prune.ts +80 -0
  54. package/src/operations/read.ts +102 -0
  55. package/src/operations/refresh.ts +72 -0
  56. package/src/operations/remove.ts +35 -0
  57. package/src/operations/search.ts +72 -0
  58. package/src/operations/tree.ts +103 -0
  59. package/src/operations/types.ts +81 -0
  60. package/src/operations/versions.ts +78 -0
  61. package/src/operations/write.ts +77 -0
  62. package/src/output/formatter.ts +68 -0
  63. package/src/output/logger.ts +114 -0
  64. package/src/output/progress.ts +78 -0
  65. package/src/output/tty.ts +91 -0
  66. package/src/refresh/runner.ts +296 -0
  67. package/src/refresh/scheduler.ts +54 -0
  68. package/src/sdk.ts +27 -0
  69. package/src/search/hybrid.ts +100 -0
  70. package/src/search/keyword.ts +62 -0
  71. package/src/search/semantic.ts +56 -0
  72. package/src/types/text-modules.d.ts +9 -0
  73. package/src/update/background.ts +73 -0
  74. package/src/update/cache.ts +40 -0
  75. package/src/update/checker.ts +117 -0
  76. package/.claude/settings.local.json +0 -7
  77. package/CLAUDE.md +0 -139
  78. package/docs/plan.md +0 -905
@@ -0,0 +1,35 @@
1
+ import { z } from "zod";
2
+ import { getCurrent, tombstone } from "../db/files.ts";
3
+ import { HelpfulError } from "../errors.ts";
4
+ import { colors } from "../output/formatter.ts";
5
+ import { defineOperation } from "./types.ts";
6
+
7
+ export const removeOperation = defineOperation({
8
+ name: "membot_delete",
9
+ cliName: "rm",
10
+ bashEquivalent: "rm",
11
+ description: `Tombstone a logical_path so it no longer appears in membot_list / membot_tree / membot_search. Old versions remain queryable via membot_versions and membot_read with an explicit version. Use membot_prune to permanently drop history.`,
12
+ inputSchema: z.object({
13
+ logical_path: z.string().describe("Path to tombstone"),
14
+ change_note: z.string().optional().describe("Why this is being deleted"),
15
+ }),
16
+ outputSchema: z.object({
17
+ logical_path: z.string(),
18
+ tombstone_version_id: z.string(),
19
+ }),
20
+ cli: { positional: ["logical_path"], aliases: { change_note: "-m" } },
21
+ console_formatter: (result) =>
22
+ `${colors.green("✓")} tombstoned ${colors.cyan(result.logical_path)} ${colors.dim(`@ ${result.tombstone_version_id}`)}`,
23
+ handler: async (input, ctx) => {
24
+ const cur = await getCurrent(ctx.db, input.logical_path);
25
+ if (!cur) {
26
+ throw new HelpfulError({
27
+ kind: "not_found",
28
+ message: `${input.logical_path} doesn't exist (or is already tombstoned)`,
29
+ hint: `Run \`membot ls\` to see active paths, or \`membot versions ${input.logical_path}\` to see history.`,
30
+ });
31
+ }
32
+ const v = await tombstone(ctx.db, input.logical_path, input.change_note ?? "deleted");
33
+ return { logical_path: input.logical_path, tombstone_version_id: v };
34
+ },
35
+ });
@@ -0,0 +1,72 @@
1
+ import { z } from "zod";
2
+ import { embedSingle } from "../ingest/embedder.ts";
3
+ import { colors } from "../output/formatter.ts";
4
+ import { fuseRRF } from "../search/hybrid.ts";
5
+ import { searchKeyword } from "../search/keyword.ts";
6
+ import { searchSemantic } from "../search/semantic.ts";
7
+ import { defineOperation } from "./types.ts";
8
+
9
+ export const searchOperation = defineOperation({
10
+ name: "membot_search",
11
+ cliName: "search",
12
+ bashEquivalent: "grep -r + semantic-search",
13
+ description: `Hybrid search over the context store. Pass \`query\` (natural language → semantic) and/or \`pattern\` (keyword/BM25); pass both for the strongest signal — hits matched by both float to the top via reciprocal rank fusion. Searches the CURRENT version of every file by default; set \`include_history=true\` to also search older versions. This is the primary discovery tool — prefer it over membot_read+scan.`,
14
+ inputSchema: z.object({
15
+ query: z.string().optional().describe("Natural-language query for semantic search"),
16
+ pattern: z.string().optional().describe("Keyword query for BM25 search"),
17
+ mode: z.enum(["hybrid", "semantic", "keyword"]).default("hybrid").describe("Search mode"),
18
+ path_prefix: z.string().optional().describe("Restrict to logical paths starting with this prefix"),
19
+ limit: z.number().default(10).describe("Max hits to return"),
20
+ include_history: z.boolean().default(false).describe("Also search older versions (default: current only)"),
21
+ }),
22
+ outputSchema: z.object({
23
+ hits: z.array(
24
+ z.object({
25
+ logical_path: z.string(),
26
+ version_id: z.string(),
27
+ chunk_index: z.number(),
28
+ snippet: z.string(),
29
+ score: z.number(),
30
+ semantic_score: z.number().nullable(),
31
+ keyword_score: z.number().nullable(),
32
+ }),
33
+ ),
34
+ mode: z.string(),
35
+ }),
36
+ cli: { positional: ["query"] },
37
+ console_formatter: (result) => {
38
+ if (result.hits.length === 0) {
39
+ return colors.dim(`(no hits in ${result.mode} mode)`);
40
+ }
41
+ const blocks = result.hits.map((h) => {
42
+ const head = `${colors.cyan(h.logical_path)} ${colors.dim(`v=${h.version_id}`)} ${colors.green(`score=${h.score.toFixed(3)}`)}`;
43
+ const snippet = h.snippet
44
+ .split("\n")
45
+ .map((l) => ` ${l}`)
46
+ .join("\n");
47
+ return `${head}\n${colors.dim(snippet)}`;
48
+ });
49
+ return `${blocks.join("\n\n")}\n${colors.dim(`${result.hits.length} hit${result.hits.length === 1 ? "" : "s"} in ${result.mode} mode`)}`;
50
+ },
51
+ handler: async (input, ctx) => {
52
+ const query = input.query ?? input.pattern ?? "";
53
+ const pattern = input.pattern ?? input.query ?? "";
54
+
55
+ const semanticHits =
56
+ input.mode === "keyword" || !query.trim()
57
+ ? []
58
+ : await searchSemantic(ctx.db, await embedSingle(query, ctx.config.embedding_model), {
59
+ limit: input.limit * 5,
60
+ pathPrefix: input.path_prefix,
61
+ includeHistory: input.include_history,
62
+ });
63
+
64
+ const keywordHits =
65
+ input.mode === "semantic" || !pattern.trim()
66
+ ? []
67
+ : await searchKeyword(ctx.db, pattern, { limit: input.limit * 5, pathPrefix: input.path_prefix });
68
+
69
+ const fused = fuseRRF(semanticHits, keywordHits, { limit: input.limit });
70
+ return { hits: fused, mode: input.mode };
71
+ },
72
+ });
@@ -0,0 +1,103 @@
1
+ import { z } from "zod";
2
+ import { listAllCurrentPaths } from "../db/files.ts";
3
+ import { colors } from "../output/formatter.ts";
4
+ import { defineOperation } from "./types.ts";
5
+
6
+ interface TreeNode {
7
+ name: string;
8
+ full_path: string;
9
+ is_file: boolean;
10
+ children?: TreeNode[];
11
+ }
12
+
13
+ export const treeOperation = defineOperation({
14
+ name: "membot_tree",
15
+ cliName: "tree",
16
+ bashEquivalent: "tree",
17
+ description: `Render the logical-path tree of the current store. Tree is synthesised from "/" segments in logical_path — there are no real directories. Tombstoned and historical versions are hidden. Use this before membot_add to pick a sensible logical path.`,
18
+ inputSchema: z.object({
19
+ prefix: z.string().optional().describe("Only show paths starting with this prefix"),
20
+ max_depth: z.number().default(4).describe("How many path segments deep to render"),
21
+ }),
22
+ outputSchema: z.object({
23
+ root: z.string(),
24
+ tree: z.array(
25
+ z.object({
26
+ name: z.string(),
27
+ full_path: z.string(),
28
+ is_file: z.boolean(),
29
+ children: z.array(z.unknown()).optional(),
30
+ }),
31
+ ),
32
+ }),
33
+ cli: { positional: ["prefix"] },
34
+ console_formatter: (result) => {
35
+ const lines: string[] = [colors.bold(result.root)];
36
+ const nodes = result.tree as TreeNode[];
37
+ renderNodes(nodes, "", lines);
38
+ if (lines.length === 1) lines.push(colors.dim("(empty)"));
39
+ return lines.join("\n");
40
+ },
41
+ handler: async (input, ctx) => {
42
+ const allPaths = await listAllCurrentPaths(ctx.db);
43
+ const filtered = input.prefix ? allPaths.filter((p) => p.startsWith(input.prefix!)) : allPaths;
44
+ return { root: input.prefix ?? "/", tree: buildTree(filtered, input.max_depth) };
45
+ },
46
+ });
47
+
48
+ /**
49
+ * Build a tree of TreeNode objects from a flat list of `/`-delimited paths.
50
+ * Splits each path into segments and groups by common prefix; nodes deeper
51
+ * than `maxDepth` are folded into their parent's `children` summary count.
52
+ */
53
+ function buildTree(paths: string[], maxDepth: number): TreeNode[] {
54
+ const root: Map<string, TreeNode> = new Map();
55
+ for (const path of paths) {
56
+ const segs = path.split("/").filter(Boolean);
57
+ let level = root;
58
+ const trail: string[] = [];
59
+ for (let i = 0; i < segs.length && i < maxDepth; i++) {
60
+ const seg = segs[i]!;
61
+ trail.push(seg);
62
+ const fullPath = trail.join("/");
63
+ let node = level.get(seg);
64
+ if (!node) {
65
+ node = { name: seg, full_path: fullPath, is_file: i === segs.length - 1 };
66
+ level.set(seg, node);
67
+ } else if (i === segs.length - 1) {
68
+ node.is_file = true;
69
+ }
70
+ if (i < segs.length - 1) {
71
+ if (!node.children) node.children = [];
72
+ const childMap = new Map(node.children.map((c) => [c.name, c] as const));
73
+ node.children = [...childMap.values()];
74
+ level = childMap;
75
+ if (childMap.size === 0) {
76
+ level = new Map();
77
+ node.children = [];
78
+ } else {
79
+ // rebuild level pointer
80
+ level = new Map(node.children.map((c) => [c.name, c] as const));
81
+ }
82
+ }
83
+ }
84
+ }
85
+ return [...root.values()].sort((a, b) => a.name.localeCompare(b.name));
86
+ }
87
+
88
+ /**
89
+ * Walk a tree and append `├── name` / `└── name` lines with proper continuation
90
+ * prefixes. Directories are rendered in cyan-bold; files in plain text.
91
+ */
92
+ function renderNodes(nodes: TreeNode[], prefix: string, out: string[]): void {
93
+ const sorted = [...nodes].sort((a, b) => a.name.localeCompare(b.name));
94
+ sorted.forEach((node, i) => {
95
+ const last = i === sorted.length - 1;
96
+ const branch = last ? "└── " : "├── ";
97
+ const label = node.is_file && !node.children?.length ? node.name : colors.cyan(colors.bold(node.name));
98
+ out.push(`${prefix}${branch}${label}`);
99
+ if (node.children?.length) {
100
+ renderNodes(node.children, prefix + (last ? " " : "│ "), out);
101
+ }
102
+ });
103
+ }
@@ -0,0 +1,81 @@
1
+ import type { z } from "zod";
2
+ import type { AppContext } from "../context.ts";
3
+
4
+ /**
5
+ * One user-facing capability defined ONCE: an MCP tool registration AND a
6
+ * commander CLI subcommand. Both surfaces consume `description`, `inputSchema`,
7
+ * and the handler from the same value, so help text and argument parsing
8
+ * cannot drift apart.
9
+ */
10
+ export interface Operation<I extends z.ZodObject = z.ZodObject, O extends z.ZodTypeAny = z.ZodTypeAny> {
11
+ /** MCP tool name. Convention: `membot_<verb>` (e.g. "membot_add"). */
12
+ name: string;
13
+ /** CLI subcommand name. Defaults to `name.replace(/^membot_/, "").replaceAll("_", "-")`. */
14
+ cliName?: string;
15
+ /**
16
+ * Optional bash-equivalent label (e.g. `cat`, `grep -r`). Surfaced as
17
+ * `[[ bash equivalent: <value> ]]` by the description composer when the
18
+ * mount adapter wants the prefix. Stored separately from `description`
19
+ * so callers can render with or without the prefix as they choose.
20
+ */
21
+ bashEquivalent?: string;
22
+ /**
23
+ * Pure prose description string. Shown to the LLM in `tools/list` AND to
24
+ * humans via `--help` (the mount adapter prepends `bashEquivalent` when
25
+ * present). Should follow purpose → when-to-use → recovery-hint shape.
26
+ */
27
+ description: string;
28
+ /** Single source of truth for the input contract. */
29
+ inputSchema: I;
30
+ /** Output contract — validated before being returned to the caller. */
31
+ outputSchema: O;
32
+ /** CLI-only metadata (positional args, short flag aliases, stdin source). */
33
+ cli?: CliMetadata<I>;
34
+ /**
35
+ * Optional console formatter. Called by the commander mount adapter when
36
+ * stdout is a TTY (and not in `--json` mode) to render the operation's
37
+ * output as colorized human text. The MCP surface ignores this — agents
38
+ * always receive the structured `outputSchema` data. When unset, the CLI
39
+ * falls back to pretty-printed JSON.
40
+ */
41
+ console_formatter?: (result: z.infer<O>) => string;
42
+ /** The work itself. AppContext gives access to db, embedder, mcpx, logger, config. */
43
+ handler: (input: z.infer<I>, ctx: AppContext) => Promise<z.infer<O>>;
44
+ }
45
+
46
+ /** CLI-only knobs for an Operation: positional args, short-flag aliases, stdin sourcing. */
47
+ export interface CliMetadata<I extends z.ZodObject> {
48
+ /** Field names that should become positional arguments instead of flags. */
49
+ positional?: (keyof z.infer<I>)[];
50
+ /** Short-flag aliases keyed by field name (e.g. `{ logical_path: "-p" }`). */
51
+ aliases?: Partial<Record<keyof z.infer<I>, string>>;
52
+ /** Field name that should be filled from stdin when not otherwise supplied. */
53
+ stdinField?: keyof z.infer<I>;
54
+ }
55
+
56
+ /** Helper that infers the generic params and lets call sites stay terse. */
57
+ export function defineOperation<I extends z.ZodObject, O extends z.ZodTypeAny>(op: Operation<I, O>): Operation<I, O> {
58
+ return op;
59
+ }
60
+
61
+ /**
62
+ * Default CLI command name for an Operation. Strips the `membot_` prefix and
63
+ * converts underscores to dashes. Override via `op.cliName`.
64
+ */
65
+ export function defaultCliName(op: { name: string; cliName?: string }): string {
66
+ if (op.cliName) return op.cliName;
67
+ return op.name.replace(/^membot_/, "").replaceAll("_", "-");
68
+ }
69
+
70
+ /**
71
+ * Compose the surface-rendered description: `[[ bash equivalent: X ]] <desc>`
72
+ * when `bashEquivalent` is set, otherwise the raw `description`. Used by both
73
+ * the MCP and commander mount adapters so the same string lands in front of
74
+ * agents (`tools/list`) and humans (`--help`).
75
+ */
76
+ export function composeDescription(op: { description: string; bashEquivalent?: string }): string {
77
+ if (op.bashEquivalent?.trim()) {
78
+ return `[[ bash equivalent: ${op.bashEquivalent.trim()} ]] ${op.description}`;
79
+ }
80
+ return op.description;
81
+ }
@@ -0,0 +1,78 @@
1
+ import { z } from "zod";
2
+ import { listVersions } from "../db/files.ts";
3
+ import { colors, renderTable } from "../output/formatter.ts";
4
+ import { defineOperation } from "./types.ts";
5
+
6
+ export const versionsOperation = defineOperation({
7
+ name: "membot_versions",
8
+ cliName: "versions",
9
+ description: `List every version of a file (newest first) with version_id, content_sha256, size, change_note, and refresh status. Use this to find the version_id you want to pass to membot_read or membot_diff. Tombstoned versions are included and flagged.`,
10
+ inputSchema: z.object({
11
+ logical_path: z.string().describe("Path whose versions to list"),
12
+ }),
13
+ outputSchema: z.object({
14
+ logical_path: z.string(),
15
+ versions: z.array(
16
+ z.object({
17
+ version_id: z.string(),
18
+ content_sha256: z.string().nullable(),
19
+ source_sha256: z.string().nullable(),
20
+ size_bytes: z.number().nullable(),
21
+ change_note: z.string().nullable(),
22
+ last_refresh_status: z.string().nullable(),
23
+ tombstone: z.boolean(),
24
+ created_at: z.string(),
25
+ }),
26
+ ),
27
+ }),
28
+ cli: { positional: ["logical_path"] },
29
+ console_formatter: (result) => {
30
+ if (result.versions.length === 0) {
31
+ return `${colors.cyan(result.logical_path)}\n${colors.dim("(no versions)")}`;
32
+ }
33
+ // Newest first — first row is current unless tombstoned.
34
+ let currentMarked = false;
35
+ const rows = result.versions.map((v) => {
36
+ let marker = " ";
37
+ if (!currentMarked && !v.tombstone) {
38
+ marker = colors.green("→");
39
+ currentMarked = true;
40
+ }
41
+ const status = v.tombstone
42
+ ? colors.red("tombstone")
43
+ : v.last_refresh_status === "failed"
44
+ ? colors.red(v.last_refresh_status)
45
+ : (v.last_refresh_status ?? "-");
46
+ return [
47
+ marker,
48
+ v.tombstone ? colors.dim(v.version_id) : v.version_id,
49
+ v.created_at,
50
+ v.size_bytes !== null ? String(v.size_bytes) : "-",
51
+ (v.content_sha256 ?? "-").slice(0, 12),
52
+ status,
53
+ v.change_note ?? "",
54
+ ];
55
+ });
56
+ const header = `${colors.bold(result.logical_path)}`;
57
+ const table = renderTable(["", "VERSION", "CREATED", "SIZE", "SHA", "STATUS", "NOTE"], rows, {
58
+ columnStyles: [undefined, colors.cyan, colors.dim, colors.dim, colors.dim],
59
+ });
60
+ return `${header}\n${table}`;
61
+ },
62
+ handler: async (input, ctx) => {
63
+ const versions = await listVersions(ctx.db, input.logical_path);
64
+ return {
65
+ logical_path: input.logical_path,
66
+ versions: versions.map((v) => ({
67
+ version_id: v.version_id,
68
+ content_sha256: v.content_sha256,
69
+ source_sha256: v.source_sha256,
70
+ size_bytes: v.size_bytes,
71
+ change_note: v.change_note,
72
+ last_refresh_status: v.last_refresh_status,
73
+ tombstone: v.tombstone,
74
+ created_at: v.created_at,
75
+ })),
76
+ };
77
+ },
78
+ });
@@ -0,0 +1,77 @@
1
+ import { z } from "zod";
2
+ import { insertChunksForVersion, rebuildFts } from "../db/chunks.ts";
3
+ import { insertVersion, millisIso } from "../db/files.ts";
4
+ import { chunkDeterministic } from "../ingest/chunker.ts";
5
+ import { describe } from "../ingest/describer.ts";
6
+ import { embed } from "../ingest/embedder.ts";
7
+ import { parseDuration } from "../ingest/ingest.ts";
8
+ import { sha256Hex } from "../ingest/local-reader.ts";
9
+ import { buildSearchText } from "../ingest/search-text.ts";
10
+ import { colors } from "../output/formatter.ts";
11
+ import { defineOperation } from "./types.ts";
12
+
13
+ export const writeOperation = defineOperation({
14
+ name: "membot_write",
15
+ cliName: "write",
16
+ bashEquivalent: "tee",
17
+ description: `Write inline agent-authored markdown. Creates a new version (source_type='inline') under the given logical_path. Use this to persist agent notes, summaries, or synthesised context that should survive across conversations. For mirroring an external document, use membot_add with a source URL instead — that gets you refresh-on-source-change for free.`,
18
+ inputSchema: z.object({
19
+ logical_path: z.string().describe("Path to write to"),
20
+ content: z.string().describe("Markdown body. CLI: pass via stdin if unspecified."),
21
+ change_note: z.string().optional().describe("Free-text note attached to the new version"),
22
+ refresh_frequency: z.string().optional().describe("Refresh cadence (rarely useful for inline)"),
23
+ }),
24
+ outputSchema: z.object({
25
+ logical_path: z.string(),
26
+ version_id: z.string(),
27
+ size_bytes: z.number(),
28
+ }),
29
+ cli: { positional: ["logical_path"], stdinField: "content" },
30
+ console_formatter: (result) =>
31
+ `${colors.green("✓")} ${colors.cyan(result.logical_path)} ${colors.dim(`@ ${result.version_id}`)} ${colors.dim(`(${result.size_bytes}B)`)}`,
32
+ handler: async (input, ctx) => {
33
+ const refreshSec = parseDuration(input.refresh_frequency);
34
+ const bytes = new TextEncoder().encode(input.content);
35
+ const description = await describe(input.logical_path, "text/markdown", input.content, ctx.config.llm);
36
+ const chunks = chunkDeterministic(input.content, ctx.config.chunker);
37
+ const searchTexts = chunks.map((c) => buildSearchText(input.logical_path, description, c.content));
38
+ const embeddings = await embed(searchTexts, ctx.config.embedding_model);
39
+
40
+ const versionId = millisIso(Date.now());
41
+ const contentSha = sha256Hex(bytes);
42
+ await insertVersion(ctx.db, {
43
+ logical_path: input.logical_path,
44
+ version_id: versionId,
45
+ source_type: "inline",
46
+ source_path: null,
47
+ source_mtime_ms: null,
48
+ source_sha256: contentSha,
49
+ blob_sha256: null,
50
+ content_sha256: contentSha,
51
+ content: input.content,
52
+ description,
53
+ mime_type: "text/markdown",
54
+ size_bytes: bytes.byteLength,
55
+ fetcher: "inline",
56
+ refresh_frequency_sec: refreshSec,
57
+ refreshed_at: new Date().toISOString(),
58
+ last_refresh_status: "ok",
59
+ change_note: input.change_note ?? null,
60
+ });
61
+
62
+ await insertChunksForVersion(
63
+ ctx.db,
64
+ input.logical_path,
65
+ versionId,
66
+ chunks.map((c, i) => ({
67
+ chunk_index: c.index,
68
+ chunk_content: c.content,
69
+ search_text: searchTexts[i] ?? buildSearchText(input.logical_path, description, c.content),
70
+ embedding: embeddings[i] ?? new Array(embeddings[0]?.length ?? 0).fill(0),
71
+ })),
72
+ );
73
+ await rebuildFts(ctx.db);
74
+
75
+ return { logical_path: input.logical_path, version_id: versionId, size_bytes: bytes.byteLength };
76
+ },
77
+ });
@@ -0,0 +1,68 @@
1
+ import ansis, { bold, cyan, dim, green, red, yellow } from "ansis";
2
+ import { isJson, useColor } from "./tty.ts";
3
+
4
+ function colorize(fn: (s: string) => string, msg: string): string {
5
+ return useColor() ? fn(msg) : msg;
6
+ }
7
+
8
+ /**
9
+ * Render a final result for the CLI. JSON mode → JSON.stringify. Otherwise
10
+ * defer to the optional `console_formatter`, falling back to JSON.
11
+ */
12
+ export function renderResult<T>(result: T, opts: { console_formatter?: (result: T) => string } = {}): string {
13
+ if (isJson()) {
14
+ return JSON.stringify(result, null, 2);
15
+ }
16
+ if (opts.console_formatter) return opts.console_formatter(result);
17
+ if (typeof result === "string") return result;
18
+ return JSON.stringify(result, null, 2);
19
+ }
20
+
21
+ /**
22
+ * Pretty-print a 2D array of cells as an aligned table. Column widths are
23
+ * computed from the visible (escape-stripped) length of each cell so coloured
24
+ * cells still align. Optional `columnStyles` are applied AFTER padding so they
25
+ * don't perturb width math.
26
+ */
27
+ export function renderTable(
28
+ headers: string[],
29
+ rows: string[][],
30
+ opts: { columnStyles?: (((s: string) => string) | undefined)[] } = {},
31
+ ): string {
32
+ const widths = headers.map((h, i) => Math.max(visibleLen(h), ...rows.map((r) => visibleLen(r[i] ?? ""))));
33
+ const styles = opts.columnStyles ?? [];
34
+
35
+ const headerLine = headers.map((h, i) => pad(h, widths[i] ?? 0)).join(" ");
36
+ const separator = headers.map((_, i) => "─".repeat(widths[i] ?? 0)).join(" ");
37
+ const bodyLines = rows.map((r) =>
38
+ r
39
+ .map((cell, i) => {
40
+ const padded = pad(cell ?? "", widths[i] ?? 0);
41
+ const style = styles[i];
42
+ return style ? style(padded) : padded;
43
+ })
44
+ .join(" "),
45
+ );
46
+
47
+ const out = [colorize(bold, headerLine), colorize(dim, separator), ...bodyLines];
48
+ return out.join("\n");
49
+ }
50
+
51
+ function visibleLen(s: string): number {
52
+ return ansis.strip(s).length;
53
+ }
54
+
55
+ function pad(s: string, width: number): string {
56
+ const visible = visibleLen(s);
57
+ if (visible >= width) return s;
58
+ return s + " ".repeat(width - visible);
59
+ }
60
+
61
+ export const colors = {
62
+ bold: (s: string) => colorize(bold, s),
63
+ dim: (s: string) => colorize(dim, s),
64
+ red: (s: string) => colorize(red, s),
65
+ green: (s: string) => colorize(green, s),
66
+ yellow: (s: string) => colorize(yellow, s),
67
+ cyan: (s: string) => colorize(cyan, s),
68
+ };
@@ -0,0 +1,114 @@
1
+ import { dim, red, yellow } from "ansis";
2
+ import { createSpinner } from "nanospinner";
3
+ import { getMode, isJson, isSilent, isVerbose, useColor, useSpinner } from "./tty.ts";
4
+
5
+ export interface Spinner {
6
+ update(text: string): void;
7
+ success(text?: string): void;
8
+ error(text?: string): void;
9
+ stop(): void;
10
+ }
11
+
12
+ const NOOP_SPINNER: Spinner = { update() {}, success() {}, error() {}, stop() {} };
13
+
14
+ /**
15
+ * Process-wide singleton that owns stderr writes. All output is spinner-aware
16
+ * (clears the active spinner line, writes, then re-renders) so log lines don't
17
+ * shred a running progress indicator. Honors JSON, verbose, and color modes
18
+ * decided in `tty.ts` so callers never have to branch on environment.
19
+ */
20
+ class Logger {
21
+ private static instance: Logger;
22
+ private activeSpinner: ReturnType<typeof createSpinner> | null = null;
23
+
24
+ /** Singleton accessor. Use the exported `logger` const instead in normal code. */
25
+ static getInstance(): Logger {
26
+ if (!Logger.instance) Logger.instance = new Logger();
27
+ return Logger.instance;
28
+ }
29
+
30
+ private color(fn: (s: string) => string, msg: string): string {
31
+ return useColor() ? fn(msg) : msg;
32
+ }
33
+
34
+ private writeStderr(msg: string): void {
35
+ if (this.activeSpinner) {
36
+ this.activeSpinner.clear();
37
+ process.stderr.write(`${msg}\n`);
38
+ this.activeSpinner.render();
39
+ } else {
40
+ process.stderr.write(`${msg}\n`);
41
+ }
42
+ }
43
+
44
+ /** Advisory info — stderr in interactive, suppressed in JSON or silent (CI/test). */
45
+ info(msg: string): void {
46
+ if (isJson() || isSilent()) return;
47
+ this.writeStderr(this.color(dim, msg));
48
+ }
49
+
50
+ /** Advisory warn — yellow on TTY, suppressed in JSON mode. */
51
+ warn(msg: string): void {
52
+ if (isJson()) return;
53
+ this.writeStderr(this.color(yellow, msg));
54
+ }
55
+
56
+ /** Errors always print, even in JSON mode (stderr won't break parseable stdout). */
57
+ error(msg: string): void {
58
+ this.writeStderr(this.color(red, msg));
59
+ }
60
+
61
+ /** Verbose-only debug. Silent unless `--verbose` is set, and always silent in JSON mode. */
62
+ debug(msg: string): void {
63
+ if (!isVerbose() || isJson()) return;
64
+ this.writeStderr(this.color(dim, msg));
65
+ }
66
+
67
+ /** Raw stderr write, no formatting added. Spinner-aware. */
68
+ writeRaw(msg: string): void {
69
+ if (this.activeSpinner) {
70
+ this.activeSpinner.clear();
71
+ process.stderr.write(msg);
72
+ this.activeSpinner.render();
73
+ } else {
74
+ process.stderr.write(msg);
75
+ }
76
+ }
77
+
78
+ /**
79
+ * Start a stderr spinner. Returns a `Spinner` controller in interactive
80
+ * mode; in JSON / piped / `CI=true` / `NO_COLOR` environments it returns
81
+ * a no-op so call sites can use the same code path either way.
82
+ */
83
+ startSpinner(text: string): Spinner {
84
+ if (!useSpinner()) return NOOP_SPINNER;
85
+
86
+ const spinner = createSpinner(text, { stream: process.stderr }).start();
87
+ this.activeSpinner = spinner;
88
+
89
+ return {
90
+ update: (t: string) => {
91
+ spinner.update({ text: t });
92
+ },
93
+ success: (t?: string) => {
94
+ spinner.success({ text: t });
95
+ if (this.activeSpinner === spinner) this.activeSpinner = null;
96
+ },
97
+ error: (t?: string) => {
98
+ spinner.error({ text: t });
99
+ if (this.activeSpinner === spinner) this.activeSpinner = null;
100
+ },
101
+ stop: () => {
102
+ spinner.stop();
103
+ if (this.activeSpinner === spinner) this.activeSpinner = null;
104
+ },
105
+ };
106
+ }
107
+
108
+ /** True when the logger should emit human output (used by progress). */
109
+ humanOutput(): boolean {
110
+ return !isJson() && !isSilent() && getMode().interactive;
111
+ }
112
+ }
113
+
114
+ export const logger = Logger.getInstance();