@mindstudio-ai/remy 0.1.320 → 0.1.321

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/dist/headless.js CHANGED
@@ -1603,7 +1603,7 @@ var promptUserTool = {
1603
1603
  type: {
1604
1604
  type: "string",
1605
1605
  enum: ["select", "checklist", "text", "file"],
1606
- description: 'select: pick one from a list. checklist: pick one or more from a list. The user can always provide a custom "Other" answer for select and checklist questions, so there is no need to include an "Other" option. text: free-form input. file: file/image upload. The answer is the local path (string) under src/.user-uploads/ \u2014 the file is already downloaded to disk. Documents may have an extracted-text sidecar at <path>.txt. Reference the path directly; pass image paths straight to analyzeImage / screenshot tools.'
1606
+ description: 'select: pick one from a list. checklist: pick one or more from a list. The user can always provide a custom "Other" answer for select and checklist questions, so there is no need to include an "Other" option. text: free-form input. file: the user hands over files, a folder, or a paste (set `multiple: true` to take several). Each answer arrives in one of two shapes. A local path (string) under src/.user-uploads/: the file is already on disk, documents may have an extracted-text sidecar at <path>.txt, and a folder arrives as src/.user-uploads/<folder>.zip for you to unpack with bash. Or, when the user brought a dataset (a folder over about 100 MB or 500 files, or any single file over 100 MB), a store landing object { kind: "store", store, prefix, files, bytes, failed } \u2014 the files were streamed into the app\'s private file store `uploads` under that prefix and are NOT on disk; read them in place with `remy-admin datasources inspect --store <store> --prefix <prefix>` and `remy-admin files get`, never download the whole set. Pass image paths straight to analyzeImage / screenshot tools.'
1607
1607
  },
1608
1608
  helpText: {
1609
1609
  type: "string",
@@ -1638,7 +1638,7 @@ var promptUserTool = {
1638
1638
  },
1639
1639
  multiple: {
1640
1640
  type: "boolean",
1641
- description: "For file type: allow multiple uploads (the answer is an array of local paths). Defaults to false."
1641
+ description: 'For file type: allow several files or folders (the answer is an array of local paths and/or store landings). Defaults to false. Set it whenever you ask for "a few examples" or "a sample of your data".'
1642
1642
  },
1643
1643
  format: {
1644
1644
  type: "string",
@@ -2629,7 +2629,7 @@ async function readAndSort(dirPath) {
2629
2629
  async function collapsePath(basePath, name) {
2630
2630
  let display = name;
2631
2631
  let current = path8.join(basePath, name);
2632
- for (; ; ) {
2632
+ while (true) {
2633
2633
  let children;
2634
2634
  try {
2635
2635
  children = await readAndSort(current);
@@ -10852,12 +10852,15 @@ var HeadlessSession = class {
10852
10852
  const questions = Array.isArray(input?.questions) ? input.questions : [];
10853
10853
  const batch = [];
10854
10854
  const slots = [];
10855
+ let landings = 0;
10855
10856
  for (const q of questions) {
10856
10857
  if (q?.type !== "file" || typeof q.id !== "string") {
10857
10858
  continue;
10858
10859
  }
10859
10860
  const val = answers[q.id];
10860
- const items = (Array.isArray(val) ? val : [val]).filter(isDescriptor);
10861
+ const all = Array.isArray(val) ? val : [val];
10862
+ landings += all.filter((v) => v && v.kind === "store").length;
10863
+ const items = all.filter(isDescriptor);
10861
10864
  if (items.length === 0) {
10862
10865
  continue;
10863
10866
  }
@@ -10876,6 +10879,9 @@ var HeadlessSession = class {
10876
10879
  }
10877
10880
  }
10878
10881
  if (batch.length === 0) {
10882
+ if (landings > 0) {
10883
+ log17.info("promptUser store landings passed through", { landings });
10884
+ }
10879
10885
  return raw;
10880
10886
  }
10881
10887
  let results;
@@ -11219,7 +11225,7 @@ var HeadlessSession = class {
11219
11225
  * there would strand the chain steps and background results behind it.
11220
11226
  */
11221
11227
  async drainQueueLoop() {
11222
- for (; ; ) {
11228
+ while (true) {
11223
11229
  const at = this.queue.firstDeliverableIndex();
11224
11230
  if (at === -1) {
11225
11231
  return;
package/dist/index.js CHANGED
@@ -1067,7 +1067,7 @@ var init_promptUser = __esm({
1067
1067
  type: {
1068
1068
  type: "string",
1069
1069
  enum: ["select", "checklist", "text", "file"],
1070
- description: 'select: pick one from a list. checklist: pick one or more from a list. The user can always provide a custom "Other" answer for select and checklist questions, so there is no need to include an "Other" option. text: free-form input. file: file/image upload. The answer is the local path (string) under src/.user-uploads/ \u2014 the file is already downloaded to disk. Documents may have an extracted-text sidecar at <path>.txt. Reference the path directly; pass image paths straight to analyzeImage / screenshot tools.'
1070
+ description: 'select: pick one from a list. checklist: pick one or more from a list. The user can always provide a custom "Other" answer for select and checklist questions, so there is no need to include an "Other" option. text: free-form input. file: the user hands over files, a folder, or a paste (set `multiple: true` to take several). Each answer arrives in one of two shapes. A local path (string) under src/.user-uploads/: the file is already on disk, documents may have an extracted-text sidecar at <path>.txt, and a folder arrives as src/.user-uploads/<folder>.zip for you to unpack with bash. Or, when the user brought a dataset (a folder over about 100 MB or 500 files, or any single file over 100 MB), a store landing object { kind: "store", store, prefix, files, bytes, failed } \u2014 the files were streamed into the app\'s private file store `uploads` under that prefix and are NOT on disk; read them in place with `remy-admin datasources inspect --store <store> --prefix <prefix>` and `remy-admin files get`, never download the whole set. Pass image paths straight to analyzeImage / screenshot tools.'
1071
1071
  },
1072
1072
  helpText: {
1073
1073
  type: "string",
@@ -1102,7 +1102,7 @@ var init_promptUser = __esm({
1102
1102
  },
1103
1103
  multiple: {
1104
1104
  type: "boolean",
1105
- description: "For file type: allow multiple uploads (the answer is an array of local paths). Defaults to false."
1105
+ description: 'For file type: allow several files or folders (the answer is an array of local paths and/or store landings). Defaults to false. Set it whenever you ask for "a few examples" or "a sample of your data".'
1106
1106
  },
1107
1107
  format: {
1108
1108
  type: "string",
@@ -3826,7 +3826,7 @@ async function readAndSort(dirPath) {
3826
3826
  async function collapsePath(basePath, name) {
3827
3827
  let display = name;
3828
3828
  let current = path8.join(basePath, name);
3829
- for (; ; ) {
3829
+ while (true) {
3830
3830
  let children;
3831
3831
  try {
3832
3832
  children = await readAndSort(current);
@@ -11896,12 +11896,15 @@ var init_headless = __esm({
11896
11896
  const questions = Array.isArray(input?.questions) ? input.questions : [];
11897
11897
  const batch = [];
11898
11898
  const slots = [];
11899
+ let landings = 0;
11899
11900
  for (const q of questions) {
11900
11901
  if (q?.type !== "file" || typeof q.id !== "string") {
11901
11902
  continue;
11902
11903
  }
11903
11904
  const val = answers[q.id];
11904
- const items = (Array.isArray(val) ? val : [val]).filter(isDescriptor);
11905
+ const all = Array.isArray(val) ? val : [val];
11906
+ landings += all.filter((v) => v && v.kind === "store").length;
11907
+ const items = all.filter(isDescriptor);
11905
11908
  if (items.length === 0) {
11906
11909
  continue;
11907
11910
  }
@@ -11920,6 +11923,9 @@ var init_headless = __esm({
11920
11923
  }
11921
11924
  }
11922
11925
  if (batch.length === 0) {
11926
+ if (landings > 0) {
11927
+ log17.info("promptUser store landings passed through", { landings });
11928
+ }
11923
11929
  return raw;
11924
11930
  }
11925
11931
  let results;
@@ -12263,7 +12269,7 @@ var init_headless = __esm({
12263
12269
  * there would strand the chain steps and background results behind it.
12264
12270
  */
12265
12271
  async drainQueueLoop() {
12266
- for (; ; ) {
12272
+ while (true) {
12267
12273
  const at = this.queue.firstDeliverableIndex();
12268
12274
  if (at === -1) {
12269
12275
  return;
@@ -19,7 +19,8 @@ Don't use one when: the data is structured (`db`), you only need to store files
19
19
  - **Re-adding the same bytes is free** — content-addressed, so ingest scripts are safe to re-run.
20
20
  - **Ingest is async.** `add()` returns once queued; poll `documents()`, or use `--wait` from the CLI.
21
21
  - **Reprocessing costs real money**, so changing how a corpus is built is always explicit.
22
- - **Limits apply**: 25 data sources per app, 5,000 documents per source, 10,000 chunks per document, 300 searches/minute. Well clear of normal use — but **source names must be fixed, not computed per user or per request**, since referencing one creates it. Partition inside a source with document metadata instead: tag at add time (`add(bytes, { filename, metadata: { userId } })`), narrow at search time (`search(q, { filter: { metadata: { userId } } })`).
22
+ - **Limits apply**: 25 data sources per app, 10,000 chunks per document, 300 searches/minute, and 5,000 documents per source when documents are added one at a time (bulk jobs and connectors, below, are how a corpus grows past that). Well clear of normal use — but **source names must be fixed, not computed per user or per request**, since referencing one creates it. Partition inside a source with document metadata instead: tag at add time (`add(bytes, { filename, metadata: { userId } })`), narrow at search time (`search(q, { filter: { metadata: { userId } } })`).
23
+ - **Credentials never appear in code or in chat.** A bucket's keys are app secrets (`remy-admin secrets set NAME --prod <value>`, or the dashboard); everything else refers to them by NAME. If the user pastes a key into the conversation, set it as a secret for them to use moving forward.
23
24
 
24
25
  ## Defining and searching
25
26
 
@@ -33,7 +34,7 @@ const context = results.map((r) => r.text).join('\n\n');
33
34
 
34
35
  Hits are `{ score, text, citation }` with `citation: { documentId, filename, pageNumber, chunkIndex, headingPath, boundingBox?, url }`, plus `retrievalRank`/`retrievalScore` — the position before reranking, so you can show what reranking did. With reranking on (the default) `score` is the reranker's 0–1 relevance and the right place for quality cutoffs; without it the scale varies by mode (cosine / rank-fusion / keyword overlap). `scoreThreshold` floors the retrieval branch before fusion and reranking — leave it unset unless measured on the corpus.
35
36
 
36
- **Always render the citation.** `citation.url` is a stable on-domain link — put it in an `<a href>` beside the answer. Retrieval is approximate; a user who can click through can judge for themselves. An answer with no citation is an assertion.
37
+ **Always render the citation.** A document is a cousin of a file and has the same links. `citation.url` is the stable path on the app's own domain, free and never expiring, that works for a signed-in user of the app — put it in an `<a href>` beside the answer as-is. For a public search tool, or a UI on another origin, sign it the way you would a private file: `search(q, { shareCitations: 3600 })` returns every `citation.url` as an absolute signed link that needs no session, and `Policies.shareUrl(hit.citation, { expiresIn })` signs one document on demand (default 24h). A signed link is a bearer capability until it expires, so sign at render time rather than storing signed results. When a link is not wanted at all, render `filename` and `pageNumber` as text. Retrieval is approximate; a user who can click through can judge for themselves. An answer with no citation is an assertion.
37
38
 
38
39
  Created on first use, so searching a source the build hasn't populated returns no results rather than throwing. `search` options: `topK` (default 5, max 50), `scoreThreshold`, `filter`, `mode`, `maxPerDocument`, `highlight`, `rerank`, `hybrid`.
39
40
 
@@ -45,8 +46,40 @@ Search is deterministic for a fixed corpus and configuration, so eval sets and r
45
46
 
46
47
  **Debugging retrieval.** Two opt-in options, neither of which changes the results or their order: `explain: true` adds `explain.{dense, lexical, matchedVia}` (which half of hybrid found each hit; costs two extra round trips), and `expand: 1` adds `neighbors.{before, after}` for surrounding context. When a document never comes back at all, `Policies.stats()` reports the config actually in effect and `Policies.chunks(documentId)` shows exactly how it was split.
47
48
 
49
+ **A cold index (shared capacity).** By default a data source lives on shared retrieval capacity: its vectors sit in their own isolated partition of a pool many apps share, and the pool keeps only a working set resident. A source nobody has searched for a while is unloaded to make room and reloaded from durable storage on the next search. A small corpus reloads inside that search and nobody notices; a large one (hundreds of thousands of chunks) reloads in the background for a minute or two, and `search()` throws `index_warming` (HTTP 503) until it lands. That means *loading*, never *empty*: catch it, tell the user the knowledge base is warming up, and retry shortly. Before a demo, `remy-admin datasources hydrate --source <slug> --wait` reloads it ahead of time. The way out of the cycle is dedicated capacity (below): a source on its own provisioned retrieval is never unloaded and never warms.
50
+
48
51
  **Configuration is not declared in code** — chunking and embedding settings live on the corpus and are set with the CLI, so code and reality can't drift.
49
52
 
53
+ ## Working with someone's data
54
+
55
+ People are bad at describing their own data, and the more of it they have the worse the description gets. "We have SO much data and a really complex system" is often a few thousand pages of PDFs; "just some documents" is occasionally a bucket of ten million records. Treat the description as a mood, not a measurement. This section is how you find out what is really there and set expectations to match, before any tooling comes up.
56
+
57
+ **1. Get real data in hand first.** Before proposing anything, ask for a sample of the actual thing: a handful of representative files, a link to where they live, or wherever the data is. Ask in the user's terms — "can you give me a few examples of what we're working with?" — as a `promptUser` form with a `file` question (`multiple: true`) and a text field for a link or a location. Let them answer however they can: if the data lives in an S3 bucket, for example, they will say so, and the bucket becomes the sample once its keys are set as app secrets (`datasources inspect --connector`). Do not ask them to size or classify it; that is your job once you can see it. What arrives depends on how much they brought: files land under `src/.user-uploads/` (documents with an extracted-text sidecar at `<path>.txt`; a folder as `<folder>.zip`, unpack it), and a folder too large to hand over that way is streamed into the app's private `uploads` file store instead, and the answer is a landing `{ store, prefix, files, bytes }` you read in place.
58
+
59
+ **2. Look, then say what it is.** Poke around the sample yourself: the files on disk, or for a landing `datasources inspect --store uploads --prefix <folder>/` plus `remy-admin files get` on a few keys (a landing's file count and bytes already tell you how big the corpus, or its known fraction, is). From it you can tell whether the file is the document (PDFs, Word, slides, pages, plain text: built-in extraction handles it) or a container of records (JSON, JSONL, CSV exports, a database dump: the file is not the document and the source needs a mapper); how big each one is; and whether there is one shape or several. The one thing a sample cannot tell you is how much more there is, so ask exactly that, in whatever terms the user has (a folder, a year of exports, a bucket). Then say what you found back in plain words.
60
+
61
+ **3. Pick the rung from the count, and say it before any tooling.** The count decides the path, never the adjectives.
62
+ - Up to a few hundred files: `datasources add`, a few dollars, the shared pool. Nothing else in this skill needs to come up; jobs, connectors and capacity stay out of the conversation.
63
+ - Thousands to a few hundred thousand documents: a job with a plan before anything is spent, still the shared pool (up to about a million chunks), a mapper only if the file is not the document. Tens to a few hundred dollars.
64
+ - Millions of records, or tens of gigabytes: the full path described below. Embedding runs over time based on rate limits, storage and compute are real line items, and the plan will likely answer `plan_requires_dedicated`, which is the moment dedicated capacity is discussed, with its price, and not before.
65
+
66
+ **4. The two ways this goes wrong.**
67
+ - Over-building: a user who sounds big doesn't need connectors and dedicated retrieval for a folder of PDFs; the rung, said out loud before any tooling, is the guard.
68
+ - Under-explaining: when the data really is big, the user has usually been handed keys and told "go do RAG" and has never been told what that entails. Walk them through it in plain numbers: it costs money in three places (embedding, storage, and possibly dedicated compute); it takes time because embedding is rate-limited (a truly massive corpus might take days, even! but the system is designed for exactly this. 99% of users will never need this, though); there are decisions only they can make and you will stop for each; the first search on a cold index is slow; and here is roughly what the bill looks like before anyone spends. Self-described "RAG experts" get the same walkthrough, phrased as what this platform will do rather than what RAG is: respect the expertise, verify the specifics anyway.
69
+
70
+ **5. The order of operations for a real load.** Once the sample says this is a load rather than an `add`, this is the shape; each step is detailed in the sections below.
71
+
72
+ 1. **Look before you promise.** `datasources inspect` over the store or the bucket: key shapes, counts and bytes by extension, size buckets, sampled heads with their JSON keys. Report it in the user's vocabulary: how many objects, how many formats, what is not documents, which parts nobody mentioned.
73
+ 2. **Ask the questions only the user can answer**, as a form, and stop for them: what is in scope and what is not, what one document is (a file, a record, the latest version of a record), what counts as a duplicate, what to do with the low-value kinds, who holds the rights to anything third-party. Do not guess these; they decide what the mapper does.
74
+ 3. **Shape it, if the file is not the document.** Write the mapper and `map test --dev` it over twenty real objects; read the outcomes back to the user as documents, metadata and skips with reasons. Adjust and re-run; deploy only when the outcomes read right.
75
+ 4. **Sample before the whole thing.** `jobs start --limit 500` loads a slice cheaply; search it; fix the mapper; `remap`. A mistake on five hundred documents costs cents.
76
+ 5. **Show the plan and get an explicit yes.** The plan is the bill: documents, chunks, cost per stage, storage, duration, and whether the corpus fits where the source lives. Present it, then `jobs approve`. If it answers `plan_requires_dedicated`, the size decision comes first: show the offering's price, get a yes, `infra provision`, `datasources move`, then approve.
77
+ 6. **Run it, and read what came out.** `jobs status` for progress and the last failures; `jobs quarantine` for what the mapper skipped or failed on, by reason; fix, deploy, `jobs replay`. Search works on the partial corpus throughout.
78
+ 7. **Keep it current.** For a bucket, a cron method calling `Source.sync()`; for anything else, the same job re-run (unchanged objects cost nothing).
79
+ 8. **Measure before you change anything.** A sample source and an eval set; compare versions and models with numbers, and ask before promoting.
80
+
81
+ Three rules hold throughout: credentials are app secrets referred to by NAME and never appear in chat or code; nothing that spends is approved or provisioned without the user's explicit yes on the numbers; and dedicated capacity is proposed when a plan asks for it, not before.
82
+
50
83
  ## Loading documents — normally at build time, from the CLI
51
84
 
52
85
  ```bash
@@ -57,7 +90,7 @@ remy-admin datasources search --source policies --filter department=legal --mode
57
90
  remy-admin datasources delete --source policies # whole source; --source is required, never defaulted
58
91
  ```
59
92
 
60
- `--wait` blocks until processing finishes and exits non-zero on failure. Also `datasources list`, `status` (per-document state + ingest errors), `rm --document <id>`. `--help` for flags.
93
+ `--wait` blocks until processing finishes and exits non-zero on failure. Also `datasources list`, `status` (per-document state + ingest errors), `rm --document <id>` or `rm --filter <k=v,...>`. `--help` for flags. For more than a few dozen files, use a job (below) rather than `add`.
61
94
 
62
95
  **Seeding a test corpus:** scenarios don't touch data sources, so load fixtures with the same command in a setup script — `datasources add --source <slug> --wait fixtures/*.pdf`. Re-running is free, so it needs no guard.
63
96
 
@@ -69,11 +102,108 @@ await Policies.add(buffer, {
69
102
  contentType: 'application/pdf',
70
103
  metadata: { department: 'legal' }, // filterable at search time
71
104
  });
72
- const docs = await Policies.documents(); // 'processing' | 'done' | 'error'
105
+ const docs = await Policies.documents({ ids: [document!.id] }); // 'processing' | 'done' | 'error'; plain documents() is the first thousand
73
106
  await Policies.remove(documentId);
74
107
  ```
75
108
 
76
- Formats: pdf, docx, pptx, xlsx, odt, rtf, epub, images, txt, md, json, csv, tsv, log, html.
109
+ Formats: pdf, docx, pptx, xlsx, odt, rtf, epub, images, txt, md, json, csv, tsv, log, html. When the file is not the document (a JSON record, a JSONL bundle of articles, a kill notice), the source needs a mapper — see below.
110
+
111
+ Removing many documents at once: `Policies.removeWhere({ metadata: { year: 2019 } })` or `{ externalIdPrefix: 'archive/2019/' }` (the key a job or connector recorded) removes every match, vectors and bytes included, in pages of a thousand. From the CLI, `datasources rm --source policies --filter year=2019`. An empty filter is refused; deleting a whole source is `datasources delete`, never something app code does.
112
+
113
+ ## Loading a corpus of any size (jobs)
114
+
115
+ `datasources add` is for a handful of files. A corpus of thousands to millions of documents is loaded by a **job**, which reads either every object under a prefix of one of the app's file stores or a JSONL manifest of URLs, and shows a **plan before anything is spent**: documents, chunks, cost per stage at today's rates, storage, whether it fits where the source lives, and a duration.
116
+
117
+ ```bash
118
+ remy-admin datasources jobs start --source archive --store raw --prefix 2024/ --wait # plan, then stop
119
+ remy-admin datasources jobs status <id> # read the plan
120
+ remy-admin datasources jobs approve <id> --wait # the yes to the spend
121
+ remy-admin datasources jobs start --source archive --manifest urls.jsonl --limit 200 --approve --wait # a cheap sample first
122
+ ```
123
+
124
+ Two gates decide whether a plan can run: the corpus has to fit the source's placement (a shared-pool source over the per-source cap answers `plan_requires_dedicated`; see Dedicated capacity below), and the workspace has to be able to cover the projection (`insufficient_credits`). **Show the user the plan and get an explicit yes before approving** — the plan is the whole point. `--budget <dollars>` pauses the job at a ceiling; `--limit <n>` loads a sample of the corpus to check quality before committing to all of it. Unchanged documents are skipped by content hash, so re-running a job is free. `jobs pause|resume|cancel` are the controls; search works on the partial corpus throughout. One bulk operation per source at a time (`data_source_busy`).
125
+
126
+ ## Keeping a corpus in sync with an S3 bucket (connectors)
127
+
128
+ When the documents live in a bucket the user owns, connect it once and sync from then on. The keys are the NAMES of two app secrets, set first:
129
+
130
+ ```bash
131
+ remy-admin secrets set ARCHIVE_S3_KEY --prod <value> # the user sets these, or does it in the dashboard
132
+ remy-admin secrets set ARCHIVE_S3_SECRET --prod <value>
133
+ remy-admin datasources connect --source archive --bucket acme-docs --region us-east-1 --prefix contracts/ --access-key-secret ARCHIVE_S3_KEY --secret-key-secret ARCHIVE_S3_SECRET --budget-per-sync 5
134
+ remy-admin datasources sync --source archive --wait # first sync: plans the whole bucket, stops for approval if over the budget
135
+ remy-admin datasources connector --source archive # what it follows, last sync, object counts
136
+ ```
137
+
138
+ `connect` checks that both secrets exist and that the keys can list the prefix before recording anything. `sync` lists the bucket, compares every object's ETag with what was ingested before, and runs a job over what is new or changed — auto-approved under the connector's per-sync budget, so **the first backfill of a big bucket stops for `jobs approve` by itself and the nightly deltas run unattended**. A changed object replaces its document; an object that disappears from the bucket takes its document with it at the end of the next full sync (`--deletions mirror`, the default; `keep` leaves them). A sync over an unchanged bucket costs nothing. Objects the extractors cannot read are skipped, not failed.
139
+
140
+ **Scheduling is the app's.** The nightly sync is an ordinary cron interface job whose method calls the SDK:
141
+
142
+ ```typescript
143
+ // methods/sync-archive.ts — scheduled "0 3 * * *" in the cron interface (see the Scheduled Jobs skill)
144
+ export default async function () {
145
+ const { job } = await Archive.sync(); // returns at once; the job plans and runs in the background
146
+ }
147
+ ```
148
+
149
+ `Archive.jobs()` lists recent syncs and `Archive.job(id)` reads one — progress, plan, the last twenty per-document failures, and why it paused if it did. The connection itself (bucket, prefix, which secrets) is only ever made from the CLI or the dashboard; code can run a sync, not repoint one. `datasources disconnect --source archive` stops following the bucket and keeps every document.
150
+
151
+ ## Mapping raw objects into documents (mappers)
152
+
153
+ A source takes every object it is given as one document through built-in extraction. When **the file is not the document** — JSON records that should become markdown plus metadata, a JSONL object that holds a thousand articles, a kill notice that means "remove this story", a bucket where only some keys matter — give the source a **mapper**: your code, one object in, documents out. This is how a structured archive becomes a corpus, and you write it.
154
+
155
+ **Inspect before you write.** `remy-admin datasources inspect --source archive --connector` (or `--store raw --prefix 2024/`) profiles the objects without reading them all: counts and bytes by extension and by key shape (`2024/#/{uuid}.json`), size buckets, and ten sampled heads with their top-level JSON keys. Report what you found and ask the questions only the user can answer (which prefixes, which schema generations, what counts as a duplicate) before writing a line.
156
+
157
+ ```typescript
158
+ // datasources/archive.mapper.ts — beside archive.ts, which exports Archive
159
+ import { defineMapper, documents, passthrough, skip, deletes } from '@mindstudio-ai/agent';
160
+ import { Archive } from './archive';
161
+
162
+ export default defineMapper(Archive, {
163
+ map: async (object) => {
164
+ if (!object.key.endsWith('.json')) return passthrough(); // PDFs etc. through built-in extraction
165
+ const { item } = await object.json();
166
+ if (item.type !== 'text') return skip(`not an article: ${item.type}`);
167
+ if (item.pubstatus === 'canceled') return deletes([item.uri]); // a kill notice
168
+ return documents([{
169
+ externalId: item.uri, // the identity the platform replaces by
170
+ title: item.headline ?? item.slugline,
171
+ markdown: toMarkdown(item),
172
+ metadata: { date: Number(item.versioncreated.slice(0, 10).replaceAll('-', '')), language: item.language },
173
+ replaces: item.altids?.original_id, // a writethru supersedes its original
174
+ }]);
175
+ },
176
+ });
177
+ ```
178
+
179
+ Declared in `mindstudio.json` — the compiler lifts it like a jewel and the platform runs it as an ordinary execution frame:
180
+
181
+ ```json
182
+ "dataSources": [{ "slug": "archive", "mapper": { "path": "dist/datasources/archive.mapper.ts", "timeoutMs": 30000 } }]
183
+ ```
184
+
185
+ `object` is `{ key, size, contentType, etag, lastModified, metadata }` plus lazy `bytes()`, `text()`, `json()`. Four outcomes: `documents([...])` (an array is one object → many documents; each `{ externalId, title, markdown, metadata?, replaces? }`), `passthrough({ metadata? })` (ingest the raw object as-is), `skip(reason)`, `deletes([externalId, ...])`. Throwing is the object's error. A mapper may call models (`runTask`) or `fetch` an API — it is your code — but every call is spend per object, so keep the common path cheap. `timeoutMs` is the per-object budget (default 30 s, max 300 s).
186
+
187
+ **Everything entering a mapped source is mapped** — jobs, syncs and `Source.add()` alike, one rule. `add()` on a mapped source returns `{ documents, document, outcome }` (several documents from one object is normal) and throws `mapper_skipped` when the mapper refused the object: surface that to the user as what it is, not as a generic failure.
188
+
189
+ The loop, in this order:
190
+
191
+ ```bash
192
+ remy-admin datasources inspect --source archive --connector # look first
193
+ remy-admin datasources map test --source archive --connector --limit 20 --dev # the LOCAL mapper, real objects, nothing ingested
194
+ # fix, re-run, until the outcomes read right; then deploy
195
+ remy-admin datasources map test --source archive --connector --limit 20 # the compiled mapper, same objects
196
+ remy-admin datasources sync --source archive --wait # or jobs start
197
+ remy-admin datasources jobs quarantine <id> # what it skipped or failed on, by reason
198
+ remy-admin datasources jobs replay <id> --wait # after a fix + deploy: just those objects again
199
+ remy-admin datasources remap --source archive --wait # a changed mapper over every raw copy
200
+ ```
201
+
202
+ `map test --dev` needs the dev session running (`npx mindstudio dev`); it runs the mapper from local source through the tunnel and prints every outcome with markdown previews. The plan of a mapped job records the mapper's outcome mix on its sample; a run whose skip share climbs past twice that pauses with `pauseReason: 'skips'` for a look at the quarantine. `remap` reads the platform's own raw copies — no origin traffic — skips unchanged markdown by hash, and supersedes changed documents, so a metadata tweak on a million-document source costs frames and little else. `externalId` is the identity everything replaces by; choose it deliberately (the record's stable id, never the key of a file that gets rewritten in place).
203
+
204
+ ## Dedicated capacity
205
+
206
+ The shared pool holds a source up to a per-source cap of chunks. A corpus beyond it — a plan that answers `plan_requires_dedicated` — runs on dedicated retrieval capacity the workspace provisions and pays for hourly: `remy-admin infra list` shows the offering catalog with prices and any resources the app has, `infra provision --offering <id> --name <n> --wait` creates one, and `datasources move --source archive --to <resource-id> --wait` puts the source on it with its data intact (`create --placement <resource-id>` starts a new source there). Provisioning bills the workspace; **never provision without the user's explicit confirmation**, and show them the offering's price first. `infra --help` covers hibernate, resume, resize and destroy.
77
207
 
78
208
  ## Answering from results
79
209
 
@@ -105,4 +235,27 @@ remy-admin datasources promote --source policies #
105
235
 
106
236
  Search serves the current version throughout, so nothing degrades while the new one builds. `datasources drop` discards an unwanted candidate.
107
237
 
238
+ ## Evaluating retrieval — measure before you promote
239
+
240
+ "Is the rebuilt version better?" and "which embedding model should this corpus use?" are measured, not guessed. The instrument is a query set (questions with the documents that should come back) run against a version, returning recall@k, MRR, nDCG, latency and cost per query as JSON. You present the table.
241
+
242
+ ```bash
243
+ remy-admin datasources sample --source archive --size 300 --stratify year --wait # a representative subset, same config as the parent
244
+ remy-admin datasources eval create --source archive-sample --name base --size 150 --wait # 150 queries generated from the sample
245
+ remy-admin datasources eval run --set <setId> --label live --wait # score the live version
246
+ remy-admin datasources revectorize --source archive-sample --max-chars 900 --wait # the change under test
247
+ remy-admin datasources eval run --set <setId> --candidate --label small-chunks --wait # score the candidate
248
+ remy-admin datasources eval compare <runA> <runB> # deltas + per-query wins/losses
249
+ remy-admin datasources eval result <runId> --worst 10 # what the misses retrieved instead
250
+ ```
251
+
252
+ Rules that keep the numbers honest:
253
+
254
+ - **Work on a sample.** A sample shares the parent's exact pinned config and its documents' content hashes, so a set built on it also scores the parent (`eval run --set <id> --source archive`). Re-vectorizing a 300-document sample costs cents; the full corpus costs real money.
255
+ - **Generated queries are a start, not the truth.** The default `cloze` style holds a sentence out of a chunk — free and deterministic, but it flatters keyword matching because a real user does not type sentences from the document. `--style question` has a chat model write the question a user would ask (a model call per query). Add the questions the user actually cares about with `eval add --set <id> --query "..." --expect <filename>` or `eval import` from JSONL; tag them so `byTag` breaks the numbers down.
256
+ - **Runs cost searches.** Every query is a real search (embedding + rerank spend), capped at 2,000 per run. Say so before running a large set.
257
+ - **Read the branch breakdown.** `branches.{denseOnly, lexicalOnly, both}` says which half of hybrid found the answers; a corpus of part numbers and proper nouns lives on `lexicalOnly`, and that is the case for keeping hybrid on even when it costs latency.
258
+ - **Compare like with like.** Same set, same target, one change at a time: a version (`--candidate`), a retrieval override (`--mode`, `--rerank false`, `--rerank-model <id>`, `--hybrid false`), never both in one run.
259
+ - **Present, then recommend.** A table with run labels, recall@5, MRR, p50 latency and cost per query, then one sentence: promote or drop. Ask before promoting; it changes what the deployed app retrieves.
260
+
108
261
  For anything deeper on the SDK, ask `askMindStudioSdk` rather than guessing at an API.
@@ -82,7 +82,7 @@ You have access to the `mindstudio` CLI, which exposes every SDK action as a com
82
82
  ### Production App Management
83
83
  You have access to `remy-admin`, a CLI for managing the user's production app. Use it via your bash tool. All output is JSON. Run `remy-admin --help` or `remy-admin <command> --help` to discover usage and available options.
84
84
 
85
- Available commands: `requests` (server logs, errors, latency), `crashes` (frontend browser errors), `analytics` (traffic queries — lifetime metrics, sources, live counters), `releases`, `diagnostics` (Lighthouse audit), `domains`, `users` (list, set roles), `db` (query production sql), `data` (live db operations like lift-from-dev), `methods` (list, invoke), `secrets`, `files` (CDN files), `datasources` (document corpora), `prerender` (crawler snapshots), `voice` (phone numbers, call logs, voice policy), `issues` (externally-reported bugs), `settings` (app settings: signup restrictions, app-store-reviewer test accounts, embedding origins, toggles).
85
+ Available commands: `requests` (server logs, errors, latency), `crashes` (frontend browser errors), `analytics` (traffic queries — lifetime metrics, sources, live counters), `releases`, `diagnostics` (Lighthouse audit), `domains`, `users` (list, set roles), `db` (query production sql), `data` (live db operations like lift-from-dev), `methods` (list, invoke), `secrets`, `files` (CDN files), `datasources` (document corpora: add, search, `inspect` a bucket or store, bulk `jobs` with a plan, S3 `connect`/`sync`, mappers via `map test`/`remap`, `eval`), `infra` (dedicated retrieval capacity for large corpora), `prerender` (crawler snapshots), `voice` (phone numbers, call logs, voice policy), `issues` (externally-reported bugs), `settings` (app settings: signup restrictions, app-store-reviewer test accounts, embedding origins, toggles).
86
86
 
87
87
  Two rules: buying a `voice` phone number bills $1/month — never buy without the user's explicit confirmation. `issues` is for externally-reported bugs only — read from it and resolve items when the user asks; never use it to track work you are doing with the user.
88
88
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mindstudio-ai/remy",
3
- "version": "0.1.320",
3
+ "version": "0.1.321",
4
4
  "description": "Remy coding agent",
5
5
  "repository": {
6
6
  "type": "git",