memgineering 0.25.0 → 0.25.1

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/CHANGELOG.md CHANGED
@@ -11,6 +11,55 @@ language the reader wants. The bilingual rule the monorepo applies to
11
11
 
12
12
  ## [Unreleased]
13
13
 
14
+ ## [0.25.1] — 2026-10-05
15
+
16
+ A security release. Upgrade if you use `setup --web`, or a hosted brain with a
17
+ chat-app connector.
18
+
19
+ ### Security
20
+
21
+ - **`setup --web` no longer switches a machine to another account.** If the
22
+ setup screen is confirmed by a different account from the one this machine is
23
+ signed in to, nothing is written and the terminal says why. The setup screen
24
+ is only told "different account", never who is signed in here.
25
+ - **Notes chosen for upload on the setup screen wait for a check on this
26
+ machine.** A setup left running in the background uploads nothing. The next
27
+ command names the folder, the account that would receive it, and the `push`
28
+ that sends it. That command is quoted, so neither a folder name nor a brain
29
+ name can run as shell code. A setup in the foreground asks first. A `push` of
30
+ that folder, or `logout`, clears the notice.
31
+ - **The setup screen can link or upload only a folder this machine offered.**
32
+ It can create a new local brain only inside the home folder. A relative or
33
+ `~/` path is read against the home folder and created exactly where it was
34
+ checked, and symlinks are followed.
35
+ - **`setup --web --json` prints `setup_url` only when no browser opened**, with
36
+ a hint to use `--print-url` if the browser never appeared. A machine signing
37
+ in for the first time is told once which account it was connected to.
38
+ - **`login` tells the approval screen when and from what the sign-in came**
39
+ (`platform`, `client`). Its wording now says that screen asks the person to
40
+ type the code.
41
+ - **Rules first written through a chat-app connector say so.** `memgineering
42
+ rules` and the before-edit hook print `(written by the ChatGPT connector)`
43
+ next to such a rule, and `--json` carries `written_by`. A rule asked for from
44
+ a chat app is now held as a note until the person promotes it at a terminal.
45
+ The hold is enforced by the server.
46
+
47
+ ### Fixed
48
+
49
+ - `revise --rule` with the claim a note already holds makes it a rule. It used
50
+ to be refused as "already says this", and nothing bound.
51
+
52
+ ### Agent guidance
53
+
54
+ - `memgineering-setup` explains what the setup link gives whoever holds it, and
55
+ what happens to an upload chosen on the screen.
56
+ - `memgineering-rules` explains rules held from chat apps. Promoting one binds
57
+ the note's own text, so read it first with `memgineering open <id> --detail
58
+ full`; to word it differently, write a new rule with `remember --rule`. It
59
+ also explains the `written by` label.
60
+
61
+ Restart the agent session after upgrading so it reads the new guidance.
62
+
14
63
  ## [0.25.0] — 2026-10-04
15
64
 
16
65
  ### Added
@@ -2,7 +2,7 @@
2
2
  name: memgineering
3
3
  description: Use whenever the user refers to something they told you before, asks what was decided, tells you something worth keeping, or settles something that should hold next time. The memory lives in their own folder and outlives this session; check it before answering from guesswork, and write to it when you learn something durable.
4
4
  type: skill
5
- version: 0.25.0
5
+ version: 0.25.1
6
6
  ---
7
7
 
8
8
  # memgineering
@@ -69,6 +69,20 @@ A hosted brain answers this too, and `--rule` writes to one land marked. The
69
69
  marking lives in the note's own frontmatter either way, so a brain that was
70
70
  pushed from a folder keeps every rule it had.
71
71
 
72
+ **A rule saved from a chat app waits for the user.** ChatGPT, Claude.ai and
73
+ other connectors can write notes, but not rules: a rule asked for there is
74
+ kept as an ordinary note, and binds only once the user promotes it at a
75
+ terminal. Promoting binds the note's own text as the chat wrote it — `--claim`
76
+ changes the summary, not the words that bind — so they read it first with
77
+ `memgineering open <id> --detail full` (the card alone shows only the title and
78
+ summary, which a chat app may have rewritten), then `memgineering revise <id> --rule --claim "<what
79
+ the note says>"`. Worded differently, it is a new rule: `memgineering remember
80
+ "<the rule>" --rule`. A connector also cannot change or unmark an existing rule. Once
81
+ promoted, the rule says where its words came from — `(written by the ChatGPT
82
+ connector)` beside it in `memgineering rules` and in the before-edit hook, and
83
+ `written_by` in `--json`. Treat it as their rule; the label says which surface
84
+ it was first said on.
85
+
72
86
  **A rule also wins a tie in `recall` now.** Where an ordinary memory and a
73
87
  standing decision matched a query equally well, the decision is ranked first —
74
88
  the asymmetry being that a missed fact costs the reader some context, while a
@@ -66,6 +66,17 @@ Three things to do after running it:
66
66
  picks it up on its own when they do. There is nothing to poll and nothing
67
67
  to wait for.
68
68
 
69
+ The link is the key to that screen, not the code: whoever holds the whole link
70
+ can drive it. Give it only to them, never paste it anywhere else — with
71
+ `--json`, `setup_url` is printed only when no browser opened.
72
+
73
+ If they chose to upload existing notes on the screen, this machine does NOT
74
+ upload them on its own: the next `memgineering` command shows which folder and
75
+ which account would receive them, with the `push` that sends them. Check with
76
+ them that the account is theirs before running it. And a machine already
77
+ signed in to a different account is never switched by `setup --web` — the
78
+ terminal says so; `logout` first if they really mean to switch.
79
+
69
80
  The screen asks everything `--agent` takes from flags — sign in, where the
70
81
  memory lives, which brain, which tools, auto-update, the session hook — with
71
82
  the recommended answer pre-selected. A person who is already signed in to
package/dist/index.js CHANGED
@@ -1341,8 +1341,8 @@ var init_exclude = __esm({
1341
1341
  [...ALWAYS_SKIP_DIRS].map((d) => fold(d))
1342
1342
  );
1343
1343
  ALWAYS_SKIP_SEGMENTS_FOLDED = ALWAYS_SKIP_SEGMENTS.map((s) => fold(s));
1344
- ALWAYS_SKIP_FILE = (basename3) => {
1345
- const b = fold(basename3);
1344
+ ALWAYS_SKIP_FILE = (basename4) => {
1345
+ const b = fold(basename4);
1346
1346
  return b.startsWith(".env") || b === ".ds_store" || b === ".memgdeny" || b === ".memgallow" || b === ".memgignore";
1347
1347
  };
1348
1348
  EMPTY_IGNORE_SET = { rules: [], unsupported: [] };
@@ -4030,6 +4030,7 @@ function saysNothingNew(entry, input, normalize) {
4030
4030
  if (norm(claim.text) !== norm(previousClaim(entry))) return false;
4031
4031
  if (claim.summary != null && norm(claim.summary) !== norm(entry.memory.summary)) return false;
4032
4032
  if (claim.title != null && norm(claim.title) !== norm(entry.memory.title)) return false;
4033
+ if (claim.binding != null && claim.binding !== (entry.memory.binding === true)) return false;
4033
4034
  const declared = new Set(
4034
4035
  entry.relations.filter((r) => r.type === "contradicts").map((r) => r.to)
4035
4036
  );
@@ -5137,7 +5138,7 @@ async function apiRequest(args) {
5137
5138
  if (cause.retryable && attempts < MAX_CONNECT_RETRIES) {
5138
5139
  const backoff = args.backoffMs ?? CONNECT_BACKOFF_MS;
5139
5140
  const wait = backoff[Math.min(attempts, backoff.length - 1)] ?? 0;
5140
- await new Promise((resolve16) => setTimeout(resolve16, wait));
5141
+ await new Promise((resolve17) => setTimeout(resolve17, wait));
5141
5142
  return apiRequest({ ...args, connectRetries: attempts + 1 });
5142
5143
  }
5143
5144
  throw memgError(
@@ -5161,7 +5162,7 @@ async function apiRequest(args) {
5161
5162
  if (res.status === 429 && (args.retries ?? MAX_RATE_LIMIT_RETRIES) > 0) {
5162
5163
  const wait = retryAfterMs(res.headers.get("retry-after"));
5163
5164
  args.onWait?.(wait);
5164
- await new Promise((resolve16) => setTimeout(resolve16, wait));
5165
+ await new Promise((resolve17) => setTimeout(resolve17, wait));
5165
5166
  return apiRequest({ ...args, retries: (args.retries ?? MAX_RATE_LIMIT_RETRIES) - 1 });
5166
5167
  }
5167
5168
  const envelope = parsed?.error;
@@ -5348,6 +5349,25 @@ function findBrain(cfg, root) {
5348
5349
  const target = resolve(root);
5349
5350
  return cfg.brain.brains.find((b) => resolve(b.root) === target);
5350
5351
  }
5352
+ async function clearSetupFollowUps(onlyPath) {
5353
+ try {
5354
+ const cfg = await loadConfig();
5355
+ const pending = cfg.system.pendingUpload;
5356
+ const dropUpload = pending !== void 0 && (onlyPath === void 0 || resolve(pending.path) === resolve(onlyPath));
5357
+ const dropConnected = onlyPath === void 0 && cfg.system.setupConnected !== void 0;
5358
+ if (!dropUpload && !dropConnected) return;
5359
+ const { pendingUpload, setupConnected, ...rest } = cfg.system;
5360
+ await saveConfig({
5361
+ ...cfg,
5362
+ system: {
5363
+ ...rest,
5364
+ ...dropUpload ? {} : pendingUpload ? { pendingUpload } : {},
5365
+ ...dropConnected ? {} : setupConnected ? { setupConnected } : {}
5366
+ }
5367
+ });
5368
+ } catch {
5369
+ }
5370
+ }
5351
5371
  var BrainSchema, ConfigSchema, EMPTY_CONFIG;
5352
5372
  var init_config = __esm({
5353
5373
  "src/lib/config.ts"() {
@@ -5486,7 +5506,31 @@ var init_config = __esm({
5486
5506
  * Absent means "written before this field existed", which is the same
5487
5507
  * answer as "older than this CLI" and wants the same one command.
5488
5508
  */
5489
- guidanceVersion: z8.string().optional()
5509
+ guidanceVersion: z8.string().optional(),
5510
+ /**
5511
+ * Notes `setup --web` was asked to upload, not yet sent.
5512
+ *
5513
+ * The detached half of the setup never uploads on its own (security
5514
+ * audit H-3): whoever confirmed the setup decided which account receives
5515
+ * the notes, and that is checked by somebody on THIS machine first. The
5516
+ * next command's notice names the path and the receiving account; a
5517
+ * `push` of that path, or `logout`, clears it.
5518
+ */
5519
+ pendingUpload: z8.object({
5520
+ path: z8.string().min(1),
5521
+ brain: z8.string().nullable(),
5522
+ /** The notice names the brain by id only: its name came from the screen. */
5523
+ brain_id: z8.string().nullable().optional(),
5524
+ account_id: z8.string().min(1),
5525
+ account_email: z8.string().nullable(),
5526
+ at: z8.string().min(1)
5527
+ }).optional().catch(void 0),
5528
+ /**
5529
+ * The account a `setup --web` signed this machine into, when it had no
5530
+ * sign-in before — shown once by the next command, because there was no
5531
+ * earlier account to compare it with (H-3).
5532
+ */
5533
+ setupConnected: z8.object({ account_id: z8.string().min(1), email: z8.string().nullable(), at: z8.string().min(1) }).optional().catch(void 0)
5490
5534
  }).default({ autoUpdate: "on" })
5491
5535
  });
5492
5536
  EMPTY_CONFIG = ConfigSchema.parse({});
@@ -5503,6 +5547,10 @@ async function openCloudBrain(target) {
5503
5547
  apiUrl: target.apiUrl
5504
5548
  };
5505
5549
  }
5550
+ function connectorLabel(writtenBy) {
5551
+ const app = writtenBy.replace(/^mcp:/, "").replace(/[^A-Za-z0-9 ._-]/g, "").replace(/\s+/g, " ").trim().slice(0, 32).trim();
5552
+ return app === "" || app.toLowerCase() === "connector" ? "written by a chat connector" : `written by the ${app} connector`;
5553
+ }
5506
5554
  function findCloudBrain(brains, named) {
5507
5555
  return brains.find((brain) => brain.id === named) ?? brains.find((brain) => brain.name === named);
5508
5556
  }
@@ -7054,12 +7102,12 @@ var init_deadline = __esm({
7054
7102
  import { spawn } from "child_process";
7055
7103
  async function openBrowser(url) {
7056
7104
  const { file, args } = browserOpener(url);
7057
- return new Promise((resolve16) => {
7105
+ return new Promise((resolve17) => {
7058
7106
  let settled = false;
7059
7107
  const finish = (opened) => {
7060
7108
  if (settled) return;
7061
7109
  settled = true;
7062
- resolve16(opened);
7110
+ resolve17(opened);
7063
7111
  };
7064
7112
  const child = spawn(file, args, { detached: true, stdio: "ignore" });
7065
7113
  child.on("error", () => finish(false));
@@ -7089,7 +7137,7 @@ function loginCommand() {
7089
7137
  "start the sign-in, open the browser, and return immediately \u2014 THE path an agent should use on a user\u2019s behalf; the next command claims the token automatically. Prefer this over the plain form, whose wait for a human click will outlast an agent\u2019s command timeout"
7090
7138
  ).option("--no-browser", "do not try to open a browser \u2014 just print the link").option("--print-url", "print the approval link even when a browser opened").addHelpText(
7091
7139
  "after",
7092
- "\nAgents: run `login --emit-only`, then SHOW the code in your reply. The\napproval page asks the user to check it against what you showed them,\nand they cannot if you never showed it. Do not ask the user to run a\nlogin command themselves."
7140
+ "\nAgents: run `login --emit-only`, then SHOW the code in your reply. The\napproval page asks the user to type it, and they cannot if you never\nshowed it. Do not ask the user to run a login command themselves."
7093
7141
  ).action(async (opts) => {
7094
7142
  const base2 = apiUrl();
7095
7143
  const already = await currentCredentials(base2);
@@ -7140,7 +7188,11 @@ async function startPairing(base2) {
7140
7188
  const res = await apiRequest({
7141
7189
  method: "POST",
7142
7190
  path: "/v1/auth/device/code",
7143
- baseUrl: base2
7191
+ baseUrl: base2,
7192
+ // Display text for the approval screen — "requested from memgineering on
7193
+ // darwin" — so a person can tell their own terminal's request from a link
7194
+ // somebody sent them (security audit H-2). Nothing identifying.
7195
+ body: { platform: process.platform, client: `memgineering/${VERSION}` }
7144
7196
  });
7145
7197
  const { device_code, user_code, verification_url, expires_in } = res.body ?? {};
7146
7198
  if (!device_code || !user_code || !verification_url) {
@@ -7171,10 +7223,10 @@ function emit(pending, ctx) {
7171
7223
  browser_opened: ctx.opened,
7172
7224
  api_url: ctx.base,
7173
7225
  waiting: ctx.blocking,
7174
- next: ctx.blocking ? "Waiting for approval in the browser." : "SHOW user_code to the user in your reply, then run any memgineering command \u2014 the token is claimed automatically once they approve. The approval screen asks THEM to agree to the terms and the privacy policy and to confirm they are 14 or older; you cannot answer that for them, and an account is not created until they do."
7226
+ next: ctx.blocking ? "Waiting for approval in the browser." : "SHOW user_code to the user in your reply \u2014 the approval screen asks them to type it \u2014 then run any memgineering command; the token is claimed automatically once they approve. The approval screen also asks THEM to agree to the terms and the privacy policy and to confirm they are 14 or older; you cannot answer that for them, and an account is not created until they do."
7175
7227
  },
7176
7228
  human: () => {
7177
- printHuman("Show this code to whoever is signing in:\n");
7229
+ printHuman("Show this code to whoever is signing in \u2014 the approval page asks them to type it:\n");
7178
7230
  printHuman(` ${c.bold().yellow(oneLine(pending.user_code, WIDTH.handle))}
