@withone/cli 1.52.3 → 1.53.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/README.md CHANGED
@@ -423,8 +423,11 @@ one sync run stripe --since 90d
423
423
  one mem sync run stripe # identical (alias)
424
424
  # Profiles with `enrich` run a second detail pass. It enriches each record ONCE;
425
425
  # the list pass thereafter merges rather than replaces, so the enriched payload
426
- # survives (reported as `memPreserved`). `--full-refresh` does not re-enrich —
427
- # delete .one/sync/data/<platform>.db to force that.
426
+ # survives (reported as `memPreserved`). `--full-refresh` reconciles deletions,
427
+ # it does not refresh detail content. To refresh detail:
428
+ one sync run gmail --re-enrich # re-fetch every detail endpoint
429
+ # ...or set `enrich.invalidateOn` in the profile (e.g. "historyId", "updated_at")
430
+ # so only records that actually changed upstream are re-enriched, automatically.
428
431
 
429
432
  # Query + search (reads from memory)
430
433
  one sync query stripe/balanceTransactions --where "status=available" --limit 20
package/dist/index.js CHANGED
@@ -5672,6 +5672,52 @@ function findEnrichedIds(db, model, idField, config2, records, tableCreated) {
5672
5672
  }
5673
5673
  return enriched;
5674
5674
  }
5675
+ var ENRICH_FINGERPRINT_COLUMN = "_enrich_fp";
5676
+ function ensureColumn(db, safeTable, column, type) {
5677
+ const cols = db.prepare(`PRAGMA table_info("${safeTable}")`).all();
5678
+ if (!cols.some((c) => c.name === column)) {
5679
+ db.exec(`ALTER TABLE "${safeTable}" ADD COLUMN "${column}" ${type}`);
5680
+ }
5681
+ }
5682
+ function clearEnrichmentStamps(db, model, timestampField = "_enriched_at") {
5683
+ const safeTable = model.replace(/[^a-zA-Z0-9_]/g, "_");
5684
+ const exists = db.prepare(
5685
+ `SELECT name FROM sqlite_master WHERE type='table' AND name = ?`
5686
+ ).get(safeTable);
5687
+ if (!exists) return 0;
5688
+ const cols = db.prepare(`PRAGMA table_info("${safeTable}")`).all();
5689
+ if (!cols.some((c) => c.name === timestampField)) return 0;
5690
+ const result = db.prepare(
5691
+ `UPDATE "${safeTable}" SET "${timestampField}" = NULL WHERE "${timestampField}" IS NOT NULL`
5692
+ ).run();
5693
+ return result.changes;
5694
+ }
5695
+ function invalidateStaleEnrichments(db, model, config2) {
5696
+ const fpField = config2.invalidateOn;
5697
+ if (!fpField) return 0;
5698
+ const tsField = config2.timestampField ?? "_enriched_at";
5699
+ const safeTable = model.replace(/[^a-zA-Z0-9_]/g, "_");
5700
+ const exists = db.prepare(
5701
+ `SELECT name FROM sqlite_master WHERE type='table' AND name = ?`
5702
+ ).get(safeTable);
5703
+ if (!exists) return 0;
5704
+ const cols = new Set(
5705
+ db.prepare(`PRAGMA table_info("${safeTable}")`).all().map((c) => c.name)
5706
+ );
5707
+ if (!cols.has(fpField)) return 0;
5708
+ if (!cols.has(tsField)) return 0;
5709
+ if (!cols.has(ENRICH_FINGERPRINT_COLUMN)) {
5710
+ ensureColumn(db, safeTable, ENRICH_FINGERPRINT_COLUMN, "TEXT");
5711
+ return 0;
5712
+ }
5713
+ const result = db.prepare(
5714
+ `UPDATE "${safeTable}" SET "${tsField}" = NULL
5715
+ WHERE "${tsField}" IS NOT NULL
5716
+ AND "${ENRICH_FINGERPRINT_COLUMN}" IS NOT NULL
5717
+ AND "${ENRICH_FINGERPRINT_COLUMN}" IS NOT CAST("${fpField}" AS TEXT)`
5718
+ ).run();
5719
+ return result.changes;
5720
+ }
5675
5721
  async function enrichPhase(api, db, config2, model, idField, connectionKey, platform, ctx = {}) {
5676
5722
  const startTime = Date.now();
5677
5723
  const tsField = config2.timestampField ?? "_enriched_at";
@@ -5740,6 +5786,10 @@ async function enrichPhase(api, db, config2, model, idField, connectionKey, plat
5740
5786
  }
5741
5787
  const merged = config2.merge !== false ? deepMerge(row, enrichedData) : { ...enrichedData, [idField]: id };
5742
5788
  merged[tsField] = now;
5789
+ if (config2.invalidateOn) {
5790
+ const fp = merged[config2.invalidateOn] ?? row[config2.invalidateOn];
5791
+ merged[ENRICH_FINGERPRINT_COLUMN] = fp == null ? null : String(fp);
5792
+ }
5743
5793
  pending.push({ merged, id });
5744
5794
  } else if (result.status === "fulfilled" && result.value === null) {
5745
5795
  rateLimited++;
@@ -5803,7 +5853,7 @@ async function enrichPhase(api, db, config2, model, idField, connectionKey, plat
5803
5853
  hookEvents.push({ type: "update", platform, model, record: merged, timestamp: now });
5804
5854
  }
5805
5855
  }
