@withone/cli 1.49.0 → 1.51.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/dist/index.js CHANGED
@@ -3,6 +3,7 @@ import {
3
3
  FLOW_SCHEMA,
4
4
  FlowRunner,
5
5
  OneApi,
6
+ PERMISSION_METHODS,
6
7
  TimeoutError,
7
8
  buildActionKnowledgeWithGuidance,
8
9
  buildCacheMeta,
@@ -31,19 +32,22 @@ import {
31
32
  validateActionInput,
32
33
  walkSteps,
33
34
  writeCache
34
- } from "./chunk-2TWFL3CS.js";
35
+ } from "./chunk-D6W756DN.js";
35
36
  import {
36
37
  memSqlCommand
37
- } from "./chunk-4WWK3GO5.js";
38
+ } from "./chunk-UV4YYDF3.js";
38
39
  import {
40
+ collectIdentityKeys,
39
41
  countRecords,
40
42
  deleteDatabase,
41
43
  deleteRecords,
42
44
  dropTable,
43
45
  ensureTable,
44
46
  evolveSchema,
47
+ extractSearchableFromPaths,
45
48
  generateTemplate,
46
49
  getDatabaseSize,
50
+ getSearchablePaths,
47
51
  isSqliteAvailable,
48
52
  listBuiltinProfiles,
49
53
  listProfiles,
@@ -53,13 +57,15 @@ import {
53
57
  openDatabase,
54
58
  readProfile,
55
59
  rebuildFtsIndex,
60
+ resolveEntityIdentity,
56
61
  resolveProfileConnectionKey,
57
62
  sanitizeTableName,
58
63
  tableExists,
59
64
  upsertRecords,
60
65
  writeDraftProfile,
66
+ writePageToMemory,
61
67
  writeProfile
62
- } from "./chunk-GXOIR3GF.js";
68
+ } from "./chunk-ZOJW6IMU.js";
63
69
  import {
64
70
  getByDotPath
65
71
  } from "./chunk-44CV5IMX.js";