7179
7231
  `);
7180
7232
  if (ctx.opened) {
@@ -7267,9 +7319,10 @@ var init_login = __esm({
7267
7319
  init_open_browser();
7268
7320
  init_pending_login();
7269
7321
  init_ui();
7322
+ init_version();
7270
7323
  BLOCKING_TIMEOUT_MS = 10 * 60 * 1e3;
7271
- sleep = (ms) => new Promise((resolve16) => {
7272
- setTimeout(resolve16, ms).unref?.();
7324
+ sleep = (ms) => new Promise((resolve17) => {
7325
+ setTimeout(resolve17, ms).unref?.();
7273
7326
  });
7274
7327
  }
7275
7328
  });
@@ -7380,6 +7433,7 @@ If your notes are in one of those, that rule is why \u2014 nothing here can see
7380
7433
  }
7381
7434
  }
7382
7435
  const repointed = await pointConfigAt(destination);
7436
+ await clearSetupFollowUps(local.root);
7383
7437
  printDual({
7384
7438
  json: {
7385
7439
  from: local.root,
@@ -7911,15 +7965,18 @@ var setup_cloud_exports = {};
7911
7965
  __export(setup_cloud_exports, {
7912
7966
  SETUP_CHILD_ENV: () => SETUP_CHILD_ENV,
7913
7967
  applyAnswers: () => applyAnswers,
7968
+ insideFolder: () => insideFolder,
7969
+ newBrainRoot: () => newBrainRoot,
7914
7970
  readChildToken: () => readChildToken,
7915
7971
  runSetupCloud: () => runSetupCloud,
7916
7972
  runSetupCloudChild: () => runSetupCloudChild,
7917
7973
  waitAndApply: () => waitAndApply
7918
7974
  });
7919
7975
  import { spawn as spawn2 } from "child_process";
7976
+ import { realpathSync as realpathSync2 } from "fs";
7920
7977
  import { readdir as readdir7, stat as stat6 } from "fs/promises";
7921
7978
  import { homedir as homedir5 } from "os";
7922
- import { join as join24 } from "path";
7979
+ import { basename as basename3, dirname as dirname9, join as join24, resolve as resolve15, sep as sep4 } from "path";
7923
7980
  async function report2(setupToken, state, detail) {
7924
7981
  try {
7925
7982
  await apiRequest({
@@ -7963,7 +8020,7 @@ function toolsFromAnswers(answers, detected) {
7963
8020
  const picked = TOOLS.filter((t) => wanted.has(t.id));
7964
8021
  return picked.length > 0 ? picked : [...detected];
7965
8022
  }
7966
- async function applyAnswers(setupToken, claim, detected) {
8023
+ async function applyAnswers(setupToken, claim, detected, guards = { candidates: [], confirmUpload: null }) {
7967
8024
  const answers = claim.answers;
7968
8025
  if (!answers) {
7969
8026
  throw memgError(
@@ -7972,22 +8029,39 @@ async function applyAnswers(setupToken, claim, detected) {
7972
8029
  "Nothing was installed. Run `memgineering setup --web` again; if it repeats, the brain server is answering in a shape this version does not understand."
7973
8030
  );
7974
8031
  }
8032
+ const localRoot = checkLocalPath(answers, guards.candidates);
8033
+ let account = null;
7975
8034
  if (typeof claim.token === "string" && claim.token !== "") {
8035
+ account = claim.account ?? await whoIs(claim.token);
8036
+ const before = await readCredentials();
8037
+ const firstSignIn = before === null || before.api_url !== apiUrl();
8038
+ if (!firstSignIn && (account === null || before.account_id !== account.id)) {
8039
+ throw memgError(
8040
+ "conflict",
8041
+ "this machine is signed in to a different account, so the setup changed nothing",
8042
+ `Signed in here: ${before.display_name ?? before.account_id}. The setup screen was confirmed by: ${account?.email ?? account?.display_name ?? "an unknown account"}. If that second account is yours and you want to switch, run \`memgineering logout\` and then \`memgineering setup --web\` again; if you did not confirm it, someone else did \u2014 nothing was changed.`
8043
+ );
8044
+ }
7976
8045
  const registry = registryFromClaim(claim.registry);