5806
- if (ctx.profile && memoryBatch.length > 0) {
5856
+ if (ctx.profile && ctx.writeToMemory !== false && memoryBatch.length > 0) {
5807
5857
  try {
5808
5858
  await writePageToMemory(ctx.profile, memoryBatch);
5809
5859
  } catch (err) {
@@ -6458,6 +6508,19 @@ async function syncModel(api, profile, options) {
6458
6508
  }
6459
6509
  let enrichResult = null;
6460
6510
  if (profile.enrich && db && tableCreated && !options.dryRun) {
6511
+ if (options.reEnrich) {
6512
+ const cleared = clearEnrichmentStamps(db, model, profile.enrich.timestampField);
6513
+ if (cleared > 0 && !isAgentMode()) {
6514
+ console.log(` Re-enriching ${cleared} record(s) (--re-enrich)`);
6515
+ }
6516
+ } else {
6517
+ const invalidated = invalidateStaleEnrichments(db, model, profile.enrich);
6518
+ if (invalidated > 0 && !isAgentMode()) {
6519
+ console.log(
6520
+ ` ${invalidated} record(s) changed upstream (${profile.enrich.invalidateOn}) \u2014 re-enriching those`
6521
+ );
6522
+ }
6523
+ }
6461
6524
  enrichResult = await enrichPhase(
6462
6525
  api,
6463
6526
  db,
@@ -6476,7 +6539,9 @@ async function syncModel(api, profile, options) {
6476
6539
  // Pass the full profile so enrich can mirror merged rows into the
6477
6540
  // memory store. Without this, memory holds only the pre-enrich
6478
6541
  // list payload — see enrich.ts:memoryBatch writeback.
6479
- profile
6542
+ profile,
6543
+ // Phase 1 honours --no-memory; phase 2 used to ignore it. (#174)
6544
+ writeToMemory: options.toMemory !== false
6480
6545
  }
6481
6546
  );
6482
6547
  enrichedTotal = enrichResult.enriched;
@@ -8596,7 +8661,7 @@ function registerSyncSubcommands(sync) {
8596
8661
  sync.command("suggest-searchable <platform/model>").description("Rank candidate memory.searchable paths by signal density (for profiles with memory.embed: true)").option("--limit <n>", "Top N paths to return (default 15)").action(async (platformModel, options) => {
8597
8662
  await syncSuggestSearchableCommand(platformModel, options);
8598
8663
  });
8599
- sync.command("run <platform>").description("Run sync for a platform (syncs all configured models, or specify --models)").option("--models <models>", "Comma-separated list of models to sync").option("--since <duration>", "Sync records since duration (e.g. 90d, 30d, 7d) or date").option("--force", "Ignore existing sync state and start fresh").option("--max-pages <n>", "Maximum number of pages to fetch").option("--dry-run", "Fetch first page only, show results without persisting").option("--full-refresh", "Fetch ALL records and delete local rows no longer in the source (handles deletions)").option("--no-memory", "Skip the unified memory dual-write (default: memory is always written)").option("--embed", "Embed synced rows under the configured model, regardless of profile.memory.embed", false).option("--no-embed", "Skip embedding even if the profile opts in").option("--to-memory", "(deprecated \u2014 memory is now always written; flag kept for back-compat)").action(async (platform, options) => {
8664
+ sync.command("run <platform>").description("Run sync for a platform (syncs all configured models, or specify --models)").option("--models <models>", "Comma-separated list of models to sync").option("--since <duration>", "Sync records since duration (e.g. 90d, 30d, 7d) or date").option("--force", "Ignore existing sync state and start fresh").option("--max-pages <n>", "Maximum number of pages to fetch").option("--dry-run", "Fetch first page only, show results without persisting").option("--full-refresh", "Fetch ALL records and delete local rows no longer in the source (handles deletions)").option("--re-enrich", 'Re-fetch the detail endpoint for every already-enriched record (profiles with an "enrich" block). Costs one detail call per record \u2014 for routine freshness set enrich.invalidateOn in the profile instead').option("--no-memory", "Skip the unified memory dual-write (default: memory is always written)").option("--embed", "Embed synced rows under the configured model, regardless of profile.memory.embed", false).option("--no-embed", "Skip embedding even if the profile opts in").option("--to-memory", "(deprecated \u2014 memory is now always written; flag kept for back-compat)").action(async (platform, options) => {
8600
8665
  await syncRunCommand(platform, {
8601
8666
  models: options.models?.split(",").map((m) => m.trim()),
8602
8667
  since: options.since,
@@ -8604,6 +8669,7 @@ function registerSyncSubcommands(sync) {
8604
8669
  maxPages: options.maxPages ? parseInt(options.maxPages, 10) : void 0,
8605
8670
  dryRun: options.dryRun,
8606
8671
  fullRefresh: options.fullRefresh,
8672
+ reEnrich: options.reEnrich,
8607
8673
  // Commander inverts `--no-memory` / `--no-embed` into options.X === false.
8608
8674
  toMemory: options.memory !== false,
8609
8675
  // Only override when the flag was actually passed — leaving
@@ -10740,6 +10806,7 @@ When a list endpoint returns lightweight records (e.g. just IDs), add an \`enric
10740
10806
  "enrich": {
10741
10807
  "actionId": "<get-message-action-id>",
10742
10808
  "pathVars": { "messageId": "{{id}}" },
10809
+ "invalidateOn": "historyId",
10743
10810
  "concurrency": 3,
10744
10811
  "delayMs": 200
10745
10812
  }
@@ -10747,7 +10814,8 @@ When a list endpoint returns lightweight records (e.g. just IDs), add an \`enric
10747
10814
  \`\`\`
10748
10815
 
10749
10816
  - \`pathVars\` / \`queryParams\` / \`body\` support \`{{field}}\` interpolation from the list record
10750
- - \`concurrency\` controls parallel detail requests per page (default: 3, lower = safer for rate limits)
10817
+ - \`invalidateOn\` names a list field that acts as a change fingerprint, so records whose detail went stale are re-enriched automatically (see "Re-enriching" below). Omit it to keep enrich-exactly-once behaviour
10818
+ - \`concurrency\` controls parallel detail requests per page (default: 5, lower = safer for rate limits)
10751
10819
  - \`delayMs\` is the pause between batches (default: 200ms)
10752
10820
  - \`resultsPath\` extracts a sub-object from the detail response before merging
10753
10821
  - \`merge: false\` replaces the record entirely instead of deep-merging
@@ -10803,7 +10871,19 @@ So for a row the mirror reports as already enriched, Phase 1 writes **non-author
10803
10871
 
10804
10872
  The write still happens, so \`--embed\`, profile edits, and the store's un-archive self-heal all keep working on enriched rows. The one thing given up: a field that disappears from the *list* shape upstream no longer disappears from an enriched record, because a merge cannot delete. \`sync run\` reports the count as \`memPreserved\`.
10805
10873
 
10806
- **Known gap:** \`--full-refresh\` does **not** clear \`_enriched_at\`, so it does not re-enrich \u2014 and there is currently no way to re-enrich a record whose upstream detail content changed after its first enrichment. Delete the mirror (\`.one/sync/data/<platform>.db\`) to force a full re-enrich.
10874
+ **Re-enriching.** \`--full-refresh\` re-pulls the list but deliberately does **not** clear \`_enriched_at\` \u2014 it reconciles deletions, it is not a detail refresh. There are two ways to refresh detail content:
10875
+
10876
+ - **\`enrich.invalidateOn\` (preferred, automatic).** Name a list-endpoint field that acts as a change fingerprint \u2014 \`historyId\` for Gmail threads, \`updated_at\` for Fathom meetings. The value seen at enrich time is recorded, and on the next sync any record whose fingerprint has moved is re-enriched. Records that did not change upstream cost nothing.
10877
+
10878
+ \`\`\`json
10879
+ { "enrich": { "actionId": "...", "invalidateOn": "historyId" } }
10880
+ \`\`\`
10881
+
10882
+ Additive by design: rows enriched before a fingerprint existed are never auto-invalidated (that would re-enrich the whole table on the first run after upgrading) \u2014 they pick one up the next time they enrich. Profiles with no sensible fingerprint field simply omit it and keep enrich-exactly-once behaviour.
10883
+
10884
+ - **\`one sync run <platform> --re-enrich\` (manual escape hatch).** Clears every enrichment stamp and re-fetches all detail endpoints. Costs one detail call per record, so it is opt-in per run and never implied by \`--full-refresh\`. Use it when the detail *shape* changed, or for profiles with no fingerprint field.
10885
+
10886
+ Deleting the mirror (\`.one/sync/data/<platform>.db\`) is no longer necessary.
10807
10887
 
10808
10888
  ## Cross-Platform Identity
10809
10889
 
@@ -10909,7 +10989,7 @@ Every \`sync X\` command is also exposed as \`mem sync X\` \u2014 same handlers,
10909
10989
  | \`sync init <plat> <model>\` | Create/patch profile (seeds from built-in, auto-tests) |
10910
10990
  | \`sync test <plat>/<model>\` | Validate profile. \`--show-searchable\` previews embedded text across 5 samples with per-path hit rates |
10911
10991
  | \`sync suggest-searchable <plat>/<model>\` | Rank candidate \`memory.searchable\` paths by signal density; emits paste-ready config |
10912
- | \`sync run <platform>\` | Sync data (\`--full-refresh\`, \`--since\`, \`--dry-run\`, \`--no-memory\`) |
10992
+ | \`sync run <platform>\` | Sync data (\`--full-refresh\`, \`--since\`, \`--dry-run\`, \`--no-memory\`, \`--re-enrich\`) |
10913
10993
  | \`sync query <plat>/<model>\` | Query memory with \`--where\` (dotted paths), \`--after/before\` |
10914
10994
  | \`sync schema <plat>/<model>\` | Inspect the JSON structure of synced records (field paths, types, examples) \u2014 run before writing \`--where\` / query paths |
10915
10995
  | \`sync search "<query>"\` | Hybrid FTS + semantic across all synced data |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@withone/cli",
3
- "version": "1.52.3",
3
+ "version": "1.53.0",
4
4
  "description": "CLI for managing One",
5
5
  "type": "module",
6
6
  "files": [
@@ -318,7 +318,7 @@ Without declared paths, the default walker concatenates every string in the reco
318
318
 
319
319
  Both are queryable with `one --agent mem find-by-key <prefix>:<value>`.
320
320
 
321
- **Enriching profiles** (`gmail/gmailThreads`, `fathom/meetings`) sync in two phases: a list pass, then a detail pass that fetches full bodies/transcripts. Two things follow. Enrichment happens **once per record** — phase 2 only visits rows it has never enriched, and `--full-refresh` does *not* reset that, so re-running a sync will not refresh detail content (delete `.one/sync/data/<platform>.db` to force it). And the list pass never overwrites an enriched record: `data` merges rather than replaces, and `searchable_text` / `identity_keys[]` are left alone. `sync run` reports these as `memPreserved`. So on an enriching profile, a record whose upstream *detail* changed will look stale until the mirror is cleared — that is expected, not a sync failure.
321
+ **Enriching profiles** (`gmail/gmailThreads`, `fathom/meetings`) sync in two phases: a list pass, then a detail pass that fetches full bodies/transcripts. Two things follow. Enrichment happens **once per record by default** — phase 2 only visits rows it has never enriched, and `--full-refresh` does *not* reset that (it reconciles deletions, it is not a detail refresh). To refresh detail content, either set `enrich.invalidateOn` in the profile to a list field that moves when the detail changes (`historyId`, `updated_at`) so only genuinely-changed records re-enrich automatically, or run `one sync run <platform> --re-enrich` to re-fetch every detail endpoint. And the list pass never overwrites an enriched record: `data` merges rather than replaces, and `searchable_text` / `identity_keys[]` are left alone. `sync run` reports these as `memPreserved`. So on an enriching profile without `invalidateOn`, a record whose upstream *detail* changed will look stale until you re-enrich — that is expected, not a sync failure.
322
322
 
323
323
  **Advanced features** (enrich, transform, exclude, hooks, --full-refresh, alternative backends, embedding tuning): run `one guide memory` or `one guide sync` for the full reference.
324
324