membot 0.5.1 → 0.6.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 +25 -10
- package/.cursor/rules/membot.mdc +25 -10
- package/README.md +35 -4
- package/package.json +8 -5
- package/scripts/apply-patches.sh +0 -11
- package/src/cli.ts +2 -2
- package/src/commands/login-page.mustache +50 -0
- package/src/commands/login.ts +83 -0
- package/src/config/schemas.ts +17 -5
- package/src/constants.ts +13 -1
- package/src/context.ts +1 -24
- package/src/db/files.ts +21 -25
- package/src/db/migrations/003-downloader-columns.ts +58 -0
- package/src/db/migrations.ts +2 -1
- package/src/ingest/converter/index.ts +9 -0
- package/src/ingest/converter/xlsx.ts +111 -0
- package/src/ingest/downloaders/browser.ts +180 -0
- package/src/ingest/downloaders/generic-web.ts +81 -0
- package/src/ingest/downloaders/github.ts +178 -0
- package/src/ingest/downloaders/google-docs.ts +56 -0
- package/src/ingest/downloaders/google-shared.ts +86 -0
- package/src/ingest/downloaders/google-sheets.ts +58 -0
- package/src/ingest/downloaders/google-slides.ts +53 -0
- package/src/ingest/downloaders/index.ts +182 -0
- package/src/ingest/downloaders/linear.ts +291 -0
- package/src/ingest/fetcher.ts +107 -127
- package/src/ingest/ingest.ts +43 -69
- package/src/mcp/instructions.ts +4 -2
- package/src/operations/add.ts +6 -4
- package/src/operations/info.ts +4 -6
- package/src/operations/move.ts +2 -3
- package/src/operations/refresh.ts +2 -4
- package/src/operations/remove.ts +23 -2
- package/src/operations/tree.ts +1 -1
- package/src/operations/types.ts +1 -1
- package/src/refresh/runner.ts +59 -114
- package/src/types/text-modules.d.ts +5 -0
- package/patches/@evantahler%2Fmcpx@0.21.4.patch +0 -51
- package/src/commands/mcpx.ts +0 -112
- package/src/ingest/agent-fetcher.ts +0 -564
package/src/ingest/ingest.ts
CHANGED
|
@@ -20,7 +20,7 @@ export interface IngestInput {
|
|
|
20
20
|
exclude?: string;
|
|
21
21
|
follow_symlinks?: boolean;
|
|
22
22
|
refresh_frequency?: string;
|
|
23
|
-
|
|
23
|
+
downloader?: string;
|
|
24
24
|
change_note?: string;
|
|
25
25
|
force?: boolean;
|
|
26
26
|
}
|
|
@@ -161,13 +161,12 @@ async function ingestInline(
|
|
|
161
161
|
bytes: null,
|
|
162
162
|
markdown: text,
|
|
163
163
|
fetcher: "inline",
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
fetcherArgs: null,
|
|
164
|
+
downloader: null,
|
|
165
|
+
downloaderArgs: null,
|
|
167
166
|
refreshSec,
|
|
168
167
|
changeNote: input.change_note ?? null,
|
|
169
168
|
},
|
|
170
|
-
(
|
|
169
|
+
(sublabel) => callbacks?.onEntryProgress?.(logicalPath, sublabel),
|
|
171
170
|
);
|
|
172
171
|
result.version_id = versionId;
|
|
173
172
|
} catch (err) {
|
|
@@ -187,38 +186,6 @@ async function ingestUrl(
|
|
|
187
186
|
force: boolean,
|
|
188
187
|
callbacks?: IngestCallbacks,
|
|
189
188
|
): Promise<IngestResult> {
|
|
190
|
-
const mcpxAdapter = ctx.mcpx
|
|
191
|
-
? {
|
|
192
|
-
async search(query: string, options?: { keywordOnly?: boolean; semanticOnly?: boolean }) {
|
|
193
|
-
try {
|
|
194
|
-
const results = await ctx.mcpx!.search(query, options);
|
|
195
|
-
return results.map((r) => ({
|
|
196
|
-
server: r.server,
|
|
197
|
-
tool: r.tool,
|
|
198
|
-
description: r.description ?? undefined,
|
|
199
|
-
score: r.score,
|
|
200
|
-
matchType: r.matchType ?? undefined,
|
|
201
|
-
}));
|
|
202
|
-
} catch (err) {
|
|
203
|
-
logger.debug(`mcpx.search(${query}) failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
204
|
-
return [];
|
|
205
|
-
}
|
|
206
|
-
},
|
|
207
|
-
async listTools(server?: string) {
|
|
208
|
-
const tools = await ctx.mcpx!.listTools(server);
|
|
209
|
-
return tools.map((t) => ({ server: t.server, tool: { name: t.tool.name, description: t.tool.description } }));
|
|
210
|
-
},
|
|
211
|
-
async info(server: string, tool: string) {
|
|
212
|
-
const t = await ctx.mcpx!.info(server, tool);
|
|
213
|
-
if (!t) return undefined;
|
|
214
|
-
return { name: t.name, description: t.description, inputSchema: t.inputSchema };
|
|
215
|
-
},
|
|
216
|
-
async exec(server: string, tool: string, args?: Record<string, unknown>) {
|
|
217
|
-
return ctx.mcpx!.exec(server, tool, args ?? {});
|
|
218
|
-
},
|
|
219
|
-
}
|
|
220
|
-
: null;
|
|
221
|
-
|
|
222
189
|
const logicalPath = input.logical_path ?? defaultLogicalForUrl(url);
|
|
223
190
|
callbacks?.onEntryStart?.(url);
|
|
224
191
|
const result: IngestEntryResult = {
|
|
@@ -228,19 +195,24 @@ async function ingestUrl(
|
|
|
228
195
|
status: "ok",
|
|
229
196
|
mime_type: null,
|
|
230
197
|
size_bytes: 0,
|
|
231
|
-
fetcher: "
|
|
198
|
+
fetcher: "downloader",
|
|
232
199
|
source_sha256: "",
|
|
233
200
|
};
|
|
234
201
|
|
|
235
202
|
try {
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
203
|
+
callbacks?.onEntryProgress?.(url, "fetching");
|
|
204
|
+
const fetched = await fetchRemote(
|
|
205
|
+
url,
|
|
206
|
+
ctx.config,
|
|
207
|
+
{
|
|
208
|
+
downloaderName: input.downloader,
|
|
209
|
+
onProgress: (sublabel) => callbacks?.onEntryProgress?.(url, sublabel),
|
|
210
|
+
},
|
|
211
|
+
ctx.dataDir,
|
|
212
|
+
);
|
|
241
213
|
result.mime_type = fetched.mimeType;
|
|
242
214
|
result.size_bytes = fetched.bytes.byteLength;
|
|
243
|
-
result.fetcher =
|
|
215
|
+
result.fetcher = "downloader";
|
|
244
216
|
result.source_sha256 = fetched.sha256;
|
|
245
217
|
|
|
246
218
|
if (!force) {
|
|
@@ -264,14 +236,13 @@ async function ingestUrl(
|
|
|
264
236
|
sourcePath: url,
|
|
265
237
|
sourceMtimeMs: null,
|
|
266
238
|
sourceSha: fetched.sha256,
|
|
267
|
-
fetcher:
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
fetcherArgs: fetched.fetcherArgs,
|
|
239
|
+
fetcher: "downloader",
|
|
240
|
+
downloader: fetched.downloader,
|
|
241
|
+
downloaderArgs: fetched.downloaderArgs,
|
|
271
242
|
refreshSec,
|
|
272
243
|
changeNote: input.change_note ?? null,
|
|
273
244
|
},
|
|
274
|
-
(
|
|
245
|
+
(sublabel) => callbacks?.onEntryProgress?.(url, sublabel),
|
|
275
246
|
);
|
|
276
247
|
result.version_id = versionId;
|
|
277
248
|
} catch (err) {
|
|
@@ -351,13 +322,12 @@ async function ingestLocalFiles(
|
|
|
351
322
|
sourceMtimeMs: local.mtimeMs,
|
|
352
323
|
sourceSha: local.sha256,
|
|
353
324
|
fetcher: "local",
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
fetcherArgs: null,
|
|
325
|
+
downloader: null,
|
|
326
|
+
downloaderArgs: null,
|
|
357
327
|
refreshSec,
|
|
358
328
|
changeNote: input.change_note ?? null,
|
|
359
329
|
},
|
|
360
|
-
(
|
|
330
|
+
(sublabel) => callbacks?.onEntryProgress?.(entry.relPathFromBase, sublabel),
|
|
361
331
|
);
|
|
362
332
|
result.version_id = versionId;
|
|
363
333
|
} catch (err) {
|
|
@@ -386,9 +356,8 @@ interface PipelineParams {
|
|
|
386
356
|
sourceMtimeMs: number | null;
|
|
387
357
|
sourceSha: string;
|
|
388
358
|
fetcher: FetcherKind;
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
fetcherArgs: Record<string, unknown> | null;
|
|
359
|
+
downloader: string | null;
|
|
360
|
+
downloaderArgs: Record<string, unknown> | null;
|
|
392
361
|
refreshSec: number | null;
|
|
393
362
|
changeNote: string | null;
|
|
394
363
|
}
|
|
@@ -403,8 +372,9 @@ interface PipelineParams {
|
|
|
403
372
|
async function pipelineForBytes(
|
|
404
373
|
ctx: AppContext,
|
|
405
374
|
p: PipelineParams,
|
|
406
|
-
|
|
375
|
+
onPhase?: (sublabel: string) => void,
|
|
407
376
|
): Promise<string> {
|
|
377
|
+
onPhase?.("storing blob");
|
|
408
378
|
await upsertBlob(ctx.db, {
|
|
409
379
|
sha256: p.sourceSha,
|
|
410
380
|
mime_type: p.mime,
|
|
@@ -412,6 +382,7 @@ async function pipelineForBytes(
|
|
|
412
382
|
bytes: p.bytes,
|
|
413
383
|
});
|
|
414
384
|
|
|
385
|
+
onPhase?.("converting");
|
|
415
386
|
const conversion = await convert(p.bytes, p.mime, p.source, ctx.config.llm);
|
|
416
387
|
const markdown = conversion.markdown;
|
|
417
388
|
const contentSha = sha256Hex(new TextEncoder().encode(markdown));
|
|
@@ -430,13 +401,12 @@ async function pipelineForBytes(
|
|
|
430
401
|
markdown,
|
|
431
402
|
contentSha,
|
|
432
403
|
fetcher: p.fetcher,
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
fetcherArgs: p.fetcherArgs,
|
|
404
|
+
downloader: p.downloader,
|
|
405
|
+
downloaderArgs: p.downloaderArgs,
|
|
436
406
|
refreshSec: p.refreshSec,
|
|
437
407
|
changeNote: p.changeNote,
|
|
438
408
|
},
|
|
439
|
-
|
|
409
|
+
onPhase,
|
|
440
410
|
);
|
|
441
411
|
}
|
|
442
412
|
|
|
@@ -452,9 +422,8 @@ interface PersistParams {
|
|
|
452
422
|
markdown: string;
|
|
453
423
|
contentSha?: string;
|
|
454
424
|
fetcher: FetcherKind;
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
fetcherArgs: Record<string, unknown> | null;
|
|
425
|
+
downloader: string | null;
|
|
426
|
+
downloaderArgs: Record<string, unknown> | null;
|
|
458
427
|
refreshSec: number | null;
|
|
459
428
|
changeNote: string | null;
|
|
460
429
|
}
|
|
@@ -468,14 +437,18 @@ interface PersistParams {
|
|
|
468
437
|
async function persistVersion(
|
|
469
438
|
ctx: AppContext,
|
|
470
439
|
p: PersistParams,
|
|
471
|
-
|
|
440
|
+
onPhase?: (sublabel: string) => void,
|
|
472
441
|
): Promise<string> {
|
|
442
|
+
onPhase?.("describing");
|
|
473
443
|
const description = await describe(p.logicalPath, p.mime, p.markdown, ctx.config.llm);
|
|
444
|
+
onPhase?.("chunking");
|
|
474
445
|
const chunks = chunkDeterministic(p.markdown, ctx.config.chunker);
|
|
475
446
|
const searchTexts = chunks.map((c) => buildSearchText(p.logicalPath, description, c.content));
|
|
476
447
|
let embeddings: number[][];
|
|
477
448
|
try {
|
|
478
|
-
embeddings = await embed(searchTexts, ctx.config.embedding_model, {
|
|
449
|
+
embeddings = await embed(searchTexts, ctx.config.embedding_model, {
|
|
450
|
+
onProgress: (done, total) => onPhase?.(`embedding ${done}/${total}`),
|
|
451
|
+
});
|
|
479
452
|
} catch (err) {
|
|
480
453
|
throw asHelpful(
|
|
481
454
|
err,
|
|
@@ -484,6 +457,7 @@ async function persistVersion(
|
|
|
484
457
|
);
|
|
485
458
|
}
|
|
486
459
|
|
|
460
|
+
onPhase?.("persisting");
|
|
487
461
|
const versionId = millisIso(Date.now());
|
|
488
462
|
const contentSha = p.contentSha ?? sha256Hex(new TextEncoder().encode(p.markdown));
|
|
489
463
|
await insertVersion(ctx.db, {
|
|
@@ -500,9 +474,8 @@ async function persistVersion(
|
|
|
500
474
|
mime_type: p.mime,
|
|
501
475
|
size_bytes: p.bytes?.byteLength ?? new TextEncoder().encode(p.markdown).byteLength,
|
|
502
476
|
fetcher: p.fetcher,
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
fetcher_args: p.fetcherArgs,
|
|
477
|
+
downloader: p.downloader,
|
|
478
|
+
downloader_args: p.downloaderArgs,
|
|
506
479
|
refresh_frequency_sec: p.refreshSec,
|
|
507
480
|
refreshed_at: new Date().toISOString(),
|
|
508
481
|
last_refresh_status: "ok",
|
|
@@ -520,6 +493,7 @@ async function persistVersion(
|
|
|
520
493
|
embedding: embeddings[i] ?? new Array(embeddings[0]?.length ?? 0).fill(0),
|
|
521
494
|
})),
|
|
522
495
|
);
|
|
496
|
+
onPhase?.("indexing");
|
|
523
497
|
await rebuildFts(ctx.db);
|
|
524
498
|
return versionId;
|
|
525
499
|
}
|
package/src/mcp/instructions.ts
CHANGED
|
@@ -11,8 +11,10 @@ indexed with BM25 — so prefer membot_search to membot_read+grep for discovery.
|
|
|
11
11
|
Workflow:
|
|
12
12
|
1. membot_tree or membot_search to find what already exists before adding new content.
|
|
13
13
|
2. membot_add to ingest a local file, a URL, or a remote document. URLs are
|
|
14
|
-
fetched via
|
|
15
|
-
|
|
14
|
+
fetched via per-service downloaders (Google Docs, Sheets, Slides, GitHub,
|
|
15
|
+
Linear, with a generic browser print-to-PDF fallback). Authentication
|
|
16
|
+
comes from the user's logged-in browser cookies (saved via \`membot login\`).
|
|
17
|
+
Each row stores which downloader was used so refresh is deterministic.
|
|
16
18
|
3. membot_read or membot_search hits to consume content.
|
|
17
19
|
4. membot_write to record agent-authored notes (source_type='inline').
|
|
18
20
|
|
package/src/operations/add.ts
CHANGED
|
@@ -10,7 +10,7 @@ import { type ResolvedSource, resolveSource } from "../ingest/source-resolver.ts
|
|
|
10
10
|
import { colors } from "../output/formatter.ts";
|
|
11
11
|
import { defineOperation } from "./types.ts";
|
|
12
12
|
|
|
13
|
-
const FetcherKindEnum = z.enum(["
|
|
13
|
+
const FetcherKindEnum = z.enum(["downloader", "local", "inline"]);
|
|
14
14
|
|
|
15
15
|
export const addOperation = defineOperation({
|
|
16
16
|
name: "membot_add",
|
|
@@ -19,7 +19,7 @@ export const addOperation = defineOperation({
|
|
|
19
19
|
- a local file path
|
|
20
20
|
- a local directory (recursive walk, symlinks followed)
|
|
21
21
|
- a glob pattern (e.g. "docs/**/*.md")
|
|
22
|
-
- a URL (fetched via
|
|
22
|
+
- a URL (fetched via the per-service downloader registry — Google Docs/Sheets/Slides via export endpoints, GitHub + Linear as rendered HTML, anything else through a generic browser print-to-PDF fallback. All fetches authenticate via the user's logged-in browser session — run \`membot login\` once to sign in.)
|
|
23
23
|
- "inline:<text>" literal
|
|
24
24
|
Pass any number of args; each is resolved independently and the matched entries are concatenated into one response. PDF, DOCX, HTML, images, and other binaries are converted to markdown — native libraries first, vision/OCR for images, LLM fallback for messy or scanned input. Original bytes are kept in the blobs table; \`membot_read bytes=true\` returns them. Setting \`refresh_frequency\` enables automatic refresh from the daemon. By default, re-ingesting an unchanged source (same source_sha256 as the current version) is a no-op and reports \`status: "unchanged"\`; pass \`force=true\` to always create a new version. Each newly-ingested file becomes a new version under its own logical_path; existing versions stay queryable via membot_versions. Directory/glob ingests stream one file at a time — partial failures do not abort the rest; the response lists per-entry status.
|
|
25
25
|
|
|
@@ -54,10 +54,12 @@ Pass \`logical_path\` to override. For a multi-source / directory / glob walk it
|
|
|
54
54
|
.default(true)
|
|
55
55
|
.describe("Follow symlinks during directory walks (cycles broken via realpath)"),
|
|
56
56
|
refresh_frequency: z.string().optional().describe("Auto-refresh cadence: 5m | 1h | 24h | 7d. Omit to disable."),
|
|
57
|
-
|
|
57
|
+
downloader: z
|
|
58
58
|
.string()
|
|
59
59
|
.optional()
|
|
60
|
-
.describe(
|
|
60
|
+
.describe(
|
|
61
|
+
"Force a specific downloader by name (e.g. 'google-docs', 'github', 'generic-web'). Skips URL-based matching.",
|
|
62
|
+
),
|
|
61
63
|
change_note: z.string().optional().describe("Free-text note attached to the new version"),
|
|
62
64
|
force: z
|
|
63
65
|
.boolean()
|
package/src/operations/info.ts
CHANGED
|
@@ -25,9 +25,8 @@ export const infoOperation = defineOperation({
|
|
|
25
25
|
size_bytes: z.number().nullable(),
|
|
26
26
|
description: z.string().nullable(),
|
|
27
27
|
fetcher: z.string().nullable(),
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
fetcher_args: z.record(z.string(), z.unknown()).nullable(),
|
|
28
|
+
downloader: z.string().nullable(),
|
|
29
|
+
downloader_args: z.record(z.string(), z.unknown()).nullable(),
|
|
31
30
|
refresh_frequency_sec: z.number().nullable(),
|
|
32
31
|
refreshed_at: z.string().nullable(),
|
|
33
32
|
last_refresh_status: z.string().nullable(),
|
|
@@ -53,9 +52,8 @@ export const infoOperation = defineOperation({
|
|
|
53
52
|
lines.push(fmt("blob_sha256", orDash(result.blob_sha256)));
|
|
54
53
|
lines.push(fmt("source_sha256", orDash(result.source_sha256)));
|
|
55
54
|
if (result.fetcher) lines.push(fmt("fetcher", result.fetcher));
|
|
56
|
-
if (result.
|
|
57
|
-
if (result.
|
|
58
|
-
if (result.fetcher_args) lines.push(fmt("fetcher_args", JSON.stringify(result.fetcher_args)));
|
|
55
|
+
if (result.downloader) lines.push(fmt("downloader", result.downloader));
|
|
56
|
+
if (result.downloader_args) lines.push(fmt("downloader_args", JSON.stringify(result.downloader_args)));
|
|
59
57
|
lines.push(
|
|
60
58
|
fmt(
|
|
61
59
|
"refresh_frequency",
|
package/src/operations/move.ts
CHANGED
|
@@ -54,9 +54,8 @@ export const moveOperation = defineOperation({
|
|
|
54
54
|
mime_type: cur.mime_type,
|
|
55
55
|
size_bytes: cur.size_bytes,
|
|
56
56
|
fetcher: cur.fetcher,
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
fetcher_args: cur.fetcher_args,
|
|
57
|
+
downloader: cur.downloader,
|
|
58
|
+
downloader_args: cur.downloader_args,
|
|
60
59
|
refresh_frequency_sec: cur.refresh_frequency_sec,
|
|
61
60
|
refreshed_at: cur.refreshed_at,
|
|
62
61
|
last_refresh_status: cur.last_refresh_status,
|
|
@@ -7,7 +7,7 @@ import { defineOperation } from "./types.ts";
|
|
|
7
7
|
export const refreshOperation = defineOperation({
|
|
8
8
|
name: "membot_refresh",
|
|
9
9
|
cliName: "refresh",
|
|
10
|
-
description: `Re-read a file's source and create a new version only if the source bytes changed. Pass \`logical_path\` to refresh one file, or omit it to refresh every file whose refresh_frequency_sec has elapsed. Local files are detected via mtime+sha; remote files are re-fetched via the same
|
|
10
|
+
description: `Re-read a file's source and create a new version only if the source bytes changed. Pass \`logical_path\` to refresh one file, or omit it to refresh every file whose refresh_frequency_sec has elapsed. Local files are detected via mtime+sha; remote files are re-fetched via the same downloader (Google Docs, GitHub, etc.) that was originally chosen. On auth or network failure the prior version stays current — check \`last_refresh_status\`. If the failure mentions a login redirect, re-run \`membot login\` and try again.`,
|
|
11
11
|
inputSchema: z.object({
|
|
12
12
|
logical_path: z.string().optional().describe("Single path to refresh; omit for all-due"),
|
|
13
13
|
force: z.boolean().default(false).describe("Re-embed even if source sha is unchanged"),
|
|
@@ -60,9 +60,7 @@ export const refreshOperation = defineOperation({
|
|
|
60
60
|
for (const path of targets) {
|
|
61
61
|
ctx.progress.tick(path);
|
|
62
62
|
try {
|
|
63
|
-
const r = await refreshOne(ctx, path, input.force, (
|
|
64
|
-
ctx.progress.update(`embedding ${done}/${total}`),
|
|
65
|
-
);
|
|
63
|
+
const r = await refreshOne(ctx, path, input.force, (sublabel) => ctx.progress.update(sublabel));
|
|
66
64
|
out.push(r);
|
|
67
65
|
} catch (err) {
|
|
68
66
|
out.push({ logical_path: path, status: "failed", error: err instanceof Error ? err.message : String(err) });
|
package/src/operations/remove.ts
CHANGED
|
@@ -10,7 +10,7 @@ export const removeOperation = defineOperation({
|
|
|
10
10
|
name: "membot_delete",
|
|
11
11
|
cliName: "rm",
|
|
12
12
|
bashEquivalent: "rm",
|
|
13
|
-
description: `Tombstone one or more logical_paths so they no longer appear in membot_list / membot_tree / membot_search. Each \`paths\` arg is independently treated as either a literal logical_path or a glob pattern (e.g. "docs/**/*.md"); globs are matched against current logical_paths in the DB, not the filesystem. The union of matches is deduplicated, then tombstoned one at a time — partial failures are reported per-entry without aborting the rest. An input arg that matches zero current files is an error (the response includes which arg). Old versions remain queryable via membot_versions and membot_read with an explicit version. Use membot_prune to permanently drop history.`,
|
|
13
|
+
description: `Tombstone one or more logical_paths so they no longer appear in membot_list / membot_tree / membot_search. Each \`paths\` arg is independently treated as either a literal logical_path or a glob pattern (e.g. "docs/**/*.md"); globs are matched against current logical_paths in the DB, not the filesystem. A literal arg that matches no exact file but is a prefix of existing paths (a "directory") is rejected unless \`recursive\` is true, in which case every path beneath it is tombstoned. The union of matches is deduplicated, then tombstoned one at a time — partial failures are reported per-entry without aborting the rest. An input arg that matches zero current files is an error (the response includes which arg). Old versions remain queryable via membot_versions and membot_read with an explicit version. Use membot_prune to permanently drop history.`,
|
|
14
14
|
inputSchema: z.object({
|
|
15
15
|
paths: z
|
|
16
16
|
.array(z.string())
|
|
@@ -18,6 +18,12 @@ export const removeOperation = defineOperation({
|
|
|
18
18
|
.describe(
|
|
19
19
|
'One or more logical_paths or glob patterns (e.g. "docs/**/*.md"). Each arg is matched independently against current logical_paths in the DB.',
|
|
20
20
|
),
|
|
21
|
+
recursive: z
|
|
22
|
+
.boolean()
|
|
23
|
+
.default(false)
|
|
24
|
+
.describe(
|
|
25
|
+
"If a literal path arg matches no file but is a prefix of existing paths, treat it as a directory and remove everything beneath it. Mirrors `rm -r`. Ignored for glob args.",
|
|
26
|
+
),
|
|
21
27
|
change_note: z.string().optional().describe("Why this is being deleted"),
|
|
22
28
|
}),
|
|
23
29
|
outputSchema: z.object({
|
|
@@ -33,7 +39,7 @@ export const removeOperation = defineOperation({
|
|
|
33
39
|
ok: z.number(),
|
|
34
40
|
failed: z.number(),
|
|
35
41
|
}),
|
|
36
|
-
cli: { positional: ["paths"], aliases: { change_note: "-m" } },
|
|
42
|
+
cli: { positional: ["paths"], aliases: { change_note: "-m", recursive: "-r" } },
|
|
37
43
|
console_formatter: (result) => {
|
|
38
44
|
const lines = result.removed.map((e) =>
|
|
39
45
|
e.status === "ok"
|
|
@@ -59,6 +65,21 @@ export const removeOperation = defineOperation({
|
|
|
59
65
|
}
|
|
60
66
|
} else if (currentSet.has(arg)) {
|
|
61
67
|
matches.push(arg);
|
|
68
|
+
} else {
|
|
69
|
+
const normalized = arg.endsWith("/") ? arg.slice(0, -1) : arg;
|
|
70
|
+
const dirPrefix = `${normalized}/`;
|
|
71
|
+
const dirMatches = currentPaths.filter((p) => p.startsWith(dirPrefix));
|
|
72
|
+
if (dirMatches.length > 0) {
|
|
73
|
+
if (input.recursive) {
|
|
74
|
+
matches.push(...dirMatches);
|
|
75
|
+
} else {
|
|
76
|
+
throw new HelpfulError({
|
|
77
|
+
kind: "not_found",
|
|
78
|
+
message: `\`${arg}\` is a directory (${dirMatches.length} files); pass --recursive to remove its contents`,
|
|
79
|
+
hint: `Re-run with \`-r\` / \`--recursive\` to tombstone every path under \`${normalized}/\`.`,
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
}
|
|
62
83
|
}
|
|
63
84
|
if (matches.length === 0) {
|
|
64
85
|
throw new HelpfulError({
|
package/src/operations/tree.ts
CHANGED
|
@@ -18,7 +18,7 @@ export const treeOperation = defineOperation({
|
|
|
18
18
|
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.`,
|
|
19
19
|
inputSchema: z.object({
|
|
20
20
|
prefix: z.string().optional().describe("Only show paths starting with this prefix"),
|
|
21
|
-
max_depth: z.number().default(
|
|
21
|
+
max_depth: z.number().default(6).describe("How many path segments deep to render"),
|
|
22
22
|
max_items: z
|
|
23
23
|
.number()
|
|
24
24
|
.default(20)
|
package/src/operations/types.ts
CHANGED
|
@@ -39,7 +39,7 @@ export interface Operation<I extends z.ZodObject = z.ZodObject, O extends z.ZodT
|
|
|
39
39
|
* falls back to pretty-printed JSON.
|
|
40
40
|
*/
|
|
41
41
|
console_formatter?: (result: z.infer<O>) => string;
|
|
42
|
-
/** The work itself. AppContext gives access to db, embedder,
|
|
42
|
+
/** The work itself. AppContext gives access to db, embedder, logger, config. */
|
|
43
43
|
handler: (input: z.infer<I>, ctx: AppContext) => Promise<z.infer<O>>;
|
|
44
44
|
}
|
|
45
45
|
|