7977
- const me = await apiRequest({
7978
- method: "GET",
7979
- path: "/v1/me",
7980
- token: claim.token
7981
- }).catch(() => null);
7982
8046
  const credentials = {
7983
8047
  api_url: apiUrl(),
7984
8048
  token: claim.token,
7985
- account_id: me?.body?.account?.id ?? "",
7986
- display_name: me?.body?.account?.display_name ?? null,
8049
+ account_id: account?.id ?? "",
8050
+ display_name: account?.display_name ?? null,
7987
8051
  signed_in_at: (/* @__PURE__ */ new Date()).toISOString(),
7988
8052
  ...registry !== void 0 ? { registry } : {}
7989
8053
  };
7990
8054
  await saveCredentials(credentials);
8055
+ if (firstSignIn && account !== null) {
8056
+ const cfg = await loadConfig();
8057
+ await saveConfig({
8058
+ ...cfg,
8059
+ system: {
8060
+ ...cfg.system,
8061
+ setupConnected: { account_id: account.id, email: account.email, at: (/* @__PURE__ */ new Date()).toISOString() }
8062
+ }
8063
+ });
8064
+ }
7991
8065
  }
7992
8066
  await report2(setupToken, "applying", "\uC124\uC815\uC744 \uC4F0\uB294 \uC911\uC774\uC5D0\uC694.");
