@withone/cli 1.52.3 → 1.54.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
@@ -143,6 +143,15 @@ function loadBuiltinProfile(platform, model) {
143
143
  return null;
144
144
  }
145
145
  }
146
+ var CAPABILITY_FIELDS = ["identityKeys", "identityKey", "enrich", "dateFilter", "memory"];
147
+ function findMissingBuiltinCapabilities(platform, model, installed) {
148
+ if (!installed) return [];
149
+ const builtin = loadBuiltinProfile(platform, model);
150
+ if (!builtin) return [];
151
+ return CAPABILITY_FIELDS.filter(
152
+ (field) => builtin[field] !== void 0 && installed[field] === void 0
153
+ );
154
+ }
146
155
  function listBuiltinProfiles(platform) {
147
156
  const dir = getProfilesDir();
148
157
  if (!dir) return [];
@@ -1070,6 +1079,7 @@ export {
1070
1079
  resolveEntityIdentity,
1071
1080
  collectIdentityKeys,
1072
1081
  loadBuiltinProfile,
1082
+ findMissingBuiltinCapabilities,
1073
1083
  listBuiltinProfiles,
1074
1084
  memMigrateCommand,
1075
1085
  buildIdentityMap,
package/dist/index.js CHANGED
@@ -45,6 +45,7 @@ import {
45
45
  ensureTable,
46
46
  evolveSchema,
47
47
  extractSearchableFromPaths,
48
+ findMissingBuiltinCapabilities,
48
49
  generateTemplate,
49
50
  getDatabaseSize,
50
51
  getSearchablePaths,
@@ -65,7 +66,7 @@ import {
65
66
  writeDraftProfile,
66
67
  writePageToMemory,
67
68
  writeProfile
68
- } from "./chunk-IKYXS7EI.js";
69
+ } from "./chunk-XN4ZWMLX.js";
69
70
  import {
70
71
  getByDotPath
71
72
  } from "./chunk-44CV5IMX.js";
@@ -5672,6 +5673,52 @@ function findEnrichedIds(db, model, idField, config2, records, tableCreated) {
5672
5673
  }
5673
5674
  return enriched;
5674
5675
  }
5676
+ var ENRICH_FINGERPRINT_COLUMN = "_enrich_fp";
5677
+ function ensureColumn(db, safeTable, column, type) {
5678
+ const cols = db.prepare(`PRAGMA table_info("${safeTable}")`).all();
5679
+ if (!cols.some((c) => c.name === column)) {
5680
+ db.exec(`ALTER TABLE "${safeTable}" ADD COLUMN "${column}" ${type}`);
5681
+ }
5682
+ }
5683
+ function clearEnrichmentStamps(db, model, timestampField = "_enriched_at") {
5684
+ const safeTable = model.replace(/[^a-zA-Z0-9_]/g, "_");
5685
+ const exists = db.prepare(
5686
+ `SELECT name FROM sqlite_master WHERE type='table' AND name = ?`
5687
+ ).get(safeTable);
5688
+ if (!exists) return 0;
5689
+ const cols = db.prepare(`PRAGMA table_info("${safeTable}")`).all();
5690
+ if (!cols.some((c) => c.name === timestampField)) return 0;
5691
+ const result = db.prepare(
5692
+ `UPDATE "${safeTable}" SET "${timestampField}" = NULL WHERE "${timestampField}" IS NOT NULL`
5693
+ ).run();
5694
+ return result.changes;
5695
+ }
5696
+ function invalidateStaleEnrichments(db, model, config2) {
5697
+ const fpField = config2.invalidateOn;
5698
+ if (!fpField) return 0;
5699
+ const tsField = config2.timestampField ?? "_enriched_at";
5700
+ const safeTable = model.replace(/[^a-zA-Z0-9_]/g, "_");
5701
+ const exists = db.prepare(
5702
+ `SELECT name FROM sqlite_master WHERE type='table' AND name = ?`
5703
+ ).get(safeTable);
5704
+ if (!exists) return 0;
5705
+ const cols = new Set(
5706
+ db.prepare(`PRAGMA table_info("${safeTable}")`).all().map((c) => c.name)
5707
+ );
5708
+ if (!cols.has(fpField)) return 0;
5709
+ if (!cols.has(tsField)) return 0;
5710
+ if (!cols.has(ENRICH_FINGERPRINT_COLUMN)) {
5711
+ ensureColumn(db, safeTable, ENRICH_FINGERPRINT_COLUMN, "TEXT");
5712
+ return 0;
5713
+ }
5714
+ const result = db.prepare(
5715
+ `UPDATE "${safeTable}" SET "${tsField}" = NULL
5716
+ WHERE "${tsField}" IS NOT NULL
5717
+ AND "${ENRICH_FINGERPRINT_COLUMN}" IS NOT NULL
5718
+ AND "${ENRICH_FINGERPRINT_COLUMN}" IS NOT CAST("${fpField}" AS TEXT)`
5719
+ ).run();
5720
+ return result.changes;
5721
+ }
5675
5722
  async function enrichPhase(api, db, config2, model, idField, connectionKey, platform, ctx = {}) {
5676
5723
  const startTime = Date.now();
5677
5724
  const tsField = config2.timestampField ?? "_enriched_at";
@@ -5740,6 +5787,10 @@ async function enrichPhase(api, db, config2, model, idField, connectionKey, plat
5740
5787
  }
5741
5788
  const merged = config2.merge !== false ? deepMerge(row, enrichedData) : { ...enrichedData, [idField]: id };
5742
5789
  merged[tsField] = now;
5790
+ if (config2.invalidateOn) {
5791
+ const fp = merged[config2.invalidateOn] ?? row[config2.invalidateOn];
5792
+ merged[ENRICH_FINGERPRINT_COLUMN] = fp == null ? null : String(fp);
5793
+ }
5743
5794
  pending.push({ merged, id });
5744
5795
  } else if (result.status === "fulfilled" && result.value === null) {
5745
5796
  rateLimited++;
@@ -5803,7 +5854,7 @@ async function enrichPhase(api, db, config2, model, idField, connectionKey, plat
5803
5854
  hookEvents.push({ type: "update", platform, model, record: merged, timestamp: now });
5804
5855
  }
5805
5856
  }
5806
- if (ctx.profile && memoryBatch.length > 0) {
5857
+ if (ctx.profile && ctx.writeToMemory !== false && memoryBatch.length > 0) {
5807
5858
  try {
5808
5859
  await writePageToMemory(ctx.profile, memoryBatch);
5809
5860
  } catch (err) {
@@ -6458,6 +6509,19 @@ async function syncModel(api, profile, options) {
6458
6509
  }
6459
6510
  let enrichResult = null;
6460
6511
  if (profile.enrich && db && tableCreated && !options.dryRun) {
6512
+ if (options.reEnrich) {
6513
+ const cleared = clearEnrichmentStamps(db, model, profile.enrich.timestampField);
6514
+ if (cleared > 0 && !isAgentMode()) {
6515
+ console.log(` Re-enriching ${cleared} record(s) (--re-enrich)`);
6516
+ }
6517
+ } else {
6518
+ const invalidated = invalidateStaleEnrichments(db, model, profile.enrich);
6519
+ if (invalidated > 0 && !isAgentMode()) {
6520
+ console.log(
6521
+ ` ${invalidated} record(s) changed upstream (${profile.enrich.invalidateOn}) \u2014 re-enriching those`
6522
+ );
6523
+ }
6524
+ }
6461
6525
  enrichResult = await enrichPhase(
6462
6526
  api,
6463
6527
  db,
@@ -6476,7 +6540,9 @@ async function syncModel(api, profile, options) {
6476
6540
  // Pass the full profile so enrich can mirror merged rows into the
6477
6541
  // memory store. Without this, memory holds only the pre-enrich
6478
6542
  // list payload — see enrich.ts:memoryBatch writeback.
6479
- profile
6543
+ profile,
6544
+ // Phase 1 honours --no-memory; phase 2 used to ignore it. (#174)
6545
+ writeToMemory: options.toMemory !== false
6480
6546
  }
6481
6547
  );
6482
6548
  enrichedTotal = enrichResult.enriched;
@@ -7764,7 +7830,12 @@ async function syncProfilesCommand(platform) {
7764
7830
  model: p10.model,
7765
7831
  description: p10.description,
7766
7832
  hasEnrich: !!p10.enrich,
7767
- hasIdentityKey: !!p10.identityKey
7833
+ // `identityKey` (singular) is the entity-merge key; `identityKeys`
7834
+ // (plural, #167) are non-merging association keys. Reading only the
7835
+ // singular reported hasIdentityKey:false for gmail/gcal/fathom — the
7836
+ // three profiles the plural form was added for. (#129/#130)
7837
+ hasIdentityKey: !!p10.identityKey || !!p10.identityKeys,
7838
+ hasIdentityKeys: !!p10.identityKeys
7768
7839
  })),
7769
7840
  total: profiles.length,
7770
7841
  _hint: profiles.length > 0 ? "Use a built-in profile: one --agent sync init <platform> <model>" : "No built-in profiles found. Use sync init to auto-infer from action knowledge."
@@ -7781,7 +7852,7 @@ async function syncProfilesCommand(platform) {
7781
7852
  for (const p10 of profiles) {
7782
7853
  const extras = [];
7783
7854
  if (p10.enrich) extras.push("enrich");
7784
- if (p10.identityKey) extras.push("identity");
7855
+ if (p10.identityKey || p10.identityKeys) extras.push("identity");
7785
7856
  if (p10.dateFilter) extras.push("incremental");
7786
7857
  const tags = extras.length > 0 ? ` ${pc9.dim(`[${extras.join(", ")}]`)}` : "";
7787
7858
  console.log(` ${pc9.bold(`${p10.platform}/${p10.model}`.padEnd(35))} ${p10.description}${tags}`);
@@ -8093,6 +8164,15 @@ async function syncRunCommand(platform, options) {
8093
8164
  if (!options.dryRun) {
8094
8165
  await maybeAutoMigrateLegacy(platform, toSync.map((p10) => p10.model));
8095
8166
  }
8167
+ const profileDrift = toSync.map((p10) => ({ model: p10.model, missing: findMissingBuiltinCapabilities(platform, p10.model, p10) })).filter((d) => d.missing.length > 0);
8168
+ if (profileDrift.length > 0 && !isAgentMode()) {
8169
+ for (const d of profileDrift) {
8170
+ console.log(
8171
+ ` ${pc9.yellow("!")} ${platform}/${d.model} is missing ${d.missing.map((f) => pc9.bold(f)).join(", ")} from the current built-in profile.
8172
+ Run ${pc9.bold(`one sync init ${platform} ${d.model}`)} to pick ${d.missing.length > 1 ? "them" : "it"} up.`
8173
+ );
8174
+ }
8175
+ }
8096
8176
  const results = [];
8097
8177
  for (const profile of toSync) {
8098
8178
  try {
@@ -8116,7 +8196,19 @@ async function syncRunCommand(platform, options) {
8116
8196
  }
8117
8197
  }
8118
8198
  if (isAgentMode()) {
8119
- json({ platform, results });
8199
+ json({
8200
+ platform,
8201
+ results,
8202
+ // Surfaced to agents too: a profile silently missing a capability the
8203
+ // built-in now declares is invisible in the record counts. (#129/#130)
8204
+ ...profileDrift.length > 0 ? {
8205
+ profileDrift: profileDrift.map((d) => ({
8206
+ model: d.model,
8207
+ missingFields: d.missing,
8208
+ fix: `one sync init ${platform} ${d.model}`
8209
+ }))
8210
+ } : {}
8211
+ });
8120
8212
  return;
8121
8213
  }
8122
8214
  for (const r of results) {
@@ -8287,7 +8379,7 @@ async function maybeAutoMigrateLegacy(platform, models) {
8287
8379
  ` detected legacy .one/sync/data/${platform}.db (${dbSize}) \u2014 auto-migrating into memory before sync.
8288
8380
  `
8289
8381
  );
8290
- const { memMigrateCommand: memMigrateCommand3 } = await import("./migrate-U2MZVGU6.js");
8382
+ const { memMigrateCommand: memMigrateCommand3 } = await import("./migrate-UGDDHWGY.js");
8291
8383
  await memMigrateCommand3({ platform, yes: true });
8292
8384
  return;
8293
8385
  }
@@ -8296,7 +8388,7 @@ async function maybeAutoMigrateLegacy(platform, models) {
8296
8388
  initialValue: true
8297
8389
  });
8298
8390
  if (p7.isCancel(shouldMigrate) || !shouldMigrate) return;
8299
- const { memMigrateCommand: memMigrateCommand2 } = await import("./migrate-U2MZVGU6.js");
8391
+ const { memMigrateCommand: memMigrateCommand2 } = await import("./migrate-UGDDHWGY.js");
8300
8392
  await memMigrateCommand2({ platform, yes: true });
8301
8393
  }
8302
8394
  async function syncSuggestSearchableCommand(platformModel, options = {}) {
@@ -8596,7 +8688,7 @@ function registerSyncSubcommands(sync) {
8596
8688
  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
8689
  await syncSuggestSearchableCommand(platformModel, options);
8598
8690
  });
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) => {
8691
+ 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
8692
  await syncRunCommand(platform, {
8601
8693
  models: options.models?.split(",").map((m) => m.trim()),
8602
8694
  since: options.since,
@@ -8604,6 +8696,7 @@ function registerSyncSubcommands(sync) {
8604
8696
  maxPages: options.maxPages ? parseInt(options.maxPages, 10) : void 0,
8605
8697
  dryRun: options.dryRun,
8606
8698
  fullRefresh: options.fullRefresh,
8699
+ reEnrich: options.reEnrich,
8607
8700
  // Commander inverts `--no-memory` / `--no-embed` into options.X === false.
8608
8701
  toMemory: options.memory !== false,
8609
8702
  // Only override when the flag was actually passed — leaving
@@ -10740,6 +10833,7 @@ When a list endpoint returns lightweight records (e.g. just IDs), add an \`enric
10740
10833
  "enrich": {
10741
10834
  "actionId": "<get-message-action-id>",
10742
10835
  "pathVars": { "messageId": "{{id}}" },
10836
+ "invalidateOn": "historyId",
10743
10837
  "concurrency": 3,
10744
10838
  "delayMs": 200
10745
10839
  }
@@ -10747,7 +10841,8 @@ When a list endpoint returns lightweight records (e.g. just IDs), add an \`enric
10747
10841
  \`\`\`
10748
10842
 
10749
10843
  - \`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)
10844
+ - \`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
10845
+ - \`concurrency\` controls parallel detail requests per page (default: 5, lower = safer for rate limits)
10751
10846
  - \`delayMs\` is the pause between batches (default: 200ms)
10752
10847
  - \`resultsPath\` extracts a sub-object from the detail response before merging
10753
10848
  - \`merge: false\` replaces the record entirely instead of deep-merging
@@ -10803,7 +10898,19 @@ So for a row the mirror reports as already enriched, Phase 1 writes **non-author
10803
10898
 
10804
10899
  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
10900
 
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.
10901
+ **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:
10902
+
10903
+ - **\`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.
10904
+
10905
+ \`\`\`json
10906
+ { "enrich": { "actionId": "...", "invalidateOn": "historyId" } }
10907
+ \`\`\`
10908
+
10909
+ 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.
10910
+
10911
+ - **\`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.
10912
+
10913
+ Deleting the mirror (\`.one/sync/data/<platform>.db\`) is no longer necessary.
10807
10914
 
10808
10915
  ## Cross-Platform Identity
10809
10916
 
@@ -10828,6 +10935,10 @@ Two ways to tag a record with a cross-platform identifier (e.g. email), dependin
10828
10935
 
10829
10936
  Each \`path\` supports \`[]\` wildcards (one key per element) and a \`[name=From]\` equality filter (e.g. Gmail \`messages[].payload.headers[name=From].value\`). \`email\`-prefixed values are email-extracted, so display-name headers (\`"Jane <jane@acme.com>"\`) and comma-lists normalize cleanly. Values are lowercased/trimmed/deduped. \`sync test\` previews how many identity keys each record resolves.
10830
10937
 
10938
+ The built-in \`gmail/gmailThreads\` profile collects From/To/**Cc/Bcc**. Gmail only returns a \`Bcc\` header on messages the authenticated user sent \u2014 it is stripped for recipients \u2014 so Bcc keys appear on your own sent threads and nowhere else.
10939
+
10940
+ **Upgrading an existing profile.** \`sync run\` reads only your on-disk profile at \`.one/sync/profiles/<platform>_<model>.json\` \u2014 it never merges built-in updates, so a profile created before a capability shipped will not have it. \`sync run\` now warns when the shipped built-in declares \`identityKeys\`, \`identityKey\`, \`enrich\`, \`dateFilter\`, or \`memory\` that your copy lacks, and \`--agent\` output carries a \`profileDrift\` array with the same information. Run \`one sync init <platform> <model>\` to pick the fields up (it patches, preserving your edits).
10941
+
10831
10942
  Query everything sharing an identity key, grouped by type:
10832
10943
 
10833
10944
  \`\`\`bash
@@ -10909,7 +11020,7 @@ Every \`sync X\` command is also exposed as \`mem sync X\` \u2014 same handlers,
10909
11020
  | \`sync init <plat> <model>\` | Create/patch profile (seeds from built-in, auto-tests) |
10910
11021
  | \`sync test <plat>/<model>\` | Validate profile. \`--show-searchable\` previews embedded text across 5 samples with per-path hit rates |
10911
11022
  | \`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\`) |
11023
+ | \`sync run <platform>\` | Sync data (\`--full-refresh\`, \`--since\`, \`--dry-run\`, \`--no-memory\`, \`--re-enrich\`) |
10913
11024
  | \`sync query <plat>/<model>\` | Query memory with \`--where\` (dotted paths), \`--after/before\` |
10914
11025
  | \`sync schema <plat>/<model>\` | Inspect the JSON structure of synced records (field paths, types, examples) \u2014 run before writing \`--where\` / query paths |
10915
11026
  | \`sync search "<query>"\` | Hybrid FTS + semantic across all synced data |
@@ -3,7 +3,7 @@ import {
3
3
  dotPathToJsonbExpr,
4
4
  memMigrateCommand,
5
5
  reviveStringifiedJson
6
- } from "./chunk-IKYXS7EI.js";
6
+ } from "./chunk-XN4ZWMLX.js";
7
7
  import "./chunk-44CV5IMX.js";
8
8
  import "./chunk-GNSR3NYN.js";
9
9
  import "./chunk-DDCDPVJH.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@withone/cli",
3
- "version": "1.52.3",
3
+ "version": "1.54.0",
4
4
  "description": "CLI for managing One",
5
5
  "type": "module",
6
6
  "files": [
@@ -18,7 +18,8 @@
18
18
  "identityKeys": [
19
19
  { "prefix": "email", "path": "messages[].payload.headers[name=From].value" },
20
20
  { "prefix": "email", "path": "messages[].payload.headers[name=To].value" },
21
- { "prefix": "email", "path": "messages[].payload.headers[name=Cc].value" }
21
+ { "prefix": "email", "path": "messages[].payload.headers[name=Cc].value" },
22
+ { "prefix": "email", "path": "messages[].payload.headers[name=Bcc].value" }
22
23
  ],
23
24
  "enrich": {
24
25
  "actionId": "conn_mod_def::GJ3ok0Eq0R8::AAzgZVLqTg2iBuITKpJLZg",
@@ -311,6 +311,8 @@ Without declared paths, the default walker concatenates every string in the reco
311
311
 
312
312
  **Connections are late-bound** — profiles use `"connection": { "platform": "<name>" }`, not literal `connectionKey` strings. The key is resolved at sync time, so `one add <platform>` (re-auth) doesn't break the profile. For multi-account platforms, add `"tag": "<connection-tag>"` to disambiguate, and create the tagged connection with `one add <platform> --tag <name>`. Don't hardcode connection keys in profiles.
313
313
 
314
+ **Installed profiles do not auto-update.** `sync run` reads only `.one/sync/profiles/<platform>_<model>.json` and never merges the shipped built-in, so a profile created before a capability shipped silently lacks it — a pre-#167 gmail profile writes zero identity keys, forever, with no change in record counts. `sync run` warns when the built-in declares `identityKeys` / `identityKey` / `enrich` / `dateFilter` / `memory` that your copy lacks (agent mode: a `profileDrift` array). Fix with `one sync init <platform> <model>`, which patches rather than overwrites.
315
+
314
316
  **Cross-platform identity on a profile.** Two separate fields, and picking the wrong one silently mangles data:
315
317
 
316
318
  - `"identityKey": "properties.email"` — singular. "This record IS this entity." One dot-path; the value lands in `keys[]` and MERGES records for the same entity across platforms (HubSpot + Attio for one person collapse into a single record).
@@ -318,7 +320,7 @@ Without declared paths, the default walker concatenates every string in the reco
318
320
 
319
321
  Both are queryable with `one --agent mem find-by-key <prefix>:<value>`.
320
322
 
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.
323
+ **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
324
 
323
325
  **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
326