membot 0.7.0 → 0.10.0
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/.claude/skills/membot.md +3 -0
- package/.cursor/rules/membot.mdc +3 -0
- package/README.md +7 -0
- package/package.json +1 -1
- package/src/cli.ts +11 -0
- package/src/config/schemas.ts +33 -0
- package/src/constants.ts +23 -0
- package/src/context.ts +24 -0
- package/src/ingest/concurrency.ts +60 -0
- package/src/ingest/describer.ts +49 -3
- package/src/ingest/embed-worker.ts +74 -0
- package/src/ingest/embedder-pool.ts +391 -0
- package/src/ingest/embedder.ts +40 -2
- package/src/ingest/ingest.ts +277 -67
- package/src/operations/add.ts +139 -99
- package/src/operations/index.ts +2 -0
- package/src/operations/refresh.ts +61 -34
- package/src/operations/stats.ts +342 -0
- package/src/operations/write.ts +48 -40
- package/src/output/formatter.ts +21 -0
- package/src/output/logger.ts +36 -0
- package/src/output/progress.ts +408 -46
- package/src/refresh/scheduler.ts +22 -13
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import type { DbConnection, SqlParam } from "../db/connection.ts";
|
|
3
|
+
import { listDueRefreshes } from "../db/files.ts";
|
|
4
|
+
import { colors } from "../output/formatter.ts";
|
|
5
|
+
import { defineOperation } from "./types.ts";
|
|
6
|
+
|
|
7
|
+
export const statsOperation = defineOperation({
|
|
8
|
+
name: "membot_stats",
|
|
9
|
+
cliName: "stats",
|
|
10
|
+
description: `Summarize the local membot index: file/version/chunk/blob counts, total content and on-disk size, refresh health, and breakdowns by source_type, downloader, and mime_type. Optional prefix narrows aggregates to a subtree (same semantics as 'membot tree <prefix>'). Read-only. Use this before membot_prune to gauge how much there is to drop, or as a first call to confirm the index has anything in it.`,
|
|
11
|
+
inputSchema: z.object({
|
|
12
|
+
prefix: z
|
|
13
|
+
.string()
|
|
14
|
+
.optional()
|
|
15
|
+
.describe(
|
|
16
|
+
"Restrict aggregates to logical paths starting with this prefix (e.g. 'docs/api/'). Omit to summarize the whole index.",
|
|
17
|
+
),
|
|
18
|
+
}),
|
|
19
|
+
outputSchema: z.object({
|
|
20
|
+
prefix: z.string().nullable(),
|
|
21
|
+
db_path: z.string(),
|
|
22
|
+
db_size_bytes: z.number(),
|
|
23
|
+
files: z.object({
|
|
24
|
+
current: z.number(),
|
|
25
|
+
tombstoned_paths: z.number(),
|
|
26
|
+
total_versions: z.number(),
|
|
27
|
+
distinct_paths: z.number(),
|
|
28
|
+
by_source_type: z.record(z.string(), z.number()),
|
|
29
|
+
by_downloader: z.record(z.string(), z.number()),
|
|
30
|
+
by_mime_type: z.record(z.string(), z.number()),
|
|
31
|
+
}),
|
|
32
|
+
content: z.object({
|
|
33
|
+
total_bytes: z.number(),
|
|
34
|
+
total_versions_bytes: z.number(),
|
|
35
|
+
}),
|
|
36
|
+
chunks: z.object({
|
|
37
|
+
current: z.number(),
|
|
38
|
+
total: z.number(),
|
|
39
|
+
}),
|
|
40
|
+
blobs: z.object({
|
|
41
|
+
count: z.number(),
|
|
42
|
+
total_bytes: z.number(),
|
|
43
|
+
}),
|
|
44
|
+
refresh: z.object({
|
|
45
|
+
scheduled: z.number(),
|
|
46
|
+
due_now: z.number(),
|
|
47
|
+
last_status: z.record(z.string(), z.number()),
|
|
48
|
+
}),
|
|
49
|
+
}),
|
|
50
|
+
cli: { positional: ["prefix"] },
|
|
51
|
+
console_formatter: (result) => {
|
|
52
|
+
const lines: string[] = [];
|
|
53
|
+
const heading = (s: string) => colors.bold(s);
|
|
54
|
+
// Always leave at least 2 spaces between key and value, even when the
|
|
55
|
+
// key is wider than the target column (long mime types, long keys).
|
|
56
|
+
const kv = (k: string, v: string, indent = 0) => {
|
|
57
|
+
const target = Math.max(22 - indent, k.length + 2);
|
|
58
|
+
return `${" ".repeat(indent)}${colors.dim(k.padEnd(target))}${v}`;
|
|
59
|
+
};
|
|
60
|
+
const orNone = (record: Record<string, number>): string[] => {
|
|
61
|
+
const keys = Object.keys(record);
|
|
62
|
+
if (keys.length === 0) return [` ${colors.dim("(none)")}`];
|
|
63
|
+
return keys.map((k) => kv(k, String(record[k]), 4));
|
|
64
|
+
};
|
|
65
|
+
const header = result.prefix
|
|
66
|
+
? `${heading("membot index summary")} ${colors.dim(`[prefix=${result.prefix}]`)}`
|
|
67
|
+
: heading("membot index summary");
|
|
68
|
+
lines.push(header);
|
|
69
|
+
lines.push(kv("db_path", result.db_path));
|
|
70
|
+
lines.push(kv("db_size_bytes", formatBytes(result.db_size_bytes)));
|
|
71
|
+
|
|
72
|
+
lines.push("");
|
|
73
|
+
lines.push(heading("files"));
|
|
74
|
+
lines.push(kv("current", String(result.files.current), 2));
|
|
75
|
+
lines.push(kv("tombstoned_paths", String(result.files.tombstoned_paths), 2));
|
|
76
|
+
lines.push(kv("total_versions", String(result.files.total_versions), 2));
|
|
77
|
+
lines.push(kv("distinct_paths", String(result.files.distinct_paths), 2));
|
|
78
|
+
lines.push(kv("by_source_type", "", 2));
|
|
79
|
+
lines.push(...orNone(result.files.by_source_type));
|
|
80
|
+
lines.push(kv("by_downloader", "", 2));
|
|
81
|
+
lines.push(...orNone(result.files.by_downloader));
|
|
82
|
+
lines.push(kv("by_mime_type", "", 2));
|
|
83
|
+
lines.push(...orNone(result.files.by_mime_type));
|
|
84
|
+
|
|
85
|
+
lines.push("");
|
|
86
|
+
lines.push(heading("content"));
|
|
87
|
+
lines.push(kv("total_bytes", formatBytes(result.content.total_bytes), 2));
|
|
88
|
+
lines.push(kv("total_versions_bytes", formatBytes(result.content.total_versions_bytes), 2));
|
|
89
|
+
|
|
90
|
+
lines.push("");
|
|
91
|
+
lines.push(heading("chunks"));
|
|
92
|
+
lines.push(kv("current", String(result.chunks.current), 2));
|
|
93
|
+
lines.push(kv("total", String(result.chunks.total), 2));
|
|
94
|
+
|
|
95
|
+
lines.push("");
|
|
96
|
+
lines.push(heading("blobs"));
|
|
97
|
+
lines.push(kv("count", String(result.blobs.count), 2));
|
|
98
|
+
lines.push(kv("total_bytes", formatBytes(result.blobs.total_bytes), 2));
|
|
99
|
+
|
|
100
|
+
lines.push("");
|
|
101
|
+
lines.push(heading("refresh"));
|
|
102
|
+
lines.push(kv("scheduled", String(result.refresh.scheduled), 2));
|
|
103
|
+
lines.push(kv("due_now", String(result.refresh.due_now), 2));
|
|
104
|
+
lines.push(kv("last_status", "", 2));
|
|
105
|
+
lines.push(...orNone(result.refresh.last_status));
|
|
106
|
+
|
|
107
|
+
return lines.join("\n");
|
|
108
|
+
},
|
|
109
|
+
handler: async (input, ctx) => {
|
|
110
|
+
const prefix = input.prefix ?? null;
|
|
111
|
+
const dbSize = await dbFileSize(ctx.db.path);
|
|
112
|
+
|
|
113
|
+
const files = await collectFileStats(ctx.db, prefix);
|
|
114
|
+
const content = await collectContentStats(ctx.db, prefix);
|
|
115
|
+
const chunks = await collectChunkStats(ctx.db, prefix);
|
|
116
|
+
const blobs = await collectBlobStats(ctx.db, prefix);
|
|
117
|
+
const refresh = await collectRefreshStats(ctx.db, prefix);
|
|
118
|
+
|
|
119
|
+
return {
|
|
120
|
+
prefix,
|
|
121
|
+
db_path: ctx.db.path,
|
|
122
|
+
db_size_bytes: dbSize,
|
|
123
|
+
files,
|
|
124
|
+
content,
|
|
125
|
+
chunks,
|
|
126
|
+
blobs,
|
|
127
|
+
refresh,
|
|
128
|
+
};
|
|
129
|
+
},
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
/** Stat the DuckDB file. Returns 0 if the file isn't on disk yet (in-memory or freshly opened). */
|
|
133
|
+
async function dbFileSize(path: string): Promise<number> {
|
|
134
|
+
try {
|
|
135
|
+
const f = Bun.file(path);
|
|
136
|
+
const exists = await f.exists();
|
|
137
|
+
return exists ? f.size : 0;
|
|
138
|
+
} catch {
|
|
139
|
+
return 0;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Build a `logical_path LIKE ?1` clause + params, or empty when prefix is null. */
|
|
144
|
+
function prefixFilter(prefix: string | null): { clause: string; params: SqlParam[] } {
|
|
145
|
+
if (!prefix) return { clause: "", params: [] };
|
|
146
|
+
return { clause: "logical_path LIKE ?1", params: [`${prefix}%`] };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Combine an existing WHERE fragment with an optional prefix filter. */
|
|
150
|
+
function and(base: string, extra: string): string {
|
|
151
|
+
if (!base) return extra;
|
|
152
|
+
if (!extra) return base;
|
|
153
|
+
return `${base} AND ${extra}`;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
interface FileStats {
|
|
157
|
+
current: number;
|
|
158
|
+
tombstoned_paths: number;
|
|
159
|
+
total_versions: number;
|
|
160
|
+
distinct_paths: number;
|
|
161
|
+
by_source_type: Record<string, number>;
|
|
162
|
+
by_downloader: Record<string, number>;
|
|
163
|
+
by_mime_type: Record<string, number>;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
async function collectFileStats(db: DbConnection, prefix: string | null): Promise<FileStats> {
|
|
167
|
+
const pf = prefixFilter(prefix);
|
|
168
|
+
const where = pf.clause ? `WHERE ${pf.clause}` : "";
|
|
169
|
+
|
|
170
|
+
const current = await scalar(db, `SELECT COUNT(*) AS n FROM current_files ${where}`, ...pf.params);
|
|
171
|
+
const totalVersions = await scalar(db, `SELECT COUNT(*) AS n FROM files ${where}`, ...pf.params);
|
|
172
|
+
const distinctPaths = await scalar(db, `SELECT COUNT(DISTINCT logical_path) AS n FROM files ${where}`, ...pf.params);
|
|
173
|
+
// Tombstoned path = a logical_path whose latest (max version_id) row is a tombstone.
|
|
174
|
+
// current_files already excludes those, so we join "latest per path" against files
|
|
175
|
+
// and count rows where tombstone = TRUE.
|
|
176
|
+
const tombstonedPaths = await scalar(
|
|
177
|
+
db,
|
|
178
|
+
`SELECT COUNT(*) AS n
|
|
179
|
+
FROM files f
|
|
180
|
+
JOIN (
|
|
181
|
+
SELECT logical_path, MAX(version_id) AS v FROM files ${where} GROUP BY logical_path
|
|
182
|
+
) m ON f.logical_path = m.logical_path AND f.version_id = m.v
|
|
183
|
+
WHERE f.tombstone = TRUE`,
|
|
184
|
+
...pf.params,
|
|
185
|
+
);
|
|
186
|
+
|
|
187
|
+
const by_source_type = await groupCount(db, "source_type", "current_files", pf);
|
|
188
|
+
const by_downloader = await groupCount(db, "downloader", "current_files", pf, { skipNull: true });
|
|
189
|
+
const by_mime_type = await groupCount(db, "mime_type", "current_files", pf, { topN: 10, skipNull: true });
|
|
190
|
+
|
|
191
|
+
return {
|
|
192
|
+
current,
|
|
193
|
+
tombstoned_paths: tombstonedPaths,
|
|
194
|
+
total_versions: totalVersions,
|
|
195
|
+
distinct_paths: distinctPaths,
|
|
196
|
+
by_source_type,
|
|
197
|
+
by_downloader,
|
|
198
|
+
by_mime_type,
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
async function collectContentStats(
|
|
203
|
+
db: DbConnection,
|
|
204
|
+
prefix: string | null,
|
|
205
|
+
): Promise<{ total_bytes: number; total_versions_bytes: number }> {
|
|
206
|
+
const pf = prefixFilter(prefix);
|
|
207
|
+
const where = pf.clause ? `WHERE ${pf.clause}` : "";
|
|
208
|
+
const total_bytes = await scalar(
|
|
209
|
+
db,
|
|
210
|
+
`SELECT COALESCE(SUM(size_bytes), 0) AS n FROM current_files ${where}`,
|
|
211
|
+
...pf.params,
|
|
212
|
+
);
|
|
213
|
+
const total_versions_bytes = await scalar(
|
|
214
|
+
db,
|
|
215
|
+
`SELECT COALESCE(SUM(size_bytes), 0) AS n FROM files ${where}`,
|
|
216
|
+
...pf.params,
|
|
217
|
+
);
|
|
218
|
+
return { total_bytes, total_versions_bytes };
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
async function collectChunkStats(db: DbConnection, prefix: string | null): Promise<{ current: number; total: number }> {
|
|
222
|
+
if (!prefix) {
|
|
223
|
+
const current = await scalar(db, `SELECT COUNT(*) AS n FROM current_chunks`);
|
|
224
|
+
const total = await scalar(db, `SELECT COUNT(*) AS n FROM chunks`);
|
|
225
|
+
return { current, total };
|
|
226
|
+
}
|
|
227
|
+
const pf = prefixFilter(prefix);
|
|
228
|
+
const current = await scalar(db, `SELECT COUNT(*) AS n FROM current_chunks WHERE ${pf.clause}`, ...pf.params);
|
|
229
|
+
const total = await scalar(db, `SELECT COUNT(*) AS n FROM chunks WHERE ${pf.clause}`, ...pf.params);
|
|
230
|
+
return { current, total };
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
async function collectBlobStats(
|
|
234
|
+
db: DbConnection,
|
|
235
|
+
prefix: string | null,
|
|
236
|
+
): Promise<{ count: number; total_bytes: number }> {
|
|
237
|
+
if (!prefix) {
|
|
238
|
+
const row = await db.queryGet<{ count: number | bigint; total: number | bigint | null }>(
|
|
239
|
+
`SELECT COUNT(*) AS count, COALESCE(SUM(size_bytes), 0) AS total FROM blobs`,
|
|
240
|
+
);
|
|
241
|
+
return { count: Number(row?.count ?? 0), total_bytes: Number(row?.total ?? 0) };
|
|
242
|
+
}
|
|
243
|
+
const pf = prefixFilter(prefix);
|
|
244
|
+
const row = await db.queryGet<{ count: number | bigint; total: number | bigint | null }>(
|
|
245
|
+
`SELECT COUNT(*) AS count, COALESCE(SUM(size_bytes), 0) AS total
|
|
246
|
+
FROM blobs
|
|
247
|
+
WHERE sha256 IN (
|
|
248
|
+
SELECT blob_sha256 FROM current_files
|
|
249
|
+
WHERE ${pf.clause} AND blob_sha256 IS NOT NULL
|
|
250
|
+
)`,
|
|
251
|
+
...pf.params,
|
|
252
|
+
);
|
|
253
|
+
return { count: Number(row?.count ?? 0), total_bytes: Number(row?.total ?? 0) };
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
async function collectRefreshStats(
|
|
257
|
+
db: DbConnection,
|
|
258
|
+
prefix: string | null,
|
|
259
|
+
): Promise<{ scheduled: number; due_now: number; last_status: Record<string, number> }> {
|
|
260
|
+
const pf = prefixFilter(prefix);
|
|
261
|
+
const scheduledWhere = and(pf.clause, "refresh_frequency_sec IS NOT NULL");
|
|
262
|
+
const scheduled = await scalar(db, `SELECT COUNT(*) AS n FROM current_files WHERE ${scheduledWhere}`, ...pf.params);
|
|
263
|
+
|
|
264
|
+
const due = await listDueRefreshes(db);
|
|
265
|
+
const due_now = prefix ? due.filter((r) => r.logical_path.startsWith(prefix)).length : due.length;
|
|
266
|
+
|
|
267
|
+
const statusRows = await db.queryAll<{ k: string | null; n: number | bigint }>(
|
|
268
|
+
`SELECT last_refresh_status AS k, COUNT(*) AS n
|
|
269
|
+
FROM current_files
|
|
270
|
+
WHERE last_refresh_status IS NOT NULL${pf.clause ? ` AND ${pf.clause}` : ""}
|
|
271
|
+
GROUP BY last_refresh_status
|
|
272
|
+
ORDER BY n DESC`,
|
|
273
|
+
...pf.params,
|
|
274
|
+
);
|
|
275
|
+
const last_status: Record<string, number> = {};
|
|
276
|
+
for (const r of statusRows) {
|
|
277
|
+
if (r.k !== null) last_status[r.k] = Number(r.n);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
return { scheduled, due_now, last_status };
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** Run a query whose first row has a single numeric column `n`, returning that number (0 when null). */
|
|
284
|
+
async function scalar(db: DbConnection, sql: string, ...params: SqlParam[]): Promise<number> {
|
|
285
|
+
const row = await db.queryGet<{ n: number | bigint | null }>(sql, ...params);
|
|
286
|
+
return Number(row?.n ?? 0);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
interface GroupOptions {
|
|
290
|
+
skipNull?: boolean;
|
|
291
|
+
topN?: number;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* GROUP BY a column on a current_files-shaped table, optionally dropping NULLs
|
|
296
|
+
* and rolling overflow into an "(other)" bucket when topN is set.
|
|
297
|
+
*/
|
|
298
|
+
async function groupCount(
|
|
299
|
+
db: DbConnection,
|
|
300
|
+
column: string,
|
|
301
|
+
table: string,
|
|
302
|
+
pf: { clause: string; params: SqlParam[] },
|
|
303
|
+
opts: GroupOptions = {},
|
|
304
|
+
): Promise<Record<string, number>> {
|
|
305
|
+
const filters: string[] = [];
|
|
306
|
+
if (pf.clause) filters.push(pf.clause);
|
|
307
|
+
if (opts.skipNull) filters.push(`${column} IS NOT NULL`);
|
|
308
|
+
const where = filters.length ? `WHERE ${filters.join(" AND ")}` : "";
|
|
309
|
+
const rows = await db.queryAll<{ k: string | null; n: number | bigint }>(
|
|
310
|
+
`SELECT ${column} AS k, COUNT(*) AS n FROM ${table} ${where} GROUP BY ${column} ORDER BY n DESC`,
|
|
311
|
+
...pf.params,
|
|
312
|
+
);
|
|
313
|
+
const out: Record<string, number> = {};
|
|
314
|
+
if (opts.topN && rows.length > opts.topN) {
|
|
315
|
+
let other = 0;
|
|
316
|
+
for (let i = 0; i < rows.length; i++) {
|
|
317
|
+
const r = rows[i]!;
|
|
318
|
+
const key = r.k ?? "(null)";
|
|
319
|
+
if (i < opts.topN) out[key] = Number(r.n);
|
|
320
|
+
else other += Number(r.n);
|
|
321
|
+
}
|
|
322
|
+
if (other > 0) out["(other)"] = other;
|
|
323
|
+
return out;
|
|
324
|
+
}
|
|
325
|
+
for (const r of rows) {
|
|
326
|
+
out[r.k ?? "(null)"] = Number(r.n);
|
|
327
|
+
}
|
|
328
|
+
return out;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/** Format a byte count in human units. 1024 boundary, 1-decimal precision past KB. */
|
|
332
|
+
function formatBytes(bytes: number): string {
|
|
333
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
334
|
+
const units = ["KB", "MB", "GB", "TB"];
|
|
335
|
+
let i = -1;
|
|
336
|
+
let n = bytes;
|
|
337
|
+
while (n >= 1024 && i < units.length - 1) {
|
|
338
|
+
n /= 1024;
|
|
339
|
+
i++;
|
|
340
|
+
}
|
|
341
|
+
return `${n.toFixed(n >= 100 ? 0 : 1)} ${units[i]}`;
|
|
342
|
+
}
|
package/src/operations/write.ts
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
+
import { resolveEmbeddingWorkers } from "../context.ts";
|
|
2
3
|
import { insertChunksForVersion, rebuildFts } from "../db/chunks.ts";
|
|
3
4
|
import { insertVersion, millisIso } from "../db/files.ts";
|
|
4
5
|
import { chunkDeterministic } from "../ingest/chunker.ts";
|
|
5
6
|
import { describe } from "../ingest/describer.ts";
|
|
6
7
|
import { embed } from "../ingest/embedder.ts";
|
|
8
|
+
import { withEmbedderPool } from "../ingest/embedder-pool.ts";
|
|
7
9
|
import { parseDuration } from "../ingest/ingest.ts";
|
|
8
10
|
import { sha256Hex } from "../ingest/local-reader.ts";
|
|
9
11
|
import { buildSearchText } from "../ingest/search-text.ts";
|
|
@@ -30,48 +32,54 @@ export const writeOperation = defineOperation({
|
|
|
30
32
|
console_formatter: (result) =>
|
|
31
33
|
`${colors.green("✓")} ${colors.cyan(result.logical_path)} ${colors.dim(`@ ${result.version_id}`)} ${colors.dim(`(${result.size_bytes}B)`)}`,
|
|
32
34
|
handler: async (input, ctx) => {
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
const
|
|
37
|
-
|
|
38
|
-
|
|
35
|
+
// Per-command embedder pool: spawn workers, embed this version's
|
|
36
|
+
// chunks in parallel, kill workers before returning. Short-circuits
|
|
37
|
+
// to single-process when `embedding.workers` is 1.
|
|
38
|
+
const workers = resolveEmbeddingWorkers(ctx.config.embedding.workers);
|
|
39
|
+
return withEmbedderPool(workers, ctx.config.embedding_model, async () => {
|
|
40
|
+
const refreshSec = parseDuration(input.refresh_frequency);
|
|
41
|
+
const bytes = new TextEncoder().encode(input.content);
|
|
42
|
+
const description = await describe(input.logical_path, "text/markdown", input.content, ctx.config.llm);
|
|
43
|
+
const chunks = chunkDeterministic(input.content, ctx.config.chunker);
|
|
44
|
+
const searchTexts = chunks.map((c) => buildSearchText(input.logical_path, description, c.content));
|
|
45
|
+
const embeddings = await embed(searchTexts, ctx.config.embedding_model);
|
|
39
46
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
47
|
+
const versionId = millisIso(Date.now());
|
|
48
|
+
const contentSha = sha256Hex(bytes);
|
|
49
|
+
await insertVersion(ctx.db, {
|
|
50
|
+
logical_path: input.logical_path,
|
|
51
|
+
version_id: versionId,
|
|
52
|
+
source_type: "inline",
|
|
53
|
+
source_path: null,
|
|
54
|
+
source_mtime_ms: null,
|
|
55
|
+
source_sha256: contentSha,
|
|
56
|
+
blob_sha256: null,
|
|
57
|
+
content_sha256: contentSha,
|
|
58
|
+
content: input.content,
|
|
59
|
+
description,
|
|
60
|
+
mime_type: "text/markdown",
|
|
61
|
+
size_bytes: bytes.byteLength,
|
|
62
|
+
fetcher: "inline",
|
|
63
|
+
refresh_frequency_sec: refreshSec,
|
|
64
|
+
refreshed_at: new Date().toISOString(),
|
|
65
|
+
last_refresh_status: "ok",
|
|
66
|
+
change_note: input.change_note ?? null,
|
|
67
|
+
});
|
|
61
68
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
69
|
+
await insertChunksForVersion(
|
|
70
|
+
ctx.db,
|
|
71
|
+
input.logical_path,
|
|
72
|
+
versionId,
|
|
73
|
+
chunks.map((c, i) => ({
|
|
74
|
+
chunk_index: c.index,
|
|
75
|
+
chunk_content: c.content,
|
|
76
|
+
search_text: searchTexts[i] ?? buildSearchText(input.logical_path, description, c.content),
|
|
77
|
+
embedding: embeddings[i] ?? new Array(embeddings[0]?.length ?? 0).fill(0),
|
|
78
|
+
})),
|
|
79
|
+
);
|
|
80
|
+
await rebuildFts(ctx.db);
|
|
74
81
|
|
|
75
|
-
|
|
82
|
+
return { logical_path: input.logical_path, version_id: versionId, size_bytes: bytes.byteLength };
|
|
83
|
+
});
|
|
76
84
|
},
|
|
77
85
|
});
|
package/src/output/formatter.ts
CHANGED
|
@@ -18,6 +18,27 @@ export function renderResult<T>(result: T, opts: { console_formatter?: (result:
|
|
|
18
18
|
return JSON.stringify(result, null, 2);
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
+
/**
|
|
22
|
+
* Format a byte count as a short human-readable string: 5654 → `5.5 KB`,
|
|
23
|
+
* 14_859 → `14.5 KB`, 2_345_678 → `2.2 MB`. Uses 1024-based units (binary
|
|
24
|
+
* prefixes) since file sizes on disk are typically reported that way.
|
|
25
|
+
* Negative or non-finite inputs render as `0 B`.
|
|
26
|
+
*/
|
|
27
|
+
export function formatBytes(n: number): string {
|
|
28
|
+
if (!Number.isFinite(n) || n < 0) return "0 B";
|
|
29
|
+
if (n < 1024) return `${n} B`;
|
|
30
|
+
const units = ["KB", "MB", "GB", "TB"] as const;
|
|
31
|
+
let value = n / 1024;
|
|
32
|
+
let unit: string = units[0];
|
|
33
|
+
for (let i = 1; i < units.length && value >= 1024; i++) {
|
|
34
|
+
value /= 1024;
|
|
35
|
+
unit = units[i] as string;
|
|
36
|
+
}
|
|
37
|
+
// One decimal until 100, then round to integer (so the column stays narrow).
|
|
38
|
+
const formatted = value < 100 ? value.toFixed(1) : `${Math.round(value)}`;
|
|
39
|
+
return `${formatted} ${unit}`;
|
|
40
|
+
}
|
|
41
|
+
|
|
21
42
|
/**
|
|
22
43
|
* Pretty-print a 2D array of cells as an aligned table. Column widths are
|
|
23
44
|
* computed from the visible (escape-stripped) length of each cell so coloured
|
package/src/output/logger.ts
CHANGED
|
@@ -9,6 +9,17 @@ export interface Spinner {
|
|
|
9
9
|
stop(): void;
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
+
/**
|
|
13
|
+
* Anything occupying a fixed area of stderr that needs to be torn down before
|
|
14
|
+
* the logger writes a stray line, then redrawn afterward. nanospinner's
|
|
15
|
+
* single-line spinner and progress.ts's multi-line worker view both implement
|
|
16
|
+
* this so log/info/warn lines don't shred the live display.
|
|
17
|
+
*/
|
|
18
|
+
export interface LiveArea {
|
|
19
|
+
clear(): void;
|
|
20
|
+
render(): void;
|
|
21
|
+
}
|
|
22
|
+
|
|
12
23
|
const NOOP_SPINNER: Spinner = { update() {}, success() {}, error() {}, stop() {} };
|
|
13
24
|
|
|
14
25
|
/**
|
|
@@ -20,6 +31,7 @@ const NOOP_SPINNER: Spinner = { update() {}, success() {}, error() {}, stop() {}
|
|
|
20
31
|
class Logger {
|
|
21
32
|
private static instance: Logger;
|
|
22
33
|
private activeSpinner: ReturnType<typeof createSpinner> | null = null;
|
|
34
|
+
private activeLiveArea: LiveArea | null = null;
|
|
23
35
|
|
|
24
36
|
/** Singleton accessor. Use the exported `logger` const instead in normal code. */
|
|
25
37
|
static getInstance(): Logger {
|
|
@@ -31,7 +43,24 @@ class Logger {
|
|
|
31
43
|
return useColor() ? fn(msg) : msg;
|
|
32
44
|
}
|
|
33
45
|
|
|
46
|
+
/**
|
|
47
|
+
* Register a multi-line live display. Logger will `clear()` it before any
|
|
48
|
+
* stderr write and `render()` it after, so log lines don't punch through
|
|
49
|
+
* the live area. Pass null to deregister. Mutually exclusive with the
|
|
50
|
+
* nanospinner path (only one live thing on stderr at a time).
|
|
51
|
+
*/
|
|
52
|
+
setActiveLiveArea(area: LiveArea | null): void {
|
|
53
|
+
this.activeLiveArea = area;
|
|
54
|
+
}
|
|
55
|
+
|
|
34
56
|
private writeStderr(msg: string): void {
|
|
57
|
+
const area = this.activeLiveArea;
|
|
58
|
+
if (area) {
|
|
59
|
+
area.clear();
|
|
60
|
+
process.stderr.write(`${msg}\n`);
|
|
61
|
+
area.render();
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
35
64
|
if (this.activeSpinner) {
|
|
36
65
|
this.activeSpinner.clear();
|
|
37
66
|
process.stderr.write(`${msg}\n`);
|
|
@@ -66,6 +95,13 @@ class Logger {
|
|
|
66
95
|
|
|
67
96
|
/** Raw stderr write, no formatting added. Spinner-aware. */
|
|
68
97
|
writeRaw(msg: string): void {
|
|
98
|
+
const area = this.activeLiveArea;
|
|
99
|
+
if (area) {
|
|
100
|
+
area.clear();
|
|
101
|
+
process.stderr.write(msg);
|
|
102
|
+
area.render();
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
69
105
|
if (this.activeSpinner) {
|
|
70
106
|
this.activeSpinner.clear();
|
|
71
107
|
process.stderr.write(msg);
|