7993
8067
  const choice = {
@@ -8005,7 +8079,7 @@ async function applyAnswers(setupToken, claim, detected) {
8005
8079
  await recordLink(target.adapter, DEFAULT_OBSERVATIONS_DIR);
8006
8080
  parts.push(`linked ${answers.local_path}`);
8007
8081
  } else {
8008
- const made = await createBrain(answers.local_path, "default");
8082
+ const made = await createBrain(localRoot ?? answers.local_path, "default");
8009
8083
  parts.push(`created ${made.root}`);
8010
8084
  }
8011
8085
  }
@@ -8015,16 +8089,77 @@ async function applyAnswers(setupToken, claim, detected) {
8015
8089
  parts.push(`pointed at ${claim.brain.name}`);
8016
8090
  }
8017
8091
  if (answers.location === "cloud" && answers.upload_path) {
8018
- await report2(setupToken, "applying", "\uB178\uD2B8\uB97C \uC62C\uB9AC\uB294 \uC911\uC774\uC5D0\uC694.");
8019
- const { pushCommand: pushCommand2 } = await Promise.resolve().then(() => (init_push(), push_exports));
8020
- await pushCommand2().parseAsync(
8021
- ["--vault", answers.upload_path, ...claim.brain?.name ? ["--brain", claim.brain.name] : []],
8022
- { from: "user" }
8023
- );
8024
- parts.push(`uploaded ${answers.upload_path}`);
8092
+ const path = answers.upload_path;
8093
+ if (!guards.candidates.includes(path)) {
8094
+ parts.push(`not uploaded: ${path} was not one of the folders this machine offered`);
8095
+ } else {
8096
+ const email = account?.email ?? null;
8097
+ const yes = guards.confirmUpload ? await guards.confirmUpload({ path, email }) : null;
8098
+ if (yes === true) {
8099
+ await report2(setupToken, "applying", "\uB178\uD2B8\uB97C \uC62C\uB9AC\uB294 \uC911\uC774\uC5D0\uC694.");
8100
+ const { pushCommand: pushCommand2 } = await Promise.resolve().then(() => (init_push(), push_exports));
8101
+ await pushCommand2().parseAsync(
8102
+ ["--vault", path, ...claim.brain?.id ? ["--brain", claim.brain.id] : []],
8103
+ { from: "user" }
8104
+ );
8105
+ parts.push(`uploaded ${path}`);
8106
+ } else if (yes === null && account !== null) {
8107
+ const cfg = await loadConfig();
8108
+ await saveConfig({
8109
+ ...cfg,
8110
+ system: {
8111
+ ...cfg.system,
8112
+ pendingUpload: {
8113
+ path,
8114
+ brain: claim.brain?.name ?? null,
8115
+ brain_id: claim.brain?.id ?? null,
8116
+ account_id: account.id,
8117
+ account_email: email,
8118
+ at: (/* @__PURE__ */ new Date()).toISOString()
8119
+ }
8120
+ }
8121
+ });
8122
+ await report2(setupToken, "applying", "\uB178\uD2B8 \uC62C\uB9AC\uAE30\uB294 \uC774 \uCEF4\uD4E8\uD130\uC5D0\uC11C \uD655\uC778\uD55C \uB4A4\uC5D0 \uC2DC\uC791\uB3FC\uC694.");
8123
+ parts.push(`upload of ${path} waits for a check on this machine`);
8124
+ } else {
8125
+ parts.push(`not uploaded: ${path}`);
8126
+ }
8127
+ }
8025
8128
  }