@@ -92,9 +98,8 @@ import {
92
98
  getBackendPlugin,
93
99
  listBackendPlugins,
94
100
  loadBackendFromConfig,
95
- updateRecord,
96
- upsertRecord
97
- } from "./chunk-7RV7T5PX.js";
101
+ updateRecord
102
+ } from "./chunk-Z4KAQGIG.js";
98
103
  import {
99
104
  DEFAULT_MEMORY_CONFIG,
100
105
  defaultSearchableText,
@@ -1032,25 +1037,25 @@ async function loginCommand() {
1032
1037
  let targetScope = "global";
1033
1038
  const existingKey = getApiKey();
1034
1039
  if (existingKey) {
1035
- const pc15 = (await import("picocolors")).default;
1040
+ const pc16 = (await import("picocolors")).default;
1036
1041
  const resolved2 = resolveConfig();
1037
1042
  const whoami2 = resolved2.config?.whoami;
1038
1043
  const env2 = getEnvFromApiKey(existingKey);
1039
- const envLabel2 = env2 === "test" ? pc15.yellow("test") : pc15.green("live");
1040
- const currentScope = resolved2.scope === "project" ? pc15.cyan("local config") : pc15.magenta("global config");
1044
+ const envLabel2 = env2 === "test" ? pc16.yellow("test") : pc16.green("live");
1045
+ const currentScope = resolved2.scope === "project" ? pc16.cyan("local config") : pc16.magenta("global config");
1041
1046
  const lines = ["You are already logged in.", ""];
1042
1047
  if (whoami2) {
1043
1048
  const contextParts2 = [];
1044
1049
  if (whoami2.organization) contextParts2.push(whoami2.organization.name);
1045
1050
  if (whoami2.project) contextParts2.push(whoami2.project.name);
1046
1051
  const scopeDisplay2 = contextParts2.length > 0 ? contextParts2.join(" / ") : "Personal";
1047
- lines.push(`${pc15.bold(scopeDisplay2)} ${pc15.dim("\xB7")} ${envLabel2}`);
1048
- lines.push(`${whoami2.user.name} ${pc15.dim(`(${whoami2.user.email})`)}`);
1049
- if (whoami2.organization) lines.push(`${pc15.dim("Org:")} ${whoami2.organization.name}`);
1050
- if (whoami2.project) lines.push(`${pc15.dim("Project:")} ${whoami2.project.name}`);
1052
+ lines.push(`${pc16.bold(scopeDisplay2)} ${pc16.dim("\xB7")} ${envLabel2}`);
1053
+ lines.push(`${whoami2.user.name} ${pc16.dim(`(${whoami2.user.email})`)}`);
1054
+ if (whoami2.organization) lines.push(`${pc16.dim("Org:")} ${whoami2.organization.name}`);
1055
+ if (whoami2.project) lines.push(`${pc16.dim("Project:")} ${whoami2.project.name}`);
1051
1056
  }
1052
1057
  lines.push("");
1053
- lines.push(`${pc15.dim("Stored in")} ${currentScope}`);
1058
+ lines.push(`${pc16.dim("Stored in")} ${currentScope}`);
1054
1059
  p2.note(lines.join("\n"));
1055
1060
  const scopeChoice = await p2.select({
1056
1061
  message: "Where would you like to log in?",
@@ -1073,40 +1078,40 @@ async function loginCommand() {
1073
1078
  if (resolved.config) {
1074
1079
  writeConfig({ ...resolved.config, whoami }, targetScope);
1075
1080
  }
1076
- const pc14 = (await import("picocolors")).default;
1081
+ const pc15 = (await import("picocolors")).default;
1077
1082
  const env = getEnvFromApiKey(apiKey);
1078
1083
  const contextParts = [];
1079
1084
  if (whoami.organization) contextParts.push(whoami.organization.name);
1080
1085
  if (whoami.project) contextParts.push(whoami.project.name);
1081
1086
  const scopeDisplay = contextParts.length > 0 ? contextParts.join(" / ") : "Personal";
1082
- const envLabel = env === "test" ? pc14.yellow("test") : pc14.green("live");
1083
- const configLabel = targetScope === "project" ? pc14.cyan("local config") : pc14.magenta("global config");
1087
+ const envLabel = env === "test" ? pc15.yellow("test") : pc15.green("live");
1088
+ const configLabel = targetScope === "project" ? pc15.cyan("local config") : pc15.magenta("global config");
1084
1089
  const infoLines = [
1085
- `${pc14.bold(scopeDisplay)} ${pc14.dim("\xB7")} ${envLabel}`,
1086
- `${whoami.user.name} ${pc14.dim(`(${whoami.user.email})`)}`
1090
+ `${pc15.bold(scopeDisplay)} ${pc15.dim("\xB7")} ${envLabel}`,
1091
+ `${whoami.user.name} ${pc15.dim(`(${whoami.user.email})`)}`
1087
1092
  ];
1088
- if (whoami.organization) infoLines.push(`${pc14.dim("Org:")} ${whoami.organization.name}`);
1089
- if (whoami.project) infoLines.push(`${pc14.dim("Project:")} ${whoami.project.name}`);
1093
+ if (whoami.organization) infoLines.push(`${pc15.dim("Org:")} ${whoami.organization.name}`);
1094
+ if (whoami.project) infoLines.push(`${pc15.dim("Project:")} ${whoami.project.name}`);
1090
1095
  infoLines.push("");
1091
- infoLines.push(`${pc14.dim("Stored in")} ${configLabel}`);
1096
+ infoLines.push(`${pc15.dim("Stored in")} ${configLabel}`);
1092
1097
  p2.note(infoLines.join("\n"), "Logged in");
1093
1098
  console.log();
1094
- console.log(` ${pc14.dim("Next steps:")}`);
1095
- console.log(` ${pc14.cyan("one add <platform>")} ${pc14.dim("\u2014")} Connect a platform (e.g. gmail, slack, stripe)`);
1099
+ console.log(` ${pc15.dim("Next steps:")}`);
1100
+ console.log(` ${pc15.cyan("one add <platform>")} ${pc15.dim("\u2014")} Connect a platform (e.g. gmail, slack, stripe)`);
1096
1101
  console.log();
1097
- console.log(pc14.bold(" Copy-paste this to your AI agent:"));
1102
+ console.log(pc15.bold(" Copy-paste this to your AI agent:"));
1098
1103
  console.log();
1099
- console.log(pc14.cyan(" \u250C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500"));
1100
- console.log(pc14.cyan(" \u2502"));
1101
- console.log(pc14.cyan(" \u2502 The One CLI (`one`) is now installed on this machine. It"));
1102
- console.log(pc14.cyan(" \u2502 lets you interact with 400+ third-party platforms (Gmail,"));
1103
- console.log(pc14.cyan(" \u2502 Slack, Shopify, HubSpot, Stripe, GitHub, etc.) through"));
1104
- console.log(pc14.cyan(" \u2502 their APIs. Whenever you need to interact with any"));
1105
- console.log(pc14.cyan(" \u2502 third-party platform or external service, use the `one`"));
1106
- console.log(pc14.cyan(" \u2502 skill. Please save this to your project instructions or"));
1107
- console.log(pc14.cyan(" \u2502 memory so you remember it in future conversations."));
1108
- console.log(pc14.cyan(" \u2502"));
1109
- console.log(pc14.cyan(" \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500"));
1104
+ console.log(pc15.cyan(" \u250C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500"));
1105
+ console.log(pc15.cyan(" \u2502"));
1106
+ console.log(pc15.cyan(" \u2502 The One CLI (`one`) is now installed on this machine. It"));
1107
+ console.log(pc15.cyan(" \u2502 lets you interact with 400+ third-party platforms (Gmail,"));
1108
+ console.log(pc15.cyan(" \u2502 Slack, Shopify, HubSpot, Stripe, GitHub, etc.) through"));
1109
+ console.log(pc15.cyan(" \u2502 their APIs. Whenever you need to interact with any"));
1110
+ console.log(pc15.cyan(" \u2502 third-party platform or external service, use the `one`"));
1111
+ console.log(pc15.cyan(" \u2502 skill. Please save this to your project instructions or"));
1112
+ console.log(pc15.cyan(" \u2502 memory so you remember it in future conversations."));
1113
+ console.log(pc15.cyan(" \u2502"));
1114
+ console.log(pc15.cyan(" \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500"));
1110
1115
  console.log();
1111
1116
  p2.outro("Happy building!");
1112
1117
  }
@@ -1942,6 +1947,54 @@ function maskApiKey(key) {
1942
1947
  import * as p4 from "@clack/prompts";
1943
1948
  import pc4 from "picocolors";
1944
1949
 
1950
+ // src/lib/access.ts
1951
+ async function resolveAllowedActions(api, actionIds) {
1952
+ if (actionIds.includes("*")) return [];
1953
+ const resolved = await Promise.all(
1954
+ actionIds.map(async (actionId) => {
1955
+ try {
1956
+ const { details } = await resolveActionDetails(api, actionId, { warn: () => {
1957
+ } });
1958
+ if (!details.connectionPlatform) return null;
1959
+ return {
1960
+ actionId,
1961
+ title: details.title,
1962
+ method: details.method,
1963
+ platform: details.connectionPlatform
1964
+ };
1965
+ } catch {
1966
+ return null;
1967
+ }
1968
+ })
1969
+ );
1970
+ return resolved.filter((a) => a !== null);
1971
+ }
1972
+ function computeConnectionAccess(platform, permissions, allowedActionIds, grantedActions) {
1973
+ if (!allowedActionIds.includes("*")) {
1974
+ const actions2 = grantedActions.filter((a) => a.platform === platform).map(({ actionId, title, method }) => ({ actionId, title, method }));
1975
+ return { policy: "actions", actions: actions2 };
1976
+ }
1977
+ const methods = PERMISSION_METHODS[permissions];
1978
+ if (methods !== null) {
1979
+ return { policy: "methods", methods };
1980
+ }
1981
+ return { policy: "full" };
1982
+ }
1983
+ function formatAccess(access, maxActions = 2) {
1984
+ switch (access.policy) {
1985
+ case "full":
1986
+ return "full";
1987
+ case "methods":
1988
+ return access.methods.join(", ");
1989
+ case "actions": {
1990
+ if (access.actions.length === 0) return "none";
1991
+ const shown = access.actions.slice(0, maxActions).map((a) => `${a.title} (${a.method})`);
1992
+ const rest = access.actions.length - shown.length;
1993
+ return rest > 0 ? `${shown.join(", ")} +${rest} more` : shown.join(", ");
1994
+ }
1995
+ }
1996
+ }
1997
+
1945
1998
  // src/lib/platforms.ts
1946
1999
  function findPlatform(platforms, query) {
1947
2000
  const normalizedQuery = query.toLowerCase().trim();
@@ -2155,8 +2208,16 @@ async function connectionListCommand(options) {
2155
2208
  try {
2156
2209
  const allConnections = await api.listConnections();
2157
2210
  const ac = getAccessControlFromAllSources();
2211
+ const permissions = ac.permissions || "admin";
2158
2212
  const allowedKeys = ac.connectionKeys || ["*"];
2213
+ const actionIds = ac.actionIds || ["*"];
2214
+ const knowledgeOnly = ac.knowledgeAgent || false;
2159
2215
  const accessFiltered = allowedKeys.includes("*") ? allConnections : allConnections.filter((conn) => allowedKeys.includes(conn.key));
2216
+ const resolvedActions = await resolveAllowedActions(api, actionIds);
2217
+ const unresolvedActionIds = actionIds.includes("*") ? [] : actionIds.filter((id) => !resolvedActions.some((a) => a.actionId === id));
2218
+ const grantedActions = resolvedActions.filter((a) => isMethodAllowed(a.method, permissions));
2219
+ const accessFor = (platform) => computeConnectionAccess(platform, permissions, actionIds, grantedActions);
2220
+ const hintText = accessHint(permissions, actionIds, knowledgeOnly);
2160
2221
  const searchQuery = options?.search?.toLowerCase();
2161
2222
  const filtered = searchQuery ? accessFiltered.filter((conn) => conn.platform.toLowerCase().includes(searchQuery)) : accessFiltered;
2162
2223
  const limitArg = options?.limit ? parseInt(options.limit, 10) : void 0;
@@ -2172,8 +2233,12 @@ async function connectionListCommand(options) {
2172
2233
  state: conn.state,
2173
2234
  key: conn.key,
2174
2235
  ...conn.name && { name: conn.name },
2175
- ...conn.tags?.length && { tags: conn.tags }
2236
+ ...conn.tags?.length && { tags: conn.tags },
2237
+ access: accessFor(conn.platform)
2176
2238
  })),
2239
+ ...knowledgeOnly && { knowledgeOnly: true },
2240
+ ...unresolvedActionIds.length > 0 && { unresolvedActionIds },
2241
+ ...hintText && { accessHint: hintText },
2177
2242
  ...limited.length < filtered.length && {
2178
2243
  hint: `Showing ${limited.length} of ${filtered.length} connections. Use --search <query> to filter by platform or --limit <n> to see more.`
2179
2244
  }
@@ -2206,20 +2271,34 @@ Add one with: ${pc4.cyan("one connection add gmail")}`,
2206
2271
  platform: conn.platform,
2207
2272
  state: conn.state,
2208
2273
  key: conn.key,
2209
- tags: conn.tags?.length ? conn.tags.join(", ") : ""
2274
+ tags: conn.tags?.length ? conn.tags.join(", ") : "",
2275
+ access: formatAccess(accessFor(conn.platform))
2210
2276
  }));
2211
2277
  const hasTags = rows.some((r) => r.tags);
2278
+ const hasScopedAccess = rows.some((r) => r.access !== "full");
2212
2279
  printTable(
2213
2280
  [
2214
2281
  { key: "status", label: "" },
2215
2282
  { key: "platform", label: "Platform" },
2216
2283
  { key: "state", label: "Status" },
2217
2284
  { key: "key", label: "Connection Key", color: pc4.dim },
2218
- ...hasTags ? [{ key: "tags", label: "Tags", color: pc4.dim }] : []
2285
+ ...hasTags ? [{ key: "tags", label: "Tags", color: pc4.dim }] : [],
2286
+ ...hasScopedAccess ? [{ key: "access", label: "Access", color: pc4.yellow }] : []
2219
2287
  ],
2220
2288
  rows
2221
2289
  );
2222
2290
  console.log();
2291
+ if (hintText) {
2292
+ const lines = [hintText];
2293
+ if (unresolvedActionIds.length > 0) {
2294
+ lines.push(
2295
+ `Could not resolve ${unresolvedActionIds.length} allowlisted action id(s): ${unresolvedActionIds.join(", ")}`
2296
+ );
2297
+ }
2298
+ p4.note(`${wrapText(lines.join("\n"))}
2299
+
2300
+ Change it with: ${pc4.cyan("one config")}`, "Access");
2301
+ }
2223
2302
  if (displayed.length < filtered.length) {
2224
2303
  p4.note(
2225
2304
  `Showing ${displayed.length} of ${filtered.length}. Increase --limit or run without it to see all.`,
@@ -2292,6 +2371,37 @@ async function connectionDeleteCommand(connectionKey, options) {
2292
2371
  error(`Error: ${error2 instanceof Error ? error2.message : "Unknown error"}`);
2293
2372
  }
2294
2373
  }
2374
+ function accessHint(permissions, actionIds, knowledgeOnly) {
2375
+ const parts = [];
2376
+ if (!actionIds.includes("*")) {
2377
+ parts.push(
2378
+ "Action-scoped: each connection's `access.actions` are the only actions you may run \u2014 use them directly, no `actions search` needed."
2379
+ );
2380
+ } else if (permissions !== "admin") {
2381
+ const methods = PERMISSION_METHODS[permissions]?.join(", ") ?? "";
2382
+ parts.push(`Permission level "${permissions}": only ${methods} actions will execute.`);
2383
+ }
2384
+ if (knowledgeOnly) {
2385
+ parts.push("Knowledge-only mode: `actions execute` is disabled \u2014 read knowledge and write code instead.");
2386
+ }
2387
+ return parts.length > 0 ? parts.join(" ") : null;
2388
+ }
2389
+ function wrapText(text5, width = 72) {
2390
+ return text5.split("\n").map((paragraph) => {
2391
+ const lines = [];
2392
+ let line = "";
2393
+ for (const word of paragraph.split(/\s+/)) {
2394
+ if (line && line.length + 1 + word.length > width) {
2395
+ lines.push(line);
2396
+ line = word;
2397
+ } else {
2398
+ line = line ? `${line} ${word}` : word;
2399
+ }
2400
+ }
2401
+ if (line) lines.push(line);
2402
+ return lines.join("\n");
2403
+ }).join("\n");
2404
+ }
2295
2405
  function getStatusIndicator(state) {
2296
2406
  switch (state) {
2297
2407
  case "operational":
@@ -5374,152 +5484,6 @@ function runShellHook(command, events) {
5374
5484
  });
5375
5485
  }
5376
5486
 
5377
- // src/lib/memory/sync/mem-writer.ts
5378
- function resolveWildcardPath(root, path12) {
5379
- const segments = path12.split("[]");
5380
- if (segments.length === 1) {
5381
- return [getByDotPath(root, path12)];
5382
- }
5383
- const recurse = (value, idx) => {
5384
- if (value === null || value === void 0) return [];
5385
- const segment = segments[idx];
5386
- const head = segment.startsWith(".") ? segment.slice(1) : segment;
5387
- if (idx === segments.length - 1) {
5388
- if (!Array.isArray(value)) return [];
5389
- if (head === "") return value;
5390
- return value.map((el) => getByDotPath(el, head));
5391
- }
5392
- if (!Array.isArray(value)) return [];
5393
- const results = [];
5394
- for (const el of value) {
5395
- const next = head === "" ? el : getByDotPath(el, head);
5396
- results.push(...recurse(next, idx + 1));
5397
- }
5398
- return results;
5399
- };
5400
- const firstHead = segments[0];
5401
- const firstValue = firstHead === "" ? root : getByDotPath(root, firstHead);
5402
- return recurse(firstValue, 1);
5403
- }
5404
- function extractSearchableFromPaths(record, paths) {
5405
- const parts = [];
5406
- const perPath = [];
5407
- for (const path12 of paths) {
5408
- const values = resolveWildcardPath(record, path12);
5409
- const collected = [];
5410
- const absorb = (v) => {
5411
- if (v === null || v === void 0) return;
5412
- if (typeof v === "string") {
5413
- const t = v.trim();
5414
- if (t) collected.push(t);
5415
- } else if (typeof v === "number" || typeof v === "boolean") {
5416
- collected.push(String(v));
5417
- } else if (Array.isArray(v)) {
5418
- for (const inner of v) absorb(inner);
5419
- }
5420
- };
5421
- for (const v of values) absorb(v);
5422
- if (collected.length > 0) {
5423
- parts.push(...collected);
5424
- perPath.push({ path: path12, found: true, sample: collected.join(" ").slice(0, 80) });
5425
- } else {
5426
- perPath.push({ path: path12, found: false, sample: "" });
5427
- }
5428
- }
5429
- const text5 = parts.join(" ").replace(/\s+/g, " ").trim();
5430
- return { text: text5, paths: perPath };
5431
- }
5432
- function getSearchablePaths(profile) {
5433
- const paths = profile.memory?.searchable;
5434
- if (!paths || !Array.isArray(paths) || paths.length === 0) return void 0;
5435
- return paths;
5436
- }
5437
- async function writePageToMemory(profile, records, opts = {}) {
5438
- const report = {
5439
- attempted: 0,
5440
- inserted: 0,
5441
- updated: 0,
5442
- skipped: 0,
5443
- sourceKeysSeen: [],
5444
- inserts: [],
5445
- updates: []
5446
- };
5447
- const type = `${profile.platform}/${profile.model}`;
5448
- const identityKey = profile.identityKey;
5449
- const embedFlag = opts.embedOverride ?? deriveEmbedFlag(profile);
5450
- const searchablePaths = getSearchablePaths(profile);
5451
- for (const record of records) {
5452
- report.attempted++;
5453
- const externalId = getByDotPath(record, profile.idField);
5454
- if (externalId === void 0 || externalId === null || externalId === "") {
5455
- report.skipped++;
5456
- continue;
5457
- }
5458
- if (typeof externalId === "object") {
5459
- report.skipped++;
5460
- continue;
5461
- }
5462
- const sourceKey = `${type}:${String(externalId)}`;
5463
- const keys = [sourceKey];
5464
- if (identityKey) {
5465
- const raw = getByDotPath(record, identityKey);
5466
- if (raw !== null && raw !== void 0 && raw !== "") {
5467
- keys.push(`${deriveIdentityPrefix(identityKey)}:${String(raw).toLowerCase().trim()}`);
5468
- }
5469
- }
5470
- const data = { ...record };
5471
- for (const k of Object.keys(data)) {
5472
- if (k.startsWith("_")) delete data[k];
5473
- }
5474
- const searchable_text = searchablePaths ? extractSearchableFromPaths(record, searchablePaths).text : void 0;
5475
- try {
5476
- const res = await upsertRecord(
5477
- {
5478
- type,
5479
- data,
5480
- keys,
5481
- searchable_text,
5482
- sources: {
5483
- [sourceKey]: {
5484
- last_synced_at: (/* @__PURE__ */ new Date()).toISOString(),
5485
- metadata: { source: profile.platform }
5486
- }
5487
- },
5488
- tags: ["synced", profile.platform],
5489
- embed: embedFlag
5490
- },
5491
- // Sync must REPLACE: if a field vanished at the source (phone
5492
- // number removed, Gmail thread archived) it must also vanish
5493
- // from memory. The default merge behaviour is correct for
5494
- // interactive `mem add` / `mem update`, wrong for sync.
5495
- { embed: embedFlag, replace: true }
5496
- );
5497
- report.sourceKeysSeen.push(sourceKey);
5498
- if (res.action === "inserted") {
5499
- report.inserted++;
5500
- if (opts.capturePerAction) report.inserts.push(record);
5501
- } else {
5502
- report.updated++;
5503
- if (opts.capturePerAction) report.updates.push(record);
5504
- }
5505
- } catch {
5506
- report.skipped++;
5507
- }
5508
- }
5509
- return report;
5510
- }
5511
- function deriveEmbedFlag(profile) {
5512
- if (profile.memory && typeof profile.memory.embed === "boolean") return profile.memory.embed;
5513
- return false;
5514
- }
5515
- function deriveIdentityPrefix(dotPath) {
5516
- const lower = dotPath.toLowerCase();
5517
- if (lower.includes("email")) return "email";
5518
- if (lower.includes("phone")) return "phone";
5519
- if (lower.includes("domain")) return "domain";
5520
- return "id";
5521
- }
5522
-
5523
5487
  // src/lib/memory/sync/transform.ts
5524
5488
  import { spawn as spawn3 } from "child_process";
5525
5489
  var TRANSFORM_TIMEOUT_MS = 6e4;
@@ -5683,6 +5647,32 @@ function pickFields(obj, fields) {
5683
5647
  }
5684
5648
  return result;
5685
5649
  }
5650
+ function findEnrichedIds(db, model, idField, config2, records, tableCreated) {
5651
+ const enriched = /* @__PURE__ */ new Set();
5652
+ if (!tableCreated || records.length === 0) return enriched;
5653
+ const tsField = config2.timestampField ?? "_enriched_at";
5654
+ const safeTable = model.replace(/[^a-zA-Z0-9_]/g, "_");
5655
+ const safeIdField = idField.replace(/"/g, '""');
5656
+ const safeTsField = tsField.replace(/"/g, '""');
5657
+ try {
5658
+ const cols = db.prepare(`PRAGMA table_info("${safeTable}")`).all().map((c) => c.name);
5659
+ if (!cols.includes(tsField) || !cols.includes(idField)) return enriched;
5660
+ const ids = records.map((r) => r[idField]).filter((id) => typeof id === "string" || typeof id === "number");
5661
+ if (ids.length === 0) return enriched;
5662
+ const CHUNK = 500;
5663
+ for (let i = 0; i < ids.length; i += CHUNK) {
5664
+ const chunk = ids.slice(i, i + CHUNK);
5665
+ const placeholders = chunk.map(() => "?").join(",");
5666
+ const rows = db.prepare(
5667
+ `SELECT "${safeIdField}" AS id FROM "${safeTable}" WHERE "${safeTsField}" IS NOT NULL AND "${safeIdField}" IN (${placeholders})`
5668
+ ).all(...chunk);
5669
+ for (const row of rows) enriched.add(String(row.id));
5670
+ }
5671
+ } catch {
5672
+ return /* @__PURE__ */ new Set();
5673
+ }
5674
+ return enriched;
5675
+ }
5686
5676
  async function enrichPhase(api, db, config2, model, idField, connectionKey, platform, ctx = {}) {
5687
5677
  const startTime = Date.now();
5688
5678
  const tsField = config2.timestampField ?? "_enriched_at";
@@ -5782,9 +5772,9 @@ async function enrichPhase(api, db, config2, model, idField, connectionKey, plat
5782
5772
  stripExcludedFields(merged, ctx.exclude);
5783
5773
  }
5784
5774
  if (ctx.identityKey) {
5785
- const raw = getByDotPath2(merged, ctx.identityKey);
5786
- if (raw !== null && raw !== void 0) {
5787
- merged._identity = String(raw).toLowerCase().trim();
5775
+ const identity = resolveEntityIdentity(merged, ctx.identityKey);
5776
+ if (identity?.value) {
5777
+ merged._identity = identity.value;
5788
5778
  }
5789
5779
  }
5790
5780
  const setClauses = [];
@@ -6063,7 +6053,7 @@ async function syncModel(api, profile, options) {
6063
6053
  updateModelState(platform, model, { status: "failed", pagesProcessed, lastCursor }),
6064
6054
  (async () => {
6065
6055
  try {
6066
- const { getBackend: getBackend2 } = await import("./runtime-2PRVL2NO.js");
6056
+ const { getBackend: getBackend2 } = await import("./runtime-HDZCBTWH.js");
6067
6057
  const backend = await getBackend2();
6068
6058
  await Promise.race([
6069
6059
  backend.close(),
@@ -6094,6 +6084,7 @@ async function syncModel(api, profile, options) {
6094
6084
  let enrichedTotal = 0;
6095
6085
  let enrichSkipped = 0;
6096
6086
  let enrichRateLimited = 0;
6087
+ let memPreserved = 0;
6097
6088
  try {
6098
6089
  if (needsSqlite) db = await openDatabase(platform);
6099
6090
  let sinceDate = null;
@@ -6263,9 +6254,9 @@ async function syncModel(api, profile, options) {
6263
6254
  }
6264
6255
  if (profile.identityKey) {
6265
6256
  for (const record of records) {
6266
- const raw = getByDotPath(record, profile.identityKey);
6267
- if (raw !== null && raw !== void 0) {
6268
- record._identity = String(raw).toLowerCase().trim();
6257
+ const identity = resolveEntityIdentity(record, profile.identityKey);
6258
+ if (identity?.value) {
6259
+ record._identity = identity.value;
6269
6260
  }
6270
6261
  }
6271
6262
  }
@@ -6298,6 +6289,14 @@ async function syncModel(api, profile, options) {
6298
6289
  let pageReport = null;
6299
6290
  if (options.toMemory !== false) {
6300
6291
  try {
6292
+ const alreadyEnrichedIds = profile.enrich && db ? findEnrichedIds(
6293
+ db,
6294
+ model,
6295
+ profile.idField,
6296
+ profile.enrich,
6297
+ records,
6298
+ tableCreated
6299
+ ) : void 0;
6301
6300
  const report = await writePageToMemory(
6302
6301
  profile,
6303
6302
  records,
@@ -6306,10 +6305,12 @@ async function syncModel(api, profile, options) {
6306
6305
  // --embed / --no-embed on `sync run` threads through as a
6307
6306
  // per-run override so you can backfill embeddings on a
6308
6307
  // first-synced-without-embed platform in one shot.
6309
- embedOverride: options.embed
6308
+ embedOverride: options.embed,
6309
+ alreadyEnrichedIds
6310
6310
  }
6311
6311
  );
6312
6312
  pageReport = report;
6313
+ memPreserved += report.preserved;
6313
6314
  for (const key of report.sourceKeysSeen) seenSourceKeys.add(key);
6314
6315
  if (hasHooks && !db) {
6315
6316
  inserts = report.inserts;
@@ -6418,7 +6419,7 @@ async function syncModel(api, profile, options) {
6418
6419
  db.exec(`DROP TABLE IF EXISTS _seen_ids`);
6419
6420
  }
6420
6421
  if (options.toMemory !== false) {
6421
- const backend = await (await import("./runtime-2PRVL2NO.js")).getBackend();
6422
+ const backend = await (await import("./runtime-HDZCBTWH.js")).getBackend();
6422
6423
  const type = `${platform}/${model}`;
6423
6424
  const existing = await backend.listKeysByType(type);
6424
6425
  const sourcePrefix = `${type}:`;
@@ -6450,6 +6451,12 @@ async function syncModel(api, profile, options) {
6450
6451
  process.stderr.write(`Syncing ${platform}/${model}... page ${pagesProcessed} (${totalRecords} records) done
6451
6452
  `);
6452
6453
  }
6454
+ if (!isAgentMode() && memPreserved > 0) {
6455
+ process.stderr.write(
6456
+ ` Kept ${memPreserved} already-enriched record(s) in memory (the list shape would have replaced the enriched payload).
6457
+ `
6458
+ );
6459
+ }
6453
6460
  let enrichResult = null;
6454
6461
  if (profile.enrich && db && tableCreated && !options.dryRun) {
6455
6462
  enrichResult = await enrichPhase(
@@ -6498,7 +6505,7 @@ async function syncModel(api, profile, options) {
6498
6505
  let statusCounts;
6499
6506
  if (options.toMemory !== false) {
6500
6507
  try {
6501
- const backend = await (await import("./runtime-2PRVL2NO.js")).getBackend();
6508
+ const backend = await (await import("./runtime-HDZCBTWH.js")).getBackend();
6502
6509
  const typeName = `${platform}/${model}`;
6503
6510
  const [active, archived] = await Promise.all([
6504
6511
  backend.count(typeName, { status: "active" }),
@@ -6529,7 +6536,12 @@ async function syncModel(api, profile, options) {
6529
6536
  ...reconcileSkipped ? { reconcileSkipped } : {},
6530
6537
  ...statusCounts ? { statusCounts } : {},
6531
6538
  ...hasHooks ? { hooksInserted, hooksUpdated } : {},
6532
- ...profile.enrich ? { enriched: enrichedTotal, enrichSkipped, enrichRateLimited } : {}
6539
+ // `memPreserved` counts already-enriched rows this run wrote
6540
+ // non-authoritatively (enriched payload kept, list fields refreshed).
6541
+ // Reported to agents too — the human path only gets a stderr line, and
6542
+ // an agent seeing `recordsSynced: 200, enriched: 0` otherwise has no way
6543
+ // to tell "nothing needed enriching" from "enrich is broken".
6544
+ ...profile.enrich ? { enriched: enrichedTotal, enrichSkipped, enrichRateLimited, ...memPreserved > 0 ? { memPreserved } : {} } : {}
6533
6545
  };
6534
6546
  } catch (err) {
6535
6547
  const failedCount = db ? countRecords(db, model) : existingState?.totalRecords ?? 0;
@@ -6569,6 +6581,33 @@ async function syncModel(api, profile, options) {
6569
6581
 
6570
6582
  // src/lib/memory/sync/test.ts
6571
6583
  var SEARCHABLE_SAMPLE_SIZE = 5;
6584
+ function buildIdentityKeysPreview(samples, profile) {
6585
+ if (!profile.identityKey && !(profile.identityKeys && profile.identityKeys.length > 0)) return void 0;
6586
+ const perRecord = [];
6587
+ const sampleKeys = /* @__PURE__ */ new Set();
6588
+ const fanOutValues = /* @__PURE__ */ new Set();
6589
+ let fanOutCount = 0;
6590
+ for (const rec of samples) {
6591
+ const keys = collectIdentityKeys(rec, profile);
6592
+ perRecord.push(keys.length);
6593
+ for (const k of keys) {
6594
+ if (sampleKeys.size < 8) sampleKeys.add(k);
6595
+ }
6596
+ const identity = resolveEntityIdentity(rec, profile.identityKey);
6597
+ if (identity && identity.values.length > 1) {
6598
+ fanOutCount++;
6599
+ for (const v of identity.values) {
6600
+ if (fanOutValues.size < 4) fanOutValues.add(`${identity.prefix}:${v}`);
6601
+ }
6602
+ }
6603
+ }
6604
+ return {
6605
+ perRecord,
6606
+ sampleKeys: [...sampleKeys],
6607
+ ...fanOutCount > 0 ? { entityFanOut: { count: fanOutCount, sampleValues: [...fanOutValues] } } : {},
6608
+ ...profile.enrich && (profile.identityKeys?.length ?? 0) > 0 ? { resolvesAfterEnrich: true } : {}
6609
+ };
6610
+ }
6572
6611
  function detectColumnType2(value) {
6573
6612
  if (value === null || value === void 0) return "TEXT";
6574
6613
  if (typeof value === "string") return "TEXT";
@@ -6775,6 +6814,7 @@ async function testSyncProfile(api, profile) {
6775
6814
  }));
6776
6815
  report.sample = first;
6777
6816
  report.samples = records.slice(0, SEARCHABLE_SAMPLE_SIZE);
6817
+ report.identityKeysPreview = buildIdentityKeysPreview(report.samples, profile);
6778
6818
  report.ok = checks.every((c) => c.ok);
6779
6819
  return report;
6780
6820
  }
@@ -7974,6 +8014,29 @@ async function syncTestCommand(platformModel, options = {}) {
7974
8014
  console.log(pc9.dim(` ... and ${report.detectedColumns.length - 20} more`));
7975
8015
  }
7976
8016
  }
8017
+ if (report.identityKeysPreview) {
8018
+ const { perRecord, sampleKeys, entityFanOut, resolvesAfterEnrich } = report.identityKeysPreview;
8019
+ const total = perRecord.reduce((a, b) => a + b, 0);
8020
+ const min = perRecord.length ? Math.min(...perRecord) : 0;
8021
+ const max = perRecord.length ? Math.max(...perRecord) : 0;
8022
+ const mark = total === 0 ? pc9.yellow("~") : pc9.green("\u2713");
8023
+ console.log(`
8024
+ ${pc9.bold("Merge + identity keys")} ${pc9.dim(`(cross-platform \u2014 #128)`)}`);
8025
+ console.log(` ${mark} ${perRecord.length} sample${perRecord.length === 1 ? "" : "s"}, ${min}\u2013${max} key${max === 1 ? "" : "s"} per record`);
8026
+ if (sampleKeys.length > 0) {
8027
+ console.log(` ${pc9.dim("e.g.")} ${sampleKeys.slice(0, 8).map((k) => pc9.cyan(k)).join(", ")}`);
8028
+ } else if (resolvesAfterEnrich) {
8029
+ console.log(` ${pc9.dim("note:")} 0 on these list-shape samples \u2014 this profile enriches, and its identityKeys paths resolve in the enrich phase.`);
8030
+ } else {
8031
+ console.log(` ${pc9.yellow("note:")} no identity keys resolved on these samples \u2014 check the identityKey/identityKeys paths.`);
8032
+ }
8033
+ if (entityFanOut) {
8034
+ console.log(` ${pc9.yellow("warn:")} identityKey resolved to MULTIPLE values on ${entityFanOut.count} of ${perRecord.length} samples \u2014 those records get NO merge key.`);
8035
+ console.log(` ${pc9.dim("saw:")} ${entityFanOut.sampleValues.map((v) => pc9.cyan(v)).join(", ")}`);
8036
+ console.log(` ${pc9.dim('A singular identityKey means "this record IS this entity", so a fan-out has no safe answer.')}`);
8037
+ console.log(` ${pc9.dim("Point it at a single-valued path, or move it to identityKeys[] for participant associations.")}`);
8038
+ }
8039
+ }
7977
8040
  if (searchablePreview) {
7978
8041
  console.log(`
7979
8042
  ${pc9.bold("Searchable preview")} ${pc9.dim(`(${searchablePreview.mode}, ${searchablePreview.sampledRecords} sample${searchablePreview.sampledRecords === 1 ? "" : "s"})`)}`);
@@ -8131,7 +8194,7 @@ ${result.total} results`);
8131
8194
  }
8132
8195
  }
8133
8196
  async function syncSqlCommand(platformModel, sql) {
8134
- const { syncSqlCommand: runSyncSql } = await import("./sql-HG7R2JML.js");
8197
+ const { syncSqlCommand: runSyncSql } = await import("./sql-AZDCMY7G.js");
8135
8198
  await runSyncSql(platformModel, sql);
8136
8199
  }
8137
8200
  async function syncDeleteCommand(platformModel, options) {
@@ -8209,7 +8272,7 @@ async function syncDeleteCommand(platformModel, options) {
8209
8272
  async function maybeAutoMigrateLegacy(platform, models) {
8210
8273
  const dbSize = getDatabaseSize(platform);
8211
8274
  if (!dbSize || dbSize === "0 B") return;
8212
- const { getBackend: getBackend2 } = await import("./runtime-2PRVL2NO.js");
8275
+ const { getBackend: getBackend2 } = await import("./runtime-HDZCBTWH.js");
8213
8276
  const backend = await getBackend2();
8214
8277
  let memoryHasData = false;
8215
8278
  for (const model of models) {
@@ -8225,7 +8288,7 @@ async function maybeAutoMigrateLegacy(platform, models) {
8225
8288
  ` detected legacy .one/sync/data/${platform}.db (${dbSize}) \u2014 auto-migrating into memory before sync.
8226
8289
  `
8227
8290
  );
8228
- const { memMigrateCommand: memMigrateCommand3 } = await import("./migrate-TRUZVAT5.js");
8291
+ const { memMigrateCommand: memMigrateCommand3 } = await import("./migrate-LXDIZMXH.js");
8229
8292
  await memMigrateCommand3({ platform, yes: true });
8230
8293
  return;
8231
8294
  }
@@ -8234,7 +8297,7 @@ async function maybeAutoMigrateLegacy(platform, models) {
8234
8297
  initialValue: true
8235
8298
  });
8236
8299
  if (p7.isCancel(shouldMigrate) || !shouldMigrate) return;
8237
- const { memMigrateCommand: memMigrateCommand2 } = await import("./migrate-TRUZVAT5.js");
8300
+ const { memMigrateCommand: memMigrateCommand2 } = await import("./migrate-LXDIZMXH.js");
8238
8301
  await memMigrateCommand2({ platform, yes: true });
8239
8302
  }
8240
8303
  async function syncSuggestSearchableCommand(platformModel, options = {}) {
@@ -8295,7 +8358,7 @@ async function syncSuggestSearchableCommand(platformModel, options = {}) {
8295
8358
  async function syncListCommand(platform) {
8296
8359
  const profiles = listProfiles(platform);
8297
8360
  const state = await readSyncState();
8298
- const { getBackend: getBackend2 } = await import("./runtime-2PRVL2NO.js");
8361
+ const { getBackend: getBackend2 } = await import("./runtime-HDZCBTWH.js");
8299
8362
  const backend = await getBackend2();
8300
8363
  const syncs = await Promise.all(profiles.map(async (p10) => {
8301
8364
  const modelState = state[p10.platform]?.[p10.model];
@@ -8570,7 +8633,7 @@ function registerSyncSubcommands(sync) {
8570
8633
  await syncSqlCommand(platformModel, sql);
8571
8634
  });
8572
8635
  sync.command("schema <platform/model>").description("Inspect the JSON structure of synced records (field paths, types, examples) \u2014 useful before writing `sync sql` queries").action(async (platformModel) => {
8573
- const { syncSchemaCommand } = await import("./schema-LCRWSFD5.js");
8636
+ const { syncSchemaCommand } = await import("./schema-SYMG6SRC.js");
8574
8637
  await syncSchemaCommand(platformModel);
8575
8638
  });
8576
8639
  sync.command("delete <platform/model>").description('Delete records from local sync data (e.g. one sync delete notion/pages --id "abc-123")').option("--id <value>", "Delete record by ID").option("--where <conditions>", 'Delete records matching conditions (e.g. "status=archived")').option("--where-sql <predicate>", `Delete using a raw SQL WHERE clause (e.g. "json_extract(data, '$.type') = 'promotion'")`).option("--yes", "Skip confirmation prompt").action(async (platformModel, options) => {
@@ -8992,6 +9055,7 @@ function parseValue(raw) {
8992
9055
  }
8993
9056
 
8994
9057
  // src/commands/mem/records.ts
9058
+ import pc10 from "picocolors";
8995
9059
  async function memAddCommand(type, dataRaw, flags) {
8996
9060
  requireMemoryInit();
8997
9061
  const data = parseJsonArg(dataRaw, "data");
@@ -9016,8 +9080,12 @@ async function memGetCommand(id, flags) {
9016
9080
  async function memUpdateCommand(id, patchRaw) {
9017
9081
  requireMemoryInit();
9018
9082
  const patch = parseJsonArg(patchRaw, "patch");
9019
- if (patch && typeof patch === "object" && !Array.isArray(patch) && "keys" in patch) {
9020
- error("`keys` is not a data field. Use `one mem key <id> --set <csv>` (or --add/--remove) to change a record's keys.");
9083
+ const isPatchObject = !!patch && typeof patch === "object" && !Array.isArray(patch);
9084
+ if (isPatchObject && "keys" in patch) {
9085
+ error("`keys` is not a data field. Use `one mem key <id> --set <csv>` (or --add/--remove) to change a record's merge keys.");
9086
+ }
9087
+ if (isPatchObject && ("identity_keys" in patch || "identityKeys" in patch)) {
9088
+ error("`identity_keys` is not a data field. Participant associations are derived on sync from the profile's `identityKeys` \u2014 edit the sync profile and re-run `one sync run`, then query with `one mem find-by-key <key>`.");
9021
9089
  }
9022
9090
  const updated = await updateRecord(id, { data: patch });
9023
9091
  if (!updated) error(`Record ${id} not found`);
@@ -9046,7 +9114,7 @@ async function memKeyCommand(id, flags) {
9046
9114
  if (outcome.status === "not_found") error(`Record ${id} not found`);
9047
9115
  if (outcome.status === "conflict") {
9048
9116
  error(
9049
- `Key "${outcome.key}" is already owned by active record ${outcome.recordId} (type ${outcome.recordType}). Keys must be unique across active records \u2014 archive or re-key that record first.`
9117
+ `Key "${outcome.key}" is already owned by active record ${outcome.recordId} (type ${outcome.recordType}). Merge keys must be unique across active records \u2014 archive or re-key that record first.`
9050
9118
  );
9051
9119
  }
9052
9120
  printRecord(outcome.record);
@@ -9180,9 +9248,104 @@ async function memFindBySourceCommand(sourceKey) {
9180
9248
  if (!record) error(`No record owns source "${sourceKey}"`);
9181
9249
  printRecord(record);
9182
9250
  }
9251
+ function summarizeRecord(r) {
9252
+ const d = r.data ?? {};
9253
+ for (const field of ["title", "subject", "name", "full_name", "display_name", "summary", "headline", "email"]) {
9254
+ const v = d[field];
9255
+ if (typeof v === "string" && v.trim()) return v.trim().slice(0, 80);
9256
+ }
9257
+ return pc10.dim("(untitled)");
9258
+ }
9259
+ function relativeTime(iso) {
9260
+ if (!iso) return "";
9261
+ const then = new Date(iso).getTime();
9262
+ if (Number.isNaN(then)) return "";
9263
+ const s = Math.max(0, Math.floor((Date.now() - then) / 1e3));
9264
+ if (s < 60) return `${s}s ago`;
9265
+ const m = Math.floor(s / 60);
9266
+ if (m < 60) return `${m}m ago`;
9267
+ const h = Math.floor(m / 60);
9268
+ if (h < 24) return `${h}h ago`;
9269
+ const d = Math.floor(h / 24);
9270
+ if (d < 30) return `${d}d ago`;
9271
+ const mo = Math.floor(d / 30);
9272
+ if (mo < 12) return `${mo}mo ago`;
9273
+ return `${Math.floor(mo / 12)}y ago`;
9274
+ }
9275
+ var FIND_BY_KEY_FETCH_CAP = 2e3;
9276
+ function normalizeQueryKey(key) {
9277
+ return key.trim().toLowerCase();
9278
+ }
9279
+ async function memFindByKeyCommand(key, secondKey, flags) {
9280
+ requireMemoryInit();
9281
+ const backend = await getBackend();
9282
+ const rawKeys = secondKey ? [key, secondKey] : [key];
9283
+ const keys = rawKeys.map(normalizeQueryKey);
9284
+ const perType = parsePositiveInt(flags.limit, 10, "--limit");
9285
+ const fetchPage = async (ks) => {
9286
+ const rows = await backend.findByKeys(ks, { type: flags.type, limit: FIND_BY_KEY_FETCH_CAP + 1 });
9287
+ return { records: rows.slice(0, FIND_BY_KEY_FETCH_CAP), truncated: rows.length > FIND_BY_KEY_FETCH_CAP };
9288
+ };
9289
+ let page = await fetchPage(keys);
9290
+ let matchedKeys = keys;
9291
+ if (page.records.length === 0 && keys.some((k, i) => k !== rawKeys[i])) {
9292
+ const verbatim = await fetchPage(rawKeys);
9293
+ if (verbatim.records.length > 0) {
9294
+ page = verbatim;
9295
+ matchedKeys = rawKeys;
9296
+ }
9297
+ }
9298
+ const { records, truncated } = page;
9299
+ const byType = /* @__PURE__ */ new Map();
9300
+ for (const r of records) {
9301
+ const list = byType.get(r.type) ?? [];
9302
+ list.push(r);
9303
+ byType.set(r.type, list);
9304
+ }
9305
+ if (isAgentMode()) {
9306
+ const grouped = {};
9307
+ for (const [type, list] of byType) {
9308
+ grouped[type] = { count: list.length, items: list.slice(0, perType) };
9309
+ }
9310
+ json({
9311
+ keys: matchedKeys,
9312
+ total: records.length,
9313
+ truncated,
9314
+ fetchCap: FIND_BY_KEY_FETCH_CAP,
9315
+ perTypeLimit: perType,
9316
+ byType: grouped
9317
+ });
9318
+ return;
9319
+ }
9320
+ const label = matchedKeys.map((k) => pc10.cyan(k)).join(pc10.dim(" + "));
9321
+ if (records.length === 0) {
9322
+ console.log(`
9323
+ No records linked to ${label}.
9324
+ `);
9325
+ return;
9326
+ }
9327
+ console.log();
9328
+ console.log(` ${label} ${pc10.dim("\u2014")} ${records.length}${truncated ? "+" : ""} record${records.length === 1 ? "" : "s"} across ${byType.size} type${byType.size === 1 ? "" : "s"}`);
9329
+ console.log();
9330
+ const typeWidth = Math.min(30, Math.max(...[...byType.keys()].map((t) => t.length)));
9331
+ for (const [type, list] of byType) {
9332
+ console.log(` ${pc10.bold(type.padEnd(typeWidth))} ${pc10.dim(`${list.length} record${list.length === 1 ? "" : "s"}`)}`);
9333
+ for (const r of list.slice(0, perType)) {
9334
+ console.log(` ${pc10.dim("\xB7")} ${summarizeRecord(r)} ${pc10.dim(relativeTime(r.updated_at))} ${pc10.dim(r.id)}`);
9335
+ }
9336
+ if (list.length > perType) {
9337
+ console.log(` ${pc10.dim(`\u2026 and ${list.length - perType} more (raise --limit)`)}`);
9338
+ }
9339
+ }
9340
+ if (truncated) {
9341
+ console.log();
9342
+ console.log(` ${pc10.yellow("\u26A0")} Stopped at ${FIND_BY_KEY_FETCH_CAP} matches \u2014 there are more, and types sorting after the last one shown are missing entirely. Re-run with --type <type> to see them.`);
9343
+ }
9344
+ console.log();
9345
+ }
9183
9346
 
9184
9347
  // src/commands/mem/doctor.ts
9185
- import pc10 from "picocolors";
9348
+ import pc11 from "picocolors";
9186
9349
  async function memDoctorCommand() {
9187
9350
  const checks = [];
9188
9351
  const cfg = getMemoryConfig();
@@ -9289,19 +9452,19 @@ function emit(checks, capInfo) {
9289
9452
  return;
9290
9453
  }
9291
9454
  for (const c of checks) {
9292
- const mark = c.ok ? pc10.green("\u2713") : pc10.red("\u2717");
9293
- const detail = c.detail ? pc10.dim(` \u2014 ${c.detail}`) : "";
9455
+ const mark = c.ok ? pc11.green("\u2713") : pc11.red("\u2717");
9456
+ const detail = c.detail ? pc11.dim(` \u2014 ${c.detail}`) : "";
9294
9457
  console.log(` ${mark} ${c.name}${detail}`);
9295
9458
  }
9296
9459
  if (!allOk) {
9297
- console.log("\n" + pc10.yellow("Memory is not fully healthy."));
9460
+ console.log("\n" + pc11.yellow("Memory is not fully healthy."));
9298
9461
  process.exitCode = 1;
9299
9462
  } else {
9300
- console.log("\n" + pc10.green("Memory is healthy."));
9463
+ console.log("\n" + pc11.green("Memory is healthy."));
9301
9464
  }
9302
9465
  const line = semanticSearchUpgradeLine({ vectorSearchAvailable: capInfo.vectorSearchAvailable });
9303
9466
  if (line) console.log(`
9304
- ${pc10.dim(line)}`);
9467
+ ${pc11.dim(line)}`);
9305
9468
  }
9306
9469
 
9307
9470
  // src/commands/mem/export.ts
@@ -9377,6 +9540,14 @@ async function memImportCommand(file) {
9377
9540
  data: record.data,
9378
9541
  tags: record.tags,
9379
9542
  keys,
9543
+ // Carry `identity_keys` through (#128). Export writes the whole row, so
9544
+ // the JSONL has them; omitting them here only looked harmless because
9545
+ // re-importing over a store that still holds the record takes the union
9546
+ // branch in mem_upsert_by_keys and preserves what's already there. On the
9547
+ // INSERT path — restore into a wiped store, or a backend swap
9548
+ // (pglite → postgres), which is exactly what export|import is for —
9549
+ // dropping them here loses every participant association permanently.
9550
+ identity_keys: record.identity_keys,
9380
9551
  sources: record.sources,
9381
9552
  searchable_text: record.searchable_text ?? null,
9382
9553
  content_hash: record.content_hash ?? null,
@@ -9563,10 +9734,10 @@ function registerMemoryCommands(program2) {
9563
9734
  mem.command("vacuum").description("Run backend maintenance (VACUUM ANALYZE on tables)").action(memVacuumCommand);
9564
9735
  mem.command("reindex").description("Backfill embeddings (default) or searchable_text (--searchable)").option("--type <type>", "Restrict to one record type, e.g. attio/attioPeople").option("--model <name>", "Override the embedding model").option("--force", "Re-embed even rows that already have a matching embedding", false).option("--batch <n>", "OpenAI calls per batch (default 50)", "50").option("--limit <n>", "Safety cap on total records to scan (default 100000)").option("--searchable", "Regenerate searchable_text (NULL or stale vs. data) instead of embeddings", false).option("--all", "With --searchable: also rewrite rows whose text is stale, not just NULL", false).action(memReindexCommand);
9565
9736
  mem.command("sql <query>").description("Run a read-only SELECT / WITH / EXPLAIN against the memory store").action(memSqlCommand);
9566
- mem.command("add <type> <data>").description("Add a new memory record (data is JSON)").option("--tags <csv>", "Comma-separated tags").option("--keys <csv>", "Comma-separated keys (prefixed, e.g. email:x@y.com)").option("--weight <n>", "Importance 1-10 (default 5)").option("--embed", "Force embedding for this record").option("--no-embed", "Skip embedding for this record").action(memAddCommand);
9737
+ mem.command("add <type> <data>").description("Add a new memory record (data is JSON)").option("--tags <csv>", "Comma-separated tags").option("--keys <csv>", "Comma-separated merge keys (`keys[]`, prefixed, e.g. email:x@y.com \u2014 unique across active records)").option("--weight <n>", "Importance 1-10 (default 5)").option("--embed", "Force embedding for this record").option("--no-embed", "Skip embedding for this record").action(memAddCommand);
9567
9738
  mem.command("get <id>").description("Get a record by id").option("--links", "Include outgoing and incoming links", false).action(memGetCommand);
9568
9739
  mem.command("update <id> <patch>").description("Update a record (patch is JSON merged into data; regenerates searchable_text)").action(memUpdateCommand);
9569
- mem.command("key <id>").description("Manage a record's identity keys (unique across active records)").option("--add <csv>", "Keys to add (comma-separated, e.g. email:x@y.com)").option("--remove <csv>", "Keys to remove (comma-separated)").option("--set <csv>", "Replace all keys with this comma-separated list").action((id, flags) => memKeyCommand(id, flags));
9740
+ mem.command("key <id>").description("Manage a record's merge keys (`keys[]` \u2014 unique across active records; these drive upsert-merge). Not participant associations \u2014 those live in `identity_keys[]`, written by a sync profile's `identityKeys`.").option("--add <csv>", "Merge keys to add (comma-separated, e.g. email:x@y.com)").option("--remove <csv>", "Merge keys to remove (comma-separated)").option("--set <csv>", "Replace all merge keys with this comma-separated list").action((id, flags) => memKeyCommand(id, flags));
9570
9741
  mem.command("archive <id>").description("Archive a record").option("--reason <text>", "Why it was archived (user_archived | deleted_upstream | superseded | \u2026)").action(memArchiveCommand);
9571
9742
  mem.command("weight <id> <n>").description("Set record relevance weight (1-10)").action(memWeightCommand);
9572
9743
  mem.command("flush <id>").description("Reset access count for a record").action(memFlushCommand);
@@ -9578,6 +9749,7 @@ function registerMemoryCommands(program2) {
9578
9749
  mem.command("linked <id>").description("List linked records").option("--relation <name>", "Filter by relation").option("--direction <dir>", "outgoing | incoming | both", "outgoing").action(memLinkedCommand);
9579
9750
  mem.command("sources <id>").description("List source entries on a record").action(memSourcesCommand);
9580
9751
  mem.command("find-by-source <sourceKey>").description('Look up the record owning "<system>/<model>:<external_id>"').action(memFindBySourceCommand);
9752
+ mem.command("find-by-key <key> [secondKey]").description("Records carrying a key (e.g. email:jane@acme.com) in either `keys[]` or `identity_keys[]`, grouped by type. Two keys = intersection. Keys are matched case-insensitively.").option("--type <type>", "Only this record type (e.g. gmail/gmailThreads) \u2014 also the way past the 2000-match fetch cap").option("--limit <n>", "Max records shown per type (default 10)").action((key, secondKey, flags) => memFindByKeyCommand(key, secondKey, flags));
9581
9753
  registerSyncCommands(mem, {
9582
9754
  description: "Sync platform data into memory (alias of `one sync`)"
9583
9755
  });
@@ -9587,7 +9759,7 @@ function registerMemoryCommands(program2) {
9587
9759
  }
9588
9760
 
9589
9761
  // src/commands/cache.ts
9590
- import pc11 from "picocolors";
9762
+ import pc12 from "picocolors";
9591
9763
  async function cacheClearCommand(actionId) {
9592
9764
  if (actionId) {
9593
9765
  const deleted = clearEntry(actionId);
@@ -9596,9 +9768,9 @@ async function cacheClearCommand(actionId) {
9596
9768
  return;
9597
9769
  }
9598
9770
  if (deleted) {
9599
- console.log(`Cleared cache for ${pc11.cyan(actionId)}`);
9771
+ console.log(`Cleared cache for ${pc12.cyan(actionId)}`);
9600
9772
  } else {
9601
- console.log(`No cache entry found for ${pc11.dim(actionId)}`);
9773
+ console.log(`No cache entry found for ${pc12.dim(actionId)}`);
9602
9774
  }
9603
9775
  } else {
9604
9776
  const count = clearAll();
@@ -9635,7 +9807,7 @@ async function cacheListCommand(options) {
9635
9807
  type: e.type,
9636
9808
  key: e.entry.key,
9637
9809
  age: formatAge(getAge(e.entry)),
9638
- status: isFresh(e.entry) ? pc11.green("fresh") : pc11.yellow("expired")
9810
+ status: isFresh(e.entry) ? pc12.green("fresh") : pc12.yellow("expired")
9639
9811
  }));
9640
9812
  printTable(
9641
9813
  [
@@ -9726,13 +9898,13 @@ async function cacheUpdateAllCommand() {
9726
9898
  if (errors.length > 0) {
9727
9899
  console.log();
9728
9900
  for (const e of errors) {
9729
- console.log(` ${pc11.red("\u2717")} ${e.key}: ${pc11.dim(e.error)}`);
9901
+ console.log(` ${pc12.red("\u2717")} ${e.key}: ${pc12.dim(e.error)}`);
9730
9902
  }
9731
9903
  }
9732
9904
  }
9733
9905
 
9734
9906
  // src/commands/guide.ts
9735
- import pc12 from "picocolors";
9907
+ import pc13 from "picocolors";
9736
9908
 
9737
9909
  // src/lib/guide-content.ts
9738
9910
  var GUIDE_OVERVIEW = `# One CLI \u2014 Agent Guide
@@ -9777,7 +9949,7 @@ Search for actions, read their docs, and execute them. This is the core workflow
9777
9949
 
9778
9950
  **Quick start:**
9779
9951
  \`\`\`bash
9780
- one --agent connection list # See connected platforms
9952
+ one --agent connection list # See connected platforms + your access on each
9781
9953
  one --agent connection delete <connection-key> # Remove a connection
9782
9954
  one --agent actions search <platform> "<query>" -t execute # Find an action
9783
9955
  one --agent actions knowledge <platform> <actionId> # Read docs (REQUIRED)
@@ -9866,6 +10038,7 @@ Request specific sections:
9866
10038
  - Always use the **exact action ID** from search results \u2014 don't guess
9867
10039
  - Always read **knowledge** before executing any action
9868
10040
  - Connection keys come from \`one connection list\` \u2014 don't hardcode them
10041
+ - \`connection list\` also reports an \`access\` field per connection (\`full\` / \`methods\` / \`actions\`) \u2014 read it before planning so you don't propose an action the access config will reject
9869
10042
  - Skills stay in lockstep with the CLI version automatically \u2014 every command checks a \`.one-cli-version\` marker in the canonical skill dir and refreshes the files if the CLI has been upgraded. Check manually with \`one config skills status\`; force a resync with \`one config skills sync\`
9870
10043
  `;
9871
10044
  var GUIDE_ACTIONS = `# One Actions \u2014 Reference
@@ -9880,7 +10053,17 @@ Always follow this sequence. Never skip the knowledge step.
9880
10053
  one --agent connection list
9881
10054
  \`\`\`
9882
10055
 
9883
- Returns platforms, status, connection keys, and tags.
10056
+ Returns platforms, status, connection keys, tags, and an \`access\` field per connection describing what the current access config lets you run there:
10057
+
10058
+ | \`access\` | Meaning |
10059
+ |----------|---------|
10060
+ | \`{"policy": "full"}\` | Every action on the connection |
10061
+ | \`{"policy": "methods", "methods": ["GET"]}\` | Only actions with these HTTP methods will execute |
10062
+ | \`{"policy": "actions", "actions": [{"actionId", "title", "method"}]}\` | Only these exact actions \u2014 use them directly, skip \`actions search\` |
10063
+
10064
+ Also present when relevant: \`knowledgeOnly: true\` (execution disabled \u2014 read knowledge and write code instead), \`unresolvedActionIds\` (allowlisted ids that could not be looked up), and \`accessHint\` (a one-line summary of the restriction).
10065
+
10066
+ Read \`access\` before planning \u2014 it prevents proposing an action the config will reject. Change it with \`one config\`.
9884
10067
 
9885
10068
  ### 1b. Delete a Connection
9886
10069
 
@@ -10212,15 +10395,18 @@ one --agent mem get <id> --links
10212
10395
  # Update (shallow merge into data; regenerates searchable_text from the merged data)
10213
10396
  one --agent mem update <id> '{"status":"done"}'
10214
10397
 
10215
- # Manage identity keys AFTER creation (keys are a first-class column, NOT data).
10216
- # Unique across ACTIVE records \u2014 a clear error names the record that owns a taken key.
10398
+ # Manage MERGE KEYS after creation \u2014 the \`keys[]\` column (a first-class column,
10399
+ # NOT data). Unique across ACTIVE records \u2014 a clear error names the record that
10400
+ # owns a taken key. This is the column that collapses two records into one.
10217
10401
  one --agent mem key <id> --add email:new@x.com
10218
10402
  one --agent mem key <id> --remove email:old@x.com
10219
- one --agent mem key <id> --set 'email:a@x.com,phone:+1555' # replace all keys
10403
+ one --agent mem key <id> --set 'email:a@x.com,phone:+1555' # replace ALL merge keys
10220
10404
  # NOTE: passing keys to 'mem update' is rejected \u2014 keys are not a data field.
10405
+ # NOTE: 'mem key' WRITES keys[]. To READ across records, use 'mem find-by-key'
10406
+ # (see Identity keys below) \u2014 different command, different column coverage.
10221
10407
 
10222
- # Archive / unarchive. Archiving FREES a record's keys: an archived record no
10223
- # longer blocks a new active record from reusing the same key (uniqueness is
10408
+ # Archive / unarchive. Archiving FREES a record's merge keys: an archived record
10409
+ # no longer blocks a new active record from reusing the same key (uniqueness is
10224
10410
  # scoped to active records).
10225
10411
  one --agent mem archive <id> --reason superseded
10226
10412
 
@@ -10262,6 +10448,55 @@ one --agent mem sources <id> # list source entries
10262
10448
  one --agent mem find-by-source attio/attioPeople:abc-123
10263
10449
  \`\`\`
10264
10450
 
10451
+ \`find-by-source\` answers "which ONE record owns this external id" \u2014 a single record, preferring the active owner. For "everything about this person", use \`find-by-key\` below.
10452
+
10453
+ ## Identity keys (cross-platform join)
10454
+
10455
+ Two different columns carry prefixed keys like \`email:jane@acme.com\`, and the difference is the whole point:
10456
+
10457
+ - **\`keys[]\` \u2014 merge keys.** "This record IS this entity." Unique across active records, and \`sync\` merges any incoming record that overlaps an existing one. Written by \`mem add --keys\`, \`mem key\`, and a profile's singular \`identityKey\`.
10458
+ - **\`identity_keys[]\` \u2014 association keys (#128).** "This record INVOLVES these people." Deliberately exempt from both the uniqueness constraint and the overlap-merge, so a Gmail thread with 12 participants stays one thread instead of collapsing into a contact. Written by a profile's plural \`identityKeys\` (see \`one guide sync\`).
10459
+
10460
+ \`mem find-by-key\` queries BOTH columns at once, so one call surfaces the CRM record, the email threads, the calendar events, and the meeting notes for the same person:
10461
+
10462
+ \`\`\`bash
10463
+ one --agent mem find-by-key email:jane@acme.com # every record carrying the key
10464
+ one --agent mem find-by-key email:jane@acme.com --type gmail/gmailThreads # one record type
10465
+ one --agent mem find-by-key email:a@x.com email:b@y.com # intersection \u2014 records with BOTH
10466
+ one --agent mem find-by-key email:jane@acme.com --limit 25 # show more per type (default 10)
10467
+ \`\`\`
10468
+
10469
+ Any prefix works (\`email:\`, \`domain:\`, \`phone:\`, even a raw source key) \u2014 matching is plain array containment over the two columns combined, so a two-key query still matches when one key sits in \`keys[]\` and the other in \`identity_keys[]\`. Only ACTIVE records are returned. Lookup keys are lowercased/trimmed to match how sync writes them, with a verbatim retry for hand-written mixed-case keys, so \`email:Jane@Acme.com\` and \`email:jane@acme.com\` find the same records.
10470
+
10471
+ Agent output is grouped by record type:
10472
+
10473
+ \`\`\`json
10474
+ {
10475
+ "keys": ["email:jane@acme.com"],
10476
+ "total": 13,
10477
+ "truncated": false,
10478
+ "fetchCap": 2000,
10479
+ "perTypeLimit": 10,
10480
+ "byType": {
10481
+ "attio/attioPeople": { "count": 1, "items": [ { "id": "...", "type": "...", "data": {}, "keys": ["attio/attioPeople:J1", "email:jane@acme.com"] } ] },
10482
+ "gmail/gmailThreads": { "count": 12, "items": [] }
10483
+ }
10484
+ }
10485
+ \`\`\`
10486
+
10487
+ \`items\` are whole mem records. \`keys\` and \`identity_keys\` are **omitted, not \`[]\`**, when the record has none \u2014 the contact above matched on \`keys[]\` and carries no \`identity_keys\` field at all. Read them as \`(item.identity_keys ?? [])\`.
10488
+
10489
+ There are TWO independent truncations, and conflating them gives wrong answers:
10490
+
10491
+ - **\`--limit\` (default 10, echoed as \`perTypeLimit\`)** is display only. It slices each \`items\` array; \`total\` and \`count\` stay full match counts. \`count > items.length\` just means "raise \`--limit\` to list the rest".
10492
+ - **\`truncated: true\`** means the query hit the \`fetchCap\` (2000 matches) before it ran out of records. Now \`total\` and every \`count\` are floors, and because rows arrive ordered by type, any type sorting after the cut is absent from \`byType\` altogether. One high-volume type \u2014 your own address on every synced Gmail thread, say \u2014 can consume the whole budget. Re-run with \`--type <type>\` to get an accurate answer for the type you care about; the interactive (non-\`--agent\`) output prints a \`\u26A0\` and a \`2000+\` style count in the same situation.
10493
+
10494
+ \`items\` entries are whole mem records, same fields as \`mem get\`, so no follow-up \`mem get\` is needed.
10495
+
10496
+ \`keys\` echoes the key form that actually matched, which is the normalized one unless the verbatim retry won.
10497
+
10498
+ Do not confuse \`mem key\` with \`mem find-by-key\`: \`mem key\` WRITES \`keys[]\` on a single record (\`--set\` replaces the array outright and can trigger a merge), while \`find-by-key\` is a read-only query across every record.
10499
+
10265
10500
  ## Sync into memory
10266
10501
 
10267
10502
  \`one mem sync\` is a full alias of \`one sync\` \u2014 same handlers, same options. Use either.
@@ -10552,9 +10787,30 @@ The transform can be any command: \`jq\`, \`python3\`, a bash script, or \`one f
10552
10787
 
10553
10788
  Transform, exclude, identityKey, and hooks all fire in **both** phases. In Phase 1 they run on the raw list page; in Phase 2 they run again on the merged (list + enriched) record so that transforms can extract columns from fields that only appear after enrichment. Phase 2 fires \`onUpdate\`/\`onChange\` for every row it writes \u2014 \`onInsert\` is Phase-1-only because the row already exists in SQL by the time enrichment runs.
10554
10789
 
10790
+ **Exception \u2014 plural \`identityKeys\` on an enriching profile.** When a profile declares \`enrich\`, Phase 1 deliberately writes *no opinion* about \`identity_keys[]\` (it leaves whatever is stored untouched) and only Phase 2 sets them. Phase 1 sees the list shape, where participant paths like \`messages[].payload.headers[name=From].value\` don't exist yet \u2014 if it wrote its empty result, every re-sync would erase the participants Phase 2 had resolved. Two consequences worth knowing: a row whose enrichment is skipped or rate-limited keeps \`identity_keys\` \`NULL\` until it enriches, and a participant removed upstream is only cleared once that row re-enriches.
10791
+
10792
+ ### Phase 1 never degrades an already-enriched record
10793
+
10794
+ Phase 2 only visits rows \`WHERE _enriched_at IS NULL\`, so it never revisits a row it has already enriched. That makes Phase 1's write dangerous on every run after the first: it holds only the thin list shape, and an authoritative write would replace the enriched payload with it \u2014 permanently, since Phase 2 won't come back to repair it.
10795
+
10796
+ So for a row the mirror reports as already enriched, Phase 1 writes **non-authoritatively**:
10797
+
10798
+ | | Already-enriched row | Not yet enriched |
10799
+ |---|---|---|
10800
+ | \`data\` | merged \u2014 enrich-only fields survive, list fields still refresh | replaced |
10801
+ | \`searchable_text\` / \`content_hash\` | kept (thin text can't overwrite the enriched text) | recomputed |
10802
+ | \`identity_keys[]\` | kept | written |
10803
+ | \`keys[]\`, \`tags\`, \`sources.last_synced_at\`, embeddings | updated normally | updated normally |
10804
+
10805
+ 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\`.
10806
+
10807
+ **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.
10808
+
10555
10809
  ## Cross-Platform Identity
10556
10810
 
10557
- Add \`identityKey\` to a sync profile to extract a stable cross-platform identifier (e.g. email) into a normalized \`_identity\` column:
10811
+ Two ways to tag a record with a cross-platform identifier (e.g. email), depending on whether the record IS an entity or INVOLVES many:
10812
+
10813
+ **\`identityKey\` (singular)** \u2014 "this record IS this entity". The value goes into the record's \`keys[]\`, which the store uses to MERGE records for the same entity across platforms (Attio + HubSpot for the same person collapse into one):
10558
10814
 
10559
10815
  \`\`\`json
10560
10816
  {"platform": "hubspot", "model": "contacts", "identityKey": "properties.email"}
@@ -10562,14 +10818,34 @@ Add \`identityKey\` to a sync profile to extract a stable cross-platform identif
10562
10818
  {"platform": "attio", "model": "attioPeople", "identityKey": "email_addresses[0].email_address"}
10563
10819
  \`\`\`
10564
10820
 
10565
- The value is lowercased and trimmed, stored as a prefixed key on the mem record (e.g. \`email:jane@acme.com\`). Look up across platforms:
10821
+ **\`identityKeys\` (plural, #128)** \u2014 "this record INVOLVES these people". For records with N participants (Gmail thread From/To/Cc, calendar attendees, meeting invitees) where a single key can't capture everyone. These go into a separate \`identity_keys[]\` column that does NOT merge \u2014 so a thread with many participants stays its own record:
10822
+
10823
+ \`\`\`json
10824
+ {"platform": "google-calendar", "model": "events", "identityKeys": [
10825
+ {"prefix": "email", "path": "organizer.email"},
10826
+ {"prefix": "email", "path": "attendees[].email"}
10827
+ ]}
10828
+ \`\`\`
10829
+
10830
+ 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.
10831
+
10832
+ Query everything sharing an identity key, grouped by type:
10566
10833
 
10567
10834
  \`\`\`bash
10835
+ one --agent mem find-by-key email:jane@acme.com # all records carrying this key
10836
+ one --agent mem find-by-key email:jane@acme.com --type gmail/gmailThreads
10837
+ one --agent mem find-by-key email:jane@acme.com email:bob@acme.com # intersection (both keys)
10838
+ one --agent mem find-by-key email:jane@acme.com --limit 25 # show more records per type
10839
+ # Look up the single record owning a source key:
10568
10840
  one --agent mem find-by-source hubspot/contacts:<id>
10569
- # Or via the dotted --where path on the identity key:
10570
- one --agent sync query hubspot/contacts --where 'email=jane@acme.com'
10841
+ # Or filter ONE model on the underlying field via the dotted --where path. Note
10842
+ # this is the raw data path, NOT the identity key \u2014 for the hubspot profile above
10843
+ # that's 'properties.email', the same path its identityKey extracts from.
10844
+ one --agent sync query hubspot/contacts --where 'properties.email=jane@acme.com'
10571
10845
  \`\`\`
10572
10846
 
10847
+ \`find-by-key\` spans BOTH columns \u2014 entity keys in \`keys[]\` and association keys in \`identity_keys[]\`. \`--limit\` (default 10) caps only how many records are listed per type; the per-type \`count\` and overall \`total\` are still full match counts, so \`count\` exceeding the listed items is the signal to raise it. A separate \`truncated\` flag reports the very different case where the 2000-match fetch cap was hit and whole types are missing \u2014 see \`one guide memory\` for the full response shape.
10848
+
10573
10849
  \`sync sql\` was retired in the unified memory cutover \u2014 a raw-SQL surface can't safely span the embedded Postgres, remote Postgres, and third-party backends without leaking specifics. Use \`mem search\` / \`sync search\` / \`sync query\` with dotted \`--where\` paths instead.
10574
10850
 
10575
10851
  ## Exclude Fields
@@ -10657,7 +10933,8 @@ Every \`sync X\` command is also exposed as \`mem sync X\` \u2014 same handlers,
10657
10933
  | limitLocation | no | "query" (default) or "body" for POST endpoints |
10658
10934
  | enrich | no | Detail endpoint config for record enrichment (actionId, pathVars, concurrency) |
10659
10935
  | transform | no | Shell command to transform records (stdin: JSON array, stdout: JSON array) |
10660
- | identityKey | no | Dot-path to cross-platform identifier (e.g. email) \u2192 stored as \`_identity\` column |
10936
+ | identityKey | no | "This record IS this entity" \u2014 dot-path to a cross-platform id (e.g. email) \u2192 \`keys[]\` (drives entity merge) |
10937
+ | identityKeys | no | "This record INVOLVES these people" \u2014 \`[{prefix, path}]\` for N participants (#128) \u2192 non-merging \`identity_keys[]\`; query with \`mem find-by-key\` |
10661
10938
  | exclude | no | Dot-path fields to strip before storing (e.g. \`["messages[].body"]\`) |
10662
10939
  | onInsert/onUpdate/onChange | no | Change hooks (shell command, "log", or flow) |
10663
10940
 
@@ -10738,14 +11015,14 @@ async function guideCommand(topic = "all") {
10738
11015
  json({ topic, title, content, availableTopics });
10739
11016
  return;
10740
11017
  }
10741
- intro(pc12.bgCyan(pc12.black(" One Guide ")));
11018
+ intro(pc13.bgCyan(pc13.black(" One Guide ")));
10742
11019
  console.log();
10743
11020
  console.log(content);
10744
- console.log(pc12.dim("\u2500".repeat(60)));
11021
+ console.log(pc13.dim("\u2500".repeat(60)));
10745
11022
  console.log(
10746
- pc12.dim("Available topics: ") + availableTopics.map((t) => pc12.cyan(t.topic)).join(", ")
11023
+ pc13.dim("Available topics: ") + availableTopics.map((t) => pc13.cyan(t.topic)).join(", ")
10747
11024
  );
10748
- console.log(pc12.dim(`Run ${pc12.cyan("one guide <topic>")} for a specific section.`));
11025
+ console.log(pc13.dim(`Run ${pc13.cyan("one guide <topic>")} for a specific section.`));
10749
11026
  }
10750
11027
 
10751
11028
  // src/lib/platform-meta.ts
@@ -11068,20 +11345,20 @@ function buildWorkflowIdeas(connections) {
11068
11345
  // src/commands/logout.ts
11069
11346
  import fs12 from "fs";
11070
11347
  import * as p9 from "@clack/prompts";
11071
- function formatWhoami(config2, apiKey, pc14) {
11348
+ function formatWhoami(config2, apiKey, pc15) {
11072
11349
  const whoami = config2.whoami;
11073
11350
  const env = getEnvFromApiKey(apiKey);
11074
- const envLabel = env === "test" ? pc14.yellow("test") : pc14.green("live");
11351
+ const envLabel = env === "test" ? pc15.yellow("test") : pc15.green("live");
11075
11352
  const lines = [];
11076
11353
  if (whoami) {
11077
11354
  const contextParts = [];
11078
11355
  if (whoami.organization) contextParts.push(whoami.organization.name);
11079
11356
  if (whoami.project) contextParts.push(whoami.project.name);
11080
11357
  const scopeDisplay = contextParts.length > 0 ? contextParts.join(" / ") : "Personal";
11081
- lines.push(`${pc14.bold(scopeDisplay)} ${pc14.dim("\xB7")} ${envLabel}`);
11082
- lines.push(`${whoami.user.name} ${pc14.dim(`(${whoami.user.email})`)}`);
11358
+ lines.push(`${pc15.bold(scopeDisplay)} ${pc15.dim("\xB7")} ${envLabel}`);
11359
+ lines.push(`${whoami.user.name} ${pc15.dim(`(${whoami.user.email})`)}`);
11083
11360
  } else {
11084
- lines.push(`${pc14.dim("Key:")} ${apiKey.slice(0, 8)}... ${pc14.dim("\xB7")} ${envLabel}`);
11361
+ lines.push(`${pc15.dim("Key:")} ${apiKey.slice(0, 8)}... ${pc15.dim("\xB7")} ${envLabel}`);
11085
11362
  }
11086
11363
  return lines;
11087
11364
  }
@@ -11110,7 +11387,7 @@ async function logoutCommand() {
11110
11387
  json({ status: cleared ? "logged_out" : "not_logged_in", message: cleared ? "Credentials cleared." : "No config found." });
11111
11388
  return;
11112
11389
  }
11113
- const pc14 = (await import("picocolors")).default;
11390
+ const pc15 = (await import("picocolors")).default;
11114
11391
  const globalConfig = readGlobalConfig();
11115
11392
  const projectConfig = readProjectConfig();
11116
11393
  const hasGlobal = globalConfig?.apiKey != null;
@@ -11119,13 +11396,13 @@ async function logoutCommand() {
11119
11396
  if (hasGlobal && hasProject) {
11120
11397
  const infoLines = ["You are logged in with multiple configs.", ""];
11121
11398
  if (projectConfig) {
11122
- infoLines.push(`${pc14.cyan("Local config:")}`);
11123
- infoLines.push(...formatWhoami(projectConfig, projectConfig.apiKey, pc14));
11399
+ infoLines.push(`${pc15.cyan("Local config:")}`);
11400
+ infoLines.push(...formatWhoami(projectConfig, projectConfig.apiKey, pc15));
11124
11401
  infoLines.push("");
11125
11402
  }
11126
11403
  if (globalConfig) {
11127
- infoLines.push(`${pc14.magenta("Global config:")}`);
11128
- infoLines.push(...formatWhoami(globalConfig, globalConfig.apiKey, pc14));
11404
+ infoLines.push(`${pc15.magenta("Global config:")}`);
11405
+ infoLines.push(...formatWhoami(globalConfig, globalConfig.apiKey, pc15));
11129
11406
  }
11130
11407
  p9.note(infoLines.join("\n"));
11131
11408
  const choice = await p9.select({
@@ -11143,14 +11420,14 @@ async function logoutCommand() {
11143
11420
  targetScope = choice;
11144
11421
  } else if (hasProject) {
11145
11422
  const infoLines = ["You are logged in.", ""];
11146
- infoLines.push(`${pc14.dim("Stored in")} ${pc14.cyan("local config")}`);
11147
- infoLines.push(...formatWhoami(projectConfig, projectConfig.apiKey, pc14));
11423
+ infoLines.push(`${pc15.dim("Stored in")} ${pc15.cyan("local config")}`);
11424
+ infoLines.push(...formatWhoami(projectConfig, projectConfig.apiKey, pc15));
11148
11425
  p9.note(infoLines.join("\n"));
11149
11426
  targetScope = "project";
11150
11427
  } else if (hasGlobal) {
11151
11428
  const infoLines = ["You are logged in.", ""];
11152
- infoLines.push(`${pc14.dim("Stored in")} ${pc14.magenta("global config")}`);
11153
- infoLines.push(...formatWhoami(globalConfig, globalConfig.apiKey, pc14));
11429
+ infoLines.push(`${pc15.dim("Stored in")} ${pc15.magenta("global config")}`);
11430
+ infoLines.push(...formatWhoami(globalConfig, globalConfig.apiKey, pc15));
11154
11431
  p9.note(infoLines.join("\n"));
11155
11432
  targetScope = "global";
11156
11433
  } else {
@@ -11187,7 +11464,7 @@ async function logoutCommand() {
11187
11464
  // src/lib/analytics.ts
11188
11465
  import { createRequire as createRequire2 } from "module";
11189
11466
  import { randomUUID, createHash } from "crypto";
11190
- import pc13 from "picocolors";
11467
+ import pc14 from "picocolors";
11191
11468
  var require3 = createRequire2(import.meta.url);
11192
11469
  var DEFAULT_POSTHOG_HOST = "https://us.i.posthog.com";
11193
11470
  var DEFAULT_POSTHOG_KEY = "phc_a9ok4w0uxiZcVoSWOISIlin85lHMXQD3vWPaYnuRlRV";
@@ -11433,7 +11710,7 @@ function maybeShowTelemetryNotice() {
11433
11710
  if (telemetryNoticeShown()) return;
11434
11711
  markTelemetryNoticeShown();
11435
11712
  process.stderr.write(
11436
- pc13.dim(
11713
+ pc14.dim(
11437
11714
  "One CLI collects usage analytics (which commands run, linked to your One account) to improve the product.\nNo arguments, inputs, or secrets are ever collected. Opt out anytime with ONE_NO_TELEMETRY=1.\n"
11438
11715
  )
11439
11716
  );
@@ -11456,7 +11733,7 @@ program.name("one").option("--agent", "Machine-readable JSON output (no colors,
11456
11733
  one whoami Show current user, organization, and project
11457
11734
 
11458
11735
  Workflow (use these in order):
11459
- 1. one list List your connected platforms and connection keys
11736
+ 1. one list List connected platforms, keys, and your access on each
11460
11737
  2. one actions search <platform> <q> Search for actions using natural language
11461
11738
  3. one actions knowledge <plat> <id> Get full docs for an action (ALWAYS do this before execute)
11462
11739
  4. one actions execute <p> <id> <key> Execute the action
@@ -11714,7 +11991,7 @@ var connection = program.command("connection").description("Manage connections")
11714
11991
  connection.command("add [platform]").alias("a").description("Add a new connection").option("--tag <name>", "Tag the new connection (disambiguates multiple connections per platform in sync/flow profiles)").action(async (platform, options) => {
11715
11992
  await connectionAddCommand(platform, { tag: options.tag });
11716
11993
  });
11717
- connection.command("list").alias("ls").description("List your connections").option("-s, --search <query>", "Filter connections by platform name").option("-l, --limit <n>", "Max connections to return (agent mode default: 20)").action(async (options) => {
11994
+ connection.command("list").alias("ls").description("List your connections and what your access config lets you run on each").option("-s, --search <query>", "Filter connections by platform name").option("-l, --limit <n>", "Max connections to return (agent mode default: 20)").action(async (options) => {
11718
11995
  await connectionListCommand(options);
11719
11996
  });
11720
11997
  connection.command("delete <connection-key>").alias("rm").description("Delete a connection").option("-f, --force", "Skip confirmation prompt").action(async (connectionKey, options) => {
@@ -11862,21 +12139,21 @@ program.command("whoami").description("Show the user, organization, and project
11862
12139
  });
11863
12140
  return;
11864
12141
  }
11865
- const pc14 = (await import("picocolors")).default;
12142
+ const pc15 = (await import("picocolors")).default;
11866
12143
  const contextParts = [];
11867
12144
  if (whoami.organization) contextParts.push(whoami.organization.name);
11868
12145
  if (whoami.project) contextParts.push(whoami.project.name);
11869
12146
  const scopeDisplay = contextParts.length > 0 ? contextParts.join(" / ") : "Personal";
11870
- const envLabel = env === "test" ? pc14.yellow("test") : pc14.green("live");
11871
- const configLabel = configScope === "project" ? pc14.cyan("project config") : pc14.magenta("global config");
12147
+ const envLabel = env === "test" ? pc15.yellow("test") : pc15.green("live");
12148
+ const configLabel = configScope === "project" ? pc15.cyan("project config") : pc15.magenta("global config");
11872
12149
  console.log();
11873
- console.log(` ${pc14.bold(scopeDisplay)} ${pc14.dim("\xB7")} ${envLabel}`);
11874
- console.log(` ${whoami.user.name} ${pc14.dim(`(${whoami.user.email})`)}`);
11875
- if (whoami.organization) console.log(` ${pc14.dim("Org:")} ${whoami.organization.name} ${pc14.dim(`(${whoami.organization.id})`)}`);
11876
- if (whoami.project) console.log(` ${pc14.dim("Project:")} ${whoami.project.name} ${pc14.dim(`(${whoami.project.id})`)}`);
12150
+ console.log(` ${pc15.bold(scopeDisplay)} ${pc15.dim("\xB7")} ${envLabel}`);
12151
+ console.log(` ${whoami.user.name} ${pc15.dim(`(${whoami.user.email})`)}`);
12152
+ if (whoami.organization) console.log(` ${pc15.dim("Org:")} ${whoami.organization.name} ${pc15.dim(`(${whoami.organization.id})`)}`);
12153
+ if (whoami.project) console.log(` ${pc15.dim("Project:")} ${whoami.project.name} ${pc15.dim(`(${whoami.project.id})`)}`);
11877
12154
  console.log();
11878
- console.log(` ${pc14.dim("Using")} ${configLabel}`);
11879
- console.log(` ${pc14.dim("API:")} ${apiBase}`);
12155
+ console.log(` ${pc15.dim("Using")} ${configLabel}`);
12156
+ console.log(` ${pc15.dim("API:")} ${apiBase}`);
11880
12157
  console.log();
11881
12158
  });
11882
12159
  program.command("add [platform]").description("Shortcut for: connection add").option("--tag <name>", "Tag the new connection (disambiguates multiple connections per platform in sync/flow profiles)").action(async (platform, options) => {