8129
+ if (account?.email) parts.push(`signed in as ${account.email}`);
8026
8130
  return { summary: parts.join(" \xB7 ") };
8027
8131
  }
8132
+ async function whoIs(token) {
8133
+ const me = await apiRequest({ method: "GET", path: "/v1/me", token }).catch(() => null);
8134
+ const a = me?.body?.account;
8135
+ return a?.id ? { id: a.id, email: a.email ?? null, display_name: a.display_name ?? null } : null;
8136
+ }
8137
+ function checkLocalPath(answers, candidates) {
8138
+ if (answers.location !== "local" || !answers.local_path) return null;
8139
+ const path = answers.local_path;
8140
+ if (answers.local_mode === "link") {
8141
+ if (!candidates.includes(path)) {
8142
+ throw memgError(
8143
+ "invalid_input",
8144
+ "the folder the setup screen chose to link was not one this machine offered",
8145
+ "Nothing was changed. To use an existing folder, run `memgineering link <folder>` here."
8146
+ );
8147
+ }
8148
+ return null;
8149
+ }
8150
+ return newBrainRoot(path);
8151
+ }
8152
+ function newBrainRoot(path, home = homedir5()) {
8153
+ const root = path === "~" ? home : path.startsWith("~/") ? resolve15(home, path.slice(2)) : resolve15(home, path);
8154
+ if (!insideFolder(root, home)) {
8155
+ throw memgError(
8156
+ "invalid_input",
8157
+ "the setup screen chose a place outside the home folder for the new brain",
8158
+ `Nothing was changed. Run \`memgineering init <folder>\` here to make a brain somewhere under ${home}.`
8159
+ );
8160
+ }
8161
+ return root;
8162
+ }
8028
8163
  async function pollOnce(setupToken) {
8029
8164
  const res = await apiRequest({
8030
8165
  method: "POST",
@@ -8036,6 +8171,7 @@ async function pollOnce(setupToken) {
8036
8171
  }
8037
8172
  async function waitAndApply(setupToken, opts) {
8038
8173
  let sentFolders = false;
8174
+ let offered = [];
8039
8175
  for (; ; ) {
8040
8176
  if (Date.now() > opts.deadline) return "expired";
8041
8177
  let answer = null;
@@ -8049,7 +8185,10 @@ async function waitAndApply(setupToken, opts) {
8049
8185
  if (answer) {
8050
8186
  await report2(setupToken, "applying");
8051
8187
  try {
8052
- const done = await applyAnswers(setupToken, answer, opts.detected);
8188
+ const done = await applyAnswers(setupToken, answer, opts.detected, {
8189
+ candidates: offered,
8190
+ confirmUpload: opts.confirmUpload ?? null
8191
+ });
8053
8192
  await report2(setupToken, "done", "\uC124\uCE58\uB97C \uB9C8\uCCE4\uC5B4\uC694.");
8054
8193
  return done;
8055
8194
  } catch (err) {
@@ -8068,6 +8207,7 @@ async function waitAndApply(setupToken, opts) {
8068
8207
  sentFolders = true;
8069
8208
  opts.onProgress?.("\uD3F4\uB354\uB97C \uCC3E\uC544\uBCF4\uB294 \uC911\u2026");
8070
8209
  const candidates = await folderCandidates();
8210
+ offered = candidates.map((c2) => c2.path);
8071
8211
  await apiRequest({
8072
8212
  method: "POST",
8073
8213
  path: "/v1/setup/session/folders",
@@ -8076,7 +8216,7 @@ async function waitAndApply(setupToken, opts) {
8076
8216
  });
8077
8217
  }
8078
8218
  }
8079
- await new Promise((resolve16) => setTimeout(resolve16, opts.intervalMs));
8219
+ await new Promise((resolve17) => setTimeout(resolve17, opts.intervalMs));
8080
8220
  }
8081
8221
  }
8082
8222
  async function startSession(detected) {
@@ -8122,7 +8262,11 @@ async function runSetupCloud(opts) {
8122
8262
  printDual({
8123
8263
  json: {
8124
8264
  user_code: session.user_code,
8125
- setup_url: session.setup_url,
8265
+ // The link drives the setup screen — it carries the browser key (H-3).
8266
+ // Once a browser has it, nobody else needs it, so it is not printed.
8267
+ ...opened ? {
8268
+ hint: "The setup screen was opened in the browser. If none appeared, run `memgineering setup --web --print-url` and open the printed link yourself."
8269
+ } : { setup_url: session.setup_url },
8126
8270
  opened,
8127
8271
  waiting: foreground
8128
8272
  },
@@ -8143,11 +8287,13 @@ async function runSetupCloud(opts) {
8143
8287
  detachChild(session.setup_token);
8144
8288
  return;
8145
8289
  }
8290
+ const interactive = process.stdin.isTTY === true && process.stdout.isTTY === true && !isJsonMode();
8146
8291
  const outcome = await waitAndApply(session.setup_token, {
8147
8292
  intervalMs,
8148
8293
  deadline: Date.now() + CHILD_LIFETIME_MS,
8149
8294
  detected: opts.detected,
8150
- onProgress: (line) => printHuman(c.gray(` ${line}`))
8295
+ onProgress: (line) => printHuman(c.gray(` ${line}`)),
8296
+ confirmUpload: interactive ? askBeforeUpload : null
8151
8297
  });
8152
8298
  if (outcome === "expired") {
8153
8299
  const line = "\uC124\uCE58 \uD654\uBA74\uC774 \uB9CC\uB8CC\uB410\uC5B4\uC694. \uC544\uBB34\uAC83\uB3C4 \uBC14\uB00C\uC9C0 \uC54A\uC558\uC5B4\uC694 \u2014 `memgineering setup --web`\uC744 \uB2E4\uC2DC \uC2E4\uD589\uD558\uBA74 \uC0C8 \uD654\uBA74\uC774 \uC5F4\uB824\uC694.";
@@ -8166,6 +8312,41 @@ ${line}`));
8166
8312
  );
8167
8313
  }
8168
8314
  }
8315
+ function insideFolder(target, folder) {
8316
+ const real = (p) => {
8317
+ try {
8318
+ return realpathSync2(p);
8319
+ } catch {
8320
+ return p;
8321
+ }
8322
+ };
8323
+ const root = real(resolve15(folder));
8324
+ let existing = resolve15(target);
8325
+ const rest = [];
8326
+ for (; ; ) {
8327
+ try {
8328
+ realpathSync2(existing);
8329
+ break;
8330
+ } catch {
8331
+ const parent = dirname9(existing);
8332
+ if (parent === existing) break;
8333
+ rest.unshift(basename3(existing));
8334
+ existing = parent;
8335
+ }
8336
+ }
8337
+ const landed = resolve15(real(existing), ...rest);
8338
+ return landed === root || landed.startsWith(root + sep4);
8339
+ }
8340
+ async function askBeforeUpload(q) {
8341
+ const { default: prompts6 } = await import("prompts");
8342
+ const { yes } = await prompts6({
8343
+ type: "confirm",
8344
+ name: "yes",
8345
+ message: `${q.path} \uC758 \uB178\uD2B8\uB97C ${q.email ?? "\uC774 \uACC4\uC815"} \uBE0C\uB808\uC778\uC73C\uB85C \uC62C\uB9B4\uAE4C\uC694?`,
8346
+ initial: false
8347
+ });
8348
+ return yes === true;
8349
+ }
8169
8350
  async function runSetupCloudChild(detected) {
8170
8351
  const setupToken = await readChildToken();
8171
8352
  if (setupToken === "") return;
@@ -8182,6 +8363,7 @@ var init_setup_cloud = __esm({
8182
8363
  "use strict";
8183
8364
  init_api_client();
8184
8365
  init_errors();
8366
+ init_config();
8185
8367
  init_credentials();
8186
8368
  init_open_browser();
8187
8369
  init_ui();
@@ -8196,7 +8378,7 @@ var init_setup_cloud = __esm({
8196
8378
  // src/commands/setup.ts
8197
8379
  import { createHash as createHash9 } from "crypto";
8198
8380
  import { access as access2, mkdir as mkdir15, readFile as readFile24 } from "fs/promises";
8199
- import { dirname as dirname9, join as join25 } from "path";
8381
+ import { dirname as dirname10, join as join25 } from "path";
8200
8382
  import { fileURLToPath as fileURLToPath3 } from "url";
8201
8383
  import { Command as Command40 } from "commander";
8202
8384
  import prompts5 from "prompts";
@@ -8780,7 +8962,7 @@ ${BRAND.hubMarkerEnd}`;
8780
8962
  const existing = await readFile24(filePath, "utf8").catch(() => null);
8781
8963
  if (existing === null) {
8782
8964
  if (!dryRun) {
8783
- await mkdir15(dirname9(filePath), { recursive: true });
8965
+ await mkdir15(dirname10(filePath), { recursive: true });
8784
8966
  await writeThenRename(filePath, `${block}
8785
8967
  `);
8786
8968
  }
@@ -8824,7 +9006,7 @@ async function writeIfDifferent(filePath, content, dryRun) {
8824
9006
  const after = fingerprint(content);
8825
9007
  if (existing === content) return { outcome: "unchanged", before: existing, after };
8826
9008
  if (!dryRun) {
8827
- await mkdir15(dirname9(filePath), { recursive: true });
9009
+ await mkdir15(dirname10(filePath), { recursive: true });
8828
9010
  await writeThenRename(filePath, content);
8829
9011
  }
8830
9012
  return { outcome: existing === null ? "injected" : "updated", before: existing, after };
@@ -8878,7 +9060,7 @@ async function installHook(spec, tool, dryRun) {
8878
9060
  }
8879
9061
  const next = { ...settings, hooks: { ...hooks, [spec.event]: existingGroups } };
8880
9062
  if (!dryRun) {
8881
- await mkdir15(dirname9(settingsPath), { recursive: true });
9063
+ await mkdir15(dirname10(settingsPath), { recursive: true });
8882
9064
  await writeThenRename(settingsPath, `${JSON.stringify(next, null, 2)}
8883
9065
  `);
8884
9066
  }
@@ -8918,14 +9100,14 @@ async function loadAsset(relPath) {
8918
9100
  return raw;
8919
9101
  }
8920
9102
  async function assetRoot() {
8921
- const here = dirname9(fileURLToPath3(import.meta.url));
9103
+ const here = dirname10(fileURLToPath3(import.meta.url));
8922
9104
  const bundled = join25(here, "..", "assets");
8923
9105
  if (await exists2(join25(bundled, BRAND.hubFileName))) return bundled;
8924
9106
  let dir = here;
8925
9107
  for (; ; ) {
8926
9108
  const candidate = join25(dir, "packages", "skills");
8927
9109
  if (await exists2(join25(candidate, BRAND.hubFileName))) return candidate;
8928
- const parent = dirname9(dir);
9110
+ const parent = dirname10(dir);
8929
9111
  if (parent === dir) break;
8930
9112
  dir = parent;
8931
9113
  }
@@ -9312,7 +9494,7 @@ async function reportError(kind, err) {
9312
9494
  headers: { "content-type": "application/json" },
9313
9495
  body: JSON.stringify(payload)
9314
9496
  }),
9315
- new Promise((resolve16) => setTimeout(resolve16, REPORT_TIMEOUT_MS))
9497
+ new Promise((resolve17) => setTimeout(resolve17, REPORT_TIMEOUT_MS))
9316
9498
  ]);
9317
9499
  } catch {
9318
9500
  }
@@ -11003,7 +11185,7 @@ var KordisClient = class {
11003
11185
  `[kordis] api: ${method} ${url.pathname}${url.search} \u2192 fetch failed, retrying in 150ms (${err.message})`
11004
11186
  );
11005
11187
  }
11006
- await new Promise((resolve16) => setTimeout(resolve16, 150));
11188
+ await new Promise((resolve17) => setTimeout(resolve17, 150));
11007
11189
  continue;
11008
11190
  }
11009
11191
  }
@@ -14507,11 +14689,11 @@ function lines(text, marker) {
14507
14689
  return text === "" ? [] : text.split("\n").map((l) => `${marker} ${l}`);
14508
14690
  }
14509
14691
  async function readInput(source) {
14510
- const raw = source === "-" ? await new Promise((resolve16, reject) => {
14692
+ const raw = source === "-" ? await new Promise((resolve17, reject) => {
14511
14693
  let buf = "";
14512
14694
  process.stdin.setEncoding("utf8");
14513
14695
  process.stdin.on("data", (chunk) => buf += chunk);
14514
- process.stdin.on("end", () => resolve16(buf));
14696
+ process.stdin.on("end", () => resolve17(buf));
14515
14697
  process.stdin.on("error", reject);
14516
14698
  }) : await readFile16(source, "utf8").catch(() => source);
14517
14699
  try {
@@ -17174,8 +17356,14 @@ function groupHostedLines(rules, project) {
17174
17356
  const width = ruleWidthFor(rules.length);
17175
17357
  const line = (r) => {
17176
17358
  const quoted = renderRuleLine(r.text ?? r.summary ?? r.title, width);
17177
- return r.freshness === "current" || r.freshness === void 0 ? quoted : `${quoted}
17178
- (${r.freshness === "expired" ? "past its end date" : "superseded"} \u2014 worth checking)`;
17359
+ const labels = [
17360
+ ...r.freshness === "current" || r.freshness === void 0 ? [] : [`(${r.freshness === "expired" ? "past its end date" : "superseded"} \u2014 worth checking)`],
17361
+ // Whose words these are, when a chat app's (security audit M-2). The
17362
+ // person promoted it; the agent is owed knowing where it came from.
17363
+ ...r.written_by === void 0 ? [] : [`(${connectorLabel(r.written_by)})`]
17364
+ ];
17365
+ return labels.length === 0 ? quoted : `${quoted}
17366
+ ${labels.join(" ")}`;
17179
17367
  };
17180
17368
  const globals = rules.filter((r) => r.lane === "global");
17181
17369
  const scoped = rules.filter((r) => r.lane === "project");
@@ -17896,6 +18084,7 @@ init_login();
17896
18084
  init_errors();
17897
18085
  init_api_client();
17898
18086
  init_credentials();
18087
+ init_config();
17899
18088
  init_pending_login();
17900
18089
  init_ui();
17901
18090
  import { Command as Command22 } from "commander";
@@ -17907,6 +18096,7 @@ function logoutCommand() {
17907
18096
  const base2 = apiUrl();
17908
18097
  const credentials = await readCredentials();
17909
18098
  await clearPendingLogin();
18099
+ await clearSetupFollowUps();
17910
18100
  if (!credentials) {
17911
18101
  printDual({
17912
18102
  json: { signed_out: true, was_signed_in: false, api_url: base2 },
@@ -22716,7 +22906,7 @@ function rulesCommand() {
22716
22906
  const lane = rule.lane === "project" ? oneLine(project2 ?? "this project", WIDTH.title) : "global";
22717
22907
  printHuman(
22718
22908
  c.gray(
22719
- ` \`open: ${oneLine(rule.handle, WIDTH.handle)}\`${rule.lane ? ` \xB7 ${lane}` : ""}`
22909
+ ` \`open: ${oneLine(rule.handle, WIDTH.handle)}\`${rule.lane ? ` \xB7 ${lane}` : ""}${rule.written_by === void 0 ? "" : ` \xB7 ${connectorLabel(rule.written_by)}`}`
22720
22910
  )
22721
22911
  );
22722
22912
  }
@@ -22735,7 +22925,7 @@ ${shownOf - rules2.length} more not shown.`));
22735
22925
  printHuman(`- ${oneLine(rule.summary ?? rule.title, WIDTH.text)}`);
22736
22926
  printHuman(
22737
22927
  c.gray(
22738
- ` \`open: ${oneLine(rule.handle, WIDTH.handle)}\` \xB7 ${oneLine(rule.scope ?? "", WIDTH.title)}`
22928
+ ` \`open: ${oneLine(rule.handle, WIDTH.handle)}\` \xB7 ${oneLine(rule.scope ?? "", WIDTH.title)}${rule.written_by === void 0 ? "" : ` \xB7 ${connectorLabel(rule.written_by)}`}`
22739
22929
  )
22740
22930
  );
22741
22931
  }
@@ -22885,6 +23075,23 @@ move existing notes up.
22885
23075
 
22886
23076
  The \`memgineering-setup\` skill covers what comes after.`;
22887
23077
  var SETUP_NUDGE = "memgineering is installed, but not set up yet. `memgineering setup --web` opens a setup screen in their browser and does NOT wait for them \u2014 ask them first unless they asked you to set it up.\nThe full notes: run `memgineering` on its own.";
23078
+ function connectedMarker(at) {
23079
+ return join26(brandHome(), `setup-connected-shown-${at.replace(/[^0-9A-Za-z]/g, "")}`);
23080
+ }
23081
+ async function connectedNoticeShown(at) {
23082
+ return readFile25(connectedMarker(at), "utf8").then(
23083
+ () => true,
23084
+ () => false
23085
+ );
23086
+ }
23087
+ async function markConnectedNoticeShown(at) {
23088
+ try {
23089
+ await mkdir16(brandHome(), { recursive: true });
23090
+ await writeFile13(connectedMarker(at), `${(/* @__PURE__ */ new Date()).toISOString()}
23091
+ `);
23092
+ } catch {
23093
+ }
23094
+ }
22888
23095
  async function claimFullSetupNotice() {
22889
23096
  const marker = join26(brandHome(), "setup-notice-shown");
22890
23097
  try {
@@ -22953,7 +23160,42 @@ var POINT_AT_A_BRAIN = `## Signed in, and this machine reads no brain yet.
22953
23160
  \`memgineering use --brain <name>\` points this machine at one.
22954
23161
  \`memgineering create <name>\` makes one and points here, when the account has none yet.
22955
23162
  `;
22956
- function nextStepFor(cfg, signedIn = false) {
23163
+ var UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
23164
+ function shellQuote(value) {
23165
+ return `'${value.replace(/'/g, `'\\''`)}'`;
23166
+ }
23167
+ function confirmUploadNotice(p) {
23168
+ const who = p.account_email ?? "the account `setup --web` connected";
23169
+ const brain = p.brain_id && UUID.test(p.brain_id) ? ` --brain ${p.brain_id}` : "";
23170
+ return `## Notes are waiting to be uploaded \u2014 after a check.
23171
+
23172
+ \`memgineering setup --web\` chose to upload \`${p.path}\` to a brain on **${who}**.
23173
+ This machine has not sent them: an upload decided on the setup screen waits
23174
+ until somebody here has seen which account would receive the notes.
23175
+
23176
+ If that is your user's account, this uploads them:
23177
+
23178
+ \`\`\`bash
23179
+ memgineering push --vault ${shellQuote(p.path)}${brain}
23180
+ \`\`\`
23181
+
23182
+ If it is not, \`memgineering logout\` disconnects this machine from that account
23183
+ and nothing is uploaded. If you are not sure which it is, ask them.`;
23184
+ }
23185
+ function setupConnectedNotice(c2) {
23186
+ const who = c2.email ?? "an account with no email on record";
23187
+ return `## This machine is signed in as **${who}**.
23188
+
23189
+ \`memgineering setup --web\` connected it to that account. If that is not your user's account,
23190
+ \`memgineering logout\` disconnects it, and nothing they write reaches that brain.`;
23191
+ }
23192
+ function nextStepFor(cfg, signedIn = false, seen = {}) {
23193
+ if (cfg.system.pendingUpload) {
23194
+ return { id: "confirm_upload", markdown: confirmUploadNotice(cfg.system.pendingUpload) };
23195
+ }
23196
+ if (cfg.system.setupConnected && !seen.connected) {
23197
+ return { id: "setup_connected", markdown: setupConnectedNotice(cfg.system.setupConnected) };
23198
+ }
22957
23199
  if (signedIn && cfg.brain.cloud === void 0) {
22958
23200
  return { id: "point_at_a_brain", markdown: POINT_AT_A_BRAIN };
22959
23201
  }
@@ -22970,7 +23212,9 @@ function nextStepFor(cfg, signedIn = false) {
22970
23212
  async function currentNextStep() {
22971
23213
  try {
22972
23214
  const stored = await readCredentials().catch(() => null);
22973
- return nextStepFor(await loadConfig(), stored !== null && stored.api_url === apiUrl());
23215
+ const cfg = await loadConfig();
23216
+ const connected = cfg.system.setupConnected ? await connectedNoticeShown(cfg.system.setupConnected.at) : false;
23217
+ return nextStepFor(cfg, stored !== null && stored.api_url === apiUrl(), { connected });
22974
23218
  } catch {
22975
23219
  return null;
22976
23220
  }
@@ -23663,7 +23907,7 @@ init_api_client();
23663
23907
  init_credentials();
23664
23908
  init_cloud_brain();
23665
23909
  init_config();
23666
- import { isAbsolute as isAbsolute5, relative as relative2, resolve as resolve15 } from "path";
23910
+ import { isAbsolute as isAbsolute5, relative as relative2, resolve as resolve16 } from "path";
23667
23911
  import { Command as Command44 } from "commander";
23668
23912
  init_vault();
23669
23913
  init_ui();
@@ -23693,7 +23937,7 @@ function useCommand() {
23693
23937
  await pointAtHosted(opts.brain, opts, brainPath);
23694
23938
  return;
23695
23939
  }
23696
- const where = await canonicalize(resolve15(opts.here ?? process.cwd()));
23940
+ const where = await canonicalize(resolve16(opts.here ?? process.cwd()));
23697
23941
  if (!brainPath) {
23698
23942
  const pointer = await readPointer(where);
23699
23943
  const resolved = await resolveBrain(void 0, where).catch((err) => err);
@@ -23839,7 +24083,7 @@ ${hint}`));
23839
24083
  });
23840
24084
  return;
23841
24085
  }
23842
- const root = await canonicalize(resolve15(brainPath));
24086
+ const root = await canonicalize(resolve16(brainPath));
23843
24087
  const cfg = await loadConfig();
23844
24088
  if (!findBrain(cfg, root)) {
23845
24089
  throw memgError(
@@ -23866,7 +24110,7 @@ A pointer to an unlinked folder would fail on every command instead of at this o
23866
24110
  return;
23867
24111
  }
23868
24112
  const value = isInside(root, where) ? relative2(where, root) || "." : root;
23869
- const file = resolve15(where, BRAND.pointerFileName);
24113
+ const file = resolve16(where, BRAND.pointerFileName);
23870
24114
  await writeThenRename(file, `${value}
23871
24115
  `);
23872
24116
  printDual({
@@ -24056,6 +24300,7 @@ function whoamiCommand() {
24056
24300
  }
24057
24301
 
24058
24302
  // src/program.ts
24303
+ init_config();
24059
24304
  init_ui();
24060
24305
  init_brand();
24061
24306
 
@@ -24178,6 +24423,10 @@ async function announceNextStep(commandName, reader) {
24178
24423
  const step = await currentNextStep();
24179
24424
  if (step === null) return;
24180
24425
  announced = true;
24426
+ if (step.id === "setup_connected") {
24427
+ const cfg = await loadConfig().catch(() => null);
24428
+ if (cfg?.system.setupConnected) await markConnectedNoticeShown(cfg.system.setupConnected.at);
24429
+ }
24181
24430
  if (step.id === "finish_setup" && reader !== "full") {
24182
24431
  const text = reader === "person" ? SHORT_SETUP_NOTICE : reader === "verb" && await claimFullSetupNotice() ? step.markdown : SETUP_NUDGE;
24183
24432
  process.stderr.write(`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "memgineering",
3
- "version": "0.25.0",
3
+ "version": "0.25.1",
4
4
  "private": false,
5
5
  "description": "One memory for the AI you connect. Recall, remember, and revise a brain your agents share — stored in your own folder.",
6
6
  "license": "Apache-2.0",