@krimto-labs/krimto 0.2.35 → 0.2.37

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -9,8 +9,8 @@ place and reads the right slice of it — Alice's preferences override the team'
9
9
  conventions override the org's standards, and every fact carries a paper trail (author, source,
10
10
  timestamp, reviewer).
11
11
 
12
- > **Where we are:** **v0.2.35** is the current release — the v0.2.17 wizard redesign is now
13
- > shipped end-to-end, plus eighteen patch releases of correctness fixes and agent-friendly
12
+ > **Where we are:** **v0.2.37** is the current release — the v0.2.17 wizard redesign is now
13
+ > shipped end-to-end, plus twenty patch releases of correctness fixes and agent-friendly
14
14
  > surface. The v0.2.16 architecture (markdown-in-git storage, `user → team → org` hierarchy,
15
15
  > hybrid retrieval, server-enforced access, two-way git sync, MCP over stdio + HTTP, the Docker
16
16
  > image, the web UI) is unchanged. What you get on top of v0.2.16:
@@ -41,7 +41,7 @@ timestamp, reviewer).
41
41
  > first, waits for `:8080` to accept TCP, then writes editor configs. Cursor's file
42
42
  > watcher never fires into an unbound port (the v0.2.27/28 ECONNREFUSED fix).
43
43
  >
44
- > **The agent story (v0.2.34 → v0.2.35).**
44
+ > **The agent story (v0.2.34 → v0.2.37).**
45
45
  > - **Phase B agent flags** — `editors --add cursor`, `service --always`, `search --keyword`,
46
46
  > `reset --yes`, `remote --set <url>`, `folder --to <path>`. Every command that used to
47
47
  > open an interactive prompt now has a flag form.
@@ -54,6 +54,14 @@ timestamp, reviewer).
54
54
  > Cursor chat" line works without prompting agents.
55
55
  > - **Cursor `alwaysApply: true` frontmatter** so `.cursor/rules/krimto.mdc` auto-attaches
56
56
  > instead of requiring the user to type "krimto" first.
57
+ > - **`krimto team init` lands you in team mode (v0.2.36)** — the wizard restarts the running
58
+ > service into team mode itself (no copy-paste recipe, no lock conflict), saves invite keys
59
+ > to a 0600 backup file, and validates the git remote URL at the prompt. `krimto notes` now
60
+ > works from any terminal (identity falls back to `git config user.email`).
61
+ > - **Write-time duplicate backstop (v0.2.37)** — `krimto_write` flags a near-duplicate fact in
62
+ > the same scope (a `related` list + a hint to `krimto_supersede`), so memory doesn't silently
63
+ > accumulate two facts about the same thing even when the agent skips `krimto_recall`. Backed
64
+ > by a new retrieval-quality eval that asserts recall returns the right fact first.
57
65
  >
58
66
  > See [ROADMAP.md](ROADMAP.md), [CHANGELOG.md](CHANGELOG.md), and the proposal-vs-reality
59
67
  > diff in [docs/krimto-v0.2.17-maria-journey.html §09](docs/krimto-v0.2.17-maria-journey.html)
@@ -409,14 +417,14 @@ admin surface.
409
417
 
410
418
  ### Option C — Docker (HTTP + bearer auth, containerized)
411
419
 
412
- Build the image and run it (a published image is coming):
420
+ The published multi-arch image at `ghcr.io/krimto-labs/krimto:latest` (built for `linux/amd64` and
421
+ `linux/arm64`) is the default path:
413
422
 
414
423
  ```bash
415
- docker build -t krimto .
416
424
  docker run -d --name krimto -p 8080:8080 \
417
425
  -e KRIMTO_BOOTSTRAP_ADMIN=you@acme.com \
418
426
  -v ~/.krimto:/data \
419
- krimto
427
+ ghcr.io/krimto-labs/krimto:latest
420
428
  docker logs krimto | grep "admin API key" # the key is printed once
421
429
  ```
422
430
 
@@ -424,17 +432,19 @@ The container serves MCP at `http://localhost:8080/mcp` (bearer auth) and health
424
432
  `/health/ready`; facts persist in the mounted `/data` volume. Point your agent at it with the same
425
433
  `"url"` + `Bearer` config as Option B.
426
434
 
427
- **Pulling a published image (no local build):** pushing a `v*` git tag runs
428
- [`.github/workflows/docker-publish.yml`](.github/workflows/docker-publish.yml), which publishes the
429
- image to `ghcr.io/krimto-labs/krimto`. After the first release tag you can skip `docker build` and run
430
- the published image directly:
435
+ **Building locally** only needed if you're developing Krimto or pinning to an unreleased
436
+ commit:
431
437
 
432
438
  ```bash
439
+ docker build -t krimto .
433
440
  docker run -d --name krimto -p 8080:8080 \
434
441
  -e KRIMTO_BOOTSTRAP_ADMIN=you@acme.com -v ~/.krimto:/data \
435
- ghcr.io/krimto-labs/krimto:latest
442
+ krimto
436
443
  ```
437
444
 
445
+ The published image is built and pushed by
446
+ [`.github/workflows/docker-publish.yml`](.github/workflows/docker-publish.yml) on every `v*` tag.
447
+
438
448
  ### Web UI (humans)
439
449
 
440
450
  When the HTTP server is running, open `http://localhost:8080/ui`. In local mode (no
@@ -496,9 +506,9 @@ Cline — is table stakes today, so Krimto ships it but doesn't lead with it.
496
506
 
497
507
  ## Roadmap
498
508
 
499
- `v0.2` (teams) → `v0.2.18` (UX redesign — wizards, per-note CLI, notes-app `/ui` — published) →
500
- `v0.3` (OAuth + PR approval flow) → `v1.0` (Krimto Cloud). See [ROADMAP.md](ROADMAP.md) for the
501
- per-release breakdown.
509
+ `v0.2` (teams, v0.2.5) → `v0.2.18` (v0.2.17 wizard redesign — published as one SemVer-clean
510
+ release) → `v0.2.37` (correctness + agent-friendly polish — current) → `v0.3` (OAuth + PR approval
511
+ flow) → `v1.0` (Krimto Cloud). See [ROADMAP.md](ROADMAP.md) for the per-release breakdown.
502
512
 
503
513
  ## License
504
514
 
package/bin/krimto.mjs CHANGED
@@ -521,7 +521,7 @@ try {
521
521
  const { resolveDataDir, resolveIdentity } = await tsImport("../src/server/index.ts", import.meta.url);
522
522
  const result = await runNotes({
523
523
  dataDir: resolveDataDir(),
524
- identity: resolveIdentity(),
524
+ identity: await resolveIdentity(),
525
525
  query: typeof query === "string" && query.length > 0 ? query : undefined,
526
526
  });
527
527
  process.stdout.write(result.message);
@@ -534,7 +534,7 @@ try {
534
534
  }
535
535
  const { runEdit } = await tsImport("../src/cli/edit.ts", import.meta.url);
536
536
  const { resolveDataDir, resolveIdentity } = await tsImport("../src/server/index.ts", import.meta.url);
537
- const result = await runEdit({ dataDir: resolveDataDir(), identity: resolveIdentity(), id });
537
+ const result = await runEdit({ dataDir: resolveDataDir(), identity: await resolveIdentity(), id });
538
538
  process.stdout.write(result.message);
539
539
  if (result.status !== "ok" && result.status !== "no-change") process.exitCode = 1;
540
540
  } else if (cmd === "mv") {
@@ -551,7 +551,7 @@ try {
551
551
  const { resolveDataDir, resolveIdentity } = await tsImport("../src/server/index.ts", import.meta.url);
552
552
  const result = await runMv({
553
553
  dataDir: resolveDataDir(),
554
- identity: resolveIdentity(),
554
+ identity: await resolveIdentity(),
555
555
  id,
556
556
  newScope,
557
557
  });
@@ -568,7 +568,7 @@ try {
568
568
  const { resolveDataDir, resolveIdentity } = await tsImport("../src/server/index.ts", import.meta.url);
569
569
  const result = await runSupersede({
570
570
  dataDir: resolveDataDir(),
571
- identity: resolveIdentity(),
571
+ identity: await resolveIdentity(),
572
572
  id,
573
573
  });
574
574
  process.stdout.write(result.message);
@@ -587,7 +587,7 @@ try {
587
587
  const { resolveDataDir, resolveIdentity } = await tsImport("../src/server/index.ts", import.meta.url);
588
588
  const result = await runTag({
589
589
  dataDir: resolveDataDir(),
590
- identity: resolveIdentity(),
590
+ identity: await resolveIdentity(),
591
591
  id,
592
592
  changes,
593
593
  });
@@ -603,7 +603,7 @@ try {
603
603
  }
604
604
  const { runDeleteFact } = await tsImport("../src/cli/deleteFact.ts", import.meta.url);
605
605
  const { resolveDataDir, resolveIdentity } = await tsImport("../src/server/index.ts", import.meta.url);
606
- const result = await runDeleteFact(resolveDataDir(), resolveIdentity(), id);
606
+ const result = await runDeleteFact(resolveDataDir(), await resolveIdentity(), id);
607
607
  process.stdout.write(result.message);
608
608
  if (result.status !== "ok") process.exitCode = 1;
609
609
  } else if (cmd === "reindex") {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@krimto-labs/krimto",
3
- "version": "0.2.35",
3
+ "version": "0.2.37",
4
4
  "description": "Open-source team memory layer for AI agents — markdown files in git, user/team/org hierarchy, cross-vendor MCP server.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
package/src/cli/join.ts CHANGED
@@ -11,6 +11,7 @@
11
11
 
12
12
  import { checkbox } from "@inquirer/prompts";
13
13
  import { promises as fs } from "node:fs";
14
+ import * as os from "node:os";
14
15
  import * as path from "node:path";
15
16
 
16
17
  import { applyRule } from "../agentRule";
@@ -20,6 +21,7 @@ import {
20
21
  type EditorEnvironment,
21
22
  type EditorKind,
22
23
  } from "./init";
24
+ import { inspectRuntime } from "./inspectRuntime";
23
25
  import { writeMcpConfig, type WriteAction } from "./mcpConfig";
24
26
  import { defaultIO, isExitPrompt, type WizardIO } from "./promptHelpers";
25
27
 
@@ -137,6 +139,15 @@ export async function runJoin(args: JoinArgs, opts: JoinOptions = {}): Promise<J
137
139
  try {
138
140
  io.out("\nKrimto — Joining team server\n\n");
139
141
  io.out(` Server: ${normalizeServerUrl(args.server)}\n`);
142
+
143
+ // Smoke-6 follow-up: a teammate may have an EARLIER solo-mode Krimto service running on
144
+ // this machine. After join, their editor points at the admin's server (good) — but the
145
+ // local solo service keeps listening on localhost:8080 with no auth, serving whatever's
146
+ // in their solo data dir. Print one warning line so they can `krimto stop` first.
147
+ // Non-blocking, narrow: only fires when (a) a service-launched lock is alive, (b) the
148
+ // local /mcp returns anything other than 401 (i.e. not enforcing auth).
149
+ await warnIfLocalSoloServiceRunning(args.server, opts, io);
150
+
140
151
  io.out(" Detecting your editors...\n");
141
152
 
142
153
  const cwd = opts.cwd ?? process.cwd();
@@ -201,6 +212,61 @@ async function readMaybe(p: string): Promise<string | null> {
201
212
  }
202
213
  }
203
214
 
215
+ /**
216
+ * Soft guard for the Plan-agent-flagged Risk (b): a teammate's earlier solo-mode Krimto
217
+ * service may still be running on this machine after join, serving solo notes on
218
+ * localhost:8080 with no auth. Print one warning line so they can `krimto stop` first.
219
+ * Best-effort, non-blocking — never refuses to proceed (that's the architectural fix's job).
220
+ */
221
+ async function warnIfLocalSoloServiceRunning(
222
+ teamServerArg: string,
223
+ opts: JoinOptions,
224
+ io: WizardIO,
225
+ ): Promise<void> {
226
+ const dataDir = path.join(opts.homeDir ?? os.homedir(), ".krimto");
227
+ let runtime;
228
+ try {
229
+ runtime = await inspectRuntime(
230
+ dataDir,
231
+ opts.homeDir ? { homeDir: opts.homeDir } : {},
232
+ );
233
+ } catch {
234
+ return; // probe failed — say nothing rather than scare the user
235
+ }
236
+ if (!runtime.lock || !runtime.lock.alive || runtime.effectiveLaunchedBy !== "service") return;
237
+
238
+ // Same-machine joins (admin onboarding themselves) are NOT the case we want to warn about —
239
+ // their local service IS the team server. Skip the warning when the team-server URL is
240
+ // localhost / 127.0.0.1 / the host's own hostname.
241
+ const url = teamServerArg.toLowerCase();
242
+ if (
243
+ url.includes("localhost") ||
244
+ url.includes("127.0.0.1") ||
245
+ url.includes(os.hostname().toLowerCase())
246
+ ) {
247
+ return;
248
+ }
249
+
250
+ // Probe the local /mcp endpoint. 401 means auth is enforced (team mode) → no warning.
251
+ // Anything else (200, 405, timeout) → likely solo mode → warn.
252
+ let enforcingAuth = false;
253
+ try {
254
+ const res = await fetch("http://127.0.0.1:8080/mcp", {
255
+ signal: AbortSignal.timeout(1500),
256
+ });
257
+ enforcingAuth = res.status === 401;
258
+ } catch {
259
+ /* unreachable on :8080 — fall through and warn */
260
+ }
261
+ if (enforcingAuth) return;
262
+
263
+ io.err(
264
+ `\n⚠ A local Krimto service is also running on this machine (PID ${runtime.lock.pid}).\n` +
265
+ ` It serves notes in ${dataDir} on http://localhost:8080 with no auth.\n` +
266
+ ` If you don't need it: \`krimto stop\` then re-run \`krimto join …\`.\n\n`,
267
+ );
268
+ }
269
+
204
270
  function printApplyResult(res: JoinResult, io: WizardIO): void {
205
271
  for (const o of res.editorOutcomes) {
206
272
  const label = EDITOR_LABEL[o.editor];
@@ -14,13 +14,18 @@ export interface SetupRemoteResult {
14
14
  url: string;
15
15
  }
16
16
 
17
- // Minimal guard against obvious typos. Anything with whitespace or no `/`/`:` separator
18
- // (e.g. "not a url") is rejected up front; everything else is handed to git, which is the
19
- // authoritative URL parser (accepts ssh, https, file://, bare local paths, etc.).
20
- function looksLikeRemoteUrl(url: string): boolean {
17
+ // Reject anything that isn't an obvious git remote BEFORE we hand it to `git`. Smoke-6
18
+ // transcript showed `github.com/krimto-labs/foo.git` (no protocol, no user) reaching git and
19
+ // failing only at push time by which point the wizard had already printed success messages.
20
+ // Tightened to require one of the well-known transport prefixes (or an absolute filesystem
21
+ // path). git is still the authoritative parser for everything past this gate.
22
+ const VALID_REMOTE_PREFIXES = ["git@", "https://", "http://", "ssh://", "file://"];
23
+
24
+ export function looksLikeRemoteUrl(url: string): boolean {
21
25
  if (url.trim() !== url || url.length === 0) return false;
22
26
  if (/\s/.test(url)) return false;
23
- return /[:/]/.test(url);
27
+ if (url.startsWith("/")) return true;
28
+ return VALID_REMOTE_PREFIXES.some((p) => url.startsWith(p));
24
29
  }
25
30
 
26
31
  export async function runSetupRemote(dataDir: string, url: string): Promise<SetupRemoteResult> {
@@ -47,7 +52,7 @@ export async function runSetupRemote(dataDir: string, url: string): Promise<Setu
47
52
  `\n ${url}\n` +
48
53
  `\n━━ Next ━━\n` +
49
54
  `\n To also auto-pull teammates' edits (every 60s), set on next boot:\n` +
50
- ` $ export KRIMTO_GIT_REMOTE=${url}\n` +
55
+ ` export KRIMTO_GIT_REMOTE=${url}\n` +
51
56
  `\n The batcher will auto-push every commit from now on regardless.\n`,
52
57
  };
53
58
  }
@@ -22,8 +22,10 @@ import { ApiKeyStore } from "../access/auth";
22
22
  import { addUser, createTeam } from "../access/membershipStore";
23
23
  import { bootstrapAdmin } from "../server/bootstrap";
24
24
  import { defaultIdentity } from "./init";
25
+ import { inspectRuntime } from "./inspectRuntime";
25
26
  import { defaultIO, isExitPrompt, type WizardIO } from "./promptHelpers";
26
- import { runSetupRemote } from "./setupRemote";
27
+ import { detectPlatform, installService } from "./service";
28
+ import { looksLikeRemoteUrl, runSetupRemote } from "./setupRemote";
27
29
 
28
30
  /** Everything the wizard collects before it calls {@link applyTeamInit}. */
29
31
  export interface TeamInitAnswers {
@@ -58,6 +60,13 @@ export interface TeamInitResult {
58
60
  serverHost: string;
59
61
  /** Resolved data dir the wizard wrote membership/keys into. */
60
62
  dataDir: string;
63
+ /**
64
+ * Path to the 0600-mode invite file written at apply time, when any keys were minted.
65
+ * Unset when there was nothing new to save (idempotent rerun where admin + all teammates
66
+ * already had keys). Admin's key is shown-once-only — losing it from scrollback used to
67
+ * require `reset-admin-key`; the file is the recoverable backup.
68
+ */
69
+ inviteFilePath?: string;
61
70
  }
62
71
 
63
72
  export interface TeamInitOptions {
@@ -68,6 +77,14 @@ export interface TeamInitOptions {
68
77
  keysPath?: string;
69
78
  /** Override KRIMTO_HTTP_PORT detection. */
70
79
  port?: number;
80
+ /** Override os.homedir() — passed to inspectRuntime + installService for tests. */
81
+ homeDir?: string;
82
+ /** When true, the post-apply service-restart step writes files but never invokes
83
+ * launchctl/systemctl/schtasks. Tests use this to assert the new env block without
84
+ * mutating CI's user services. */
85
+ dryRun?: boolean;
86
+ /** Suppress the post-apply service-restart probe entirely (tests that don't exercise it). */
87
+ skipServiceRestart?: boolean;
71
88
  /** Skip `runSetupRemote` even when a URL was given. Tests use this. */
72
89
  skipRemoteSetup?: boolean;
73
90
  }
@@ -123,6 +140,24 @@ export async function applyTeamInit(
123
140
  }
124
141
 
125
142
  const port = opts.port ?? Number(process.env.KRIMTO_HTTP_PORT ?? "8080");
143
+ const serverHost = `localhost:${port}`;
144
+
145
+ // 5. Save plaintext keys + DM template to a 0600 file. Admin's key is shown-once-only;
146
+ // teammates' keys are shown-once-each. Without this file the only recovery is
147
+ // `reset-admin-key` (admin) or re-issuing each invite (teammates) — both unnecessary churn.
148
+ let inviteFilePath: string | undefined;
149
+ if (adminKey || invites.length > 0) {
150
+ inviteFilePath = await writeInviteFile({
151
+ dataDir,
152
+ adminEmail: answers.adminEmail,
153
+ adminKey,
154
+ teamSlug: answers.teamSlug,
155
+ teamName: answers.teamName,
156
+ invites,
157
+ serverHost,
158
+ });
159
+ }
160
+
126
161
  return {
127
162
  adminEmail: answers.adminEmail,
128
163
  adminKey,
@@ -130,11 +165,60 @@ export async function applyTeamInit(
130
165
  teamName: answers.teamName,
131
166
  remote,
132
167
  invites,
133
- serverHost: `localhost:${port}`,
168
+ serverHost,
134
169
  dataDir,
170
+ ...(inviteFilePath ? { inviteFilePath } : {}),
135
171
  };
136
172
  }
137
173
 
174
+ /**
175
+ * Write the team-invite file to `<dataDir>/.krimto/team-invites-<ISO-timestamp>.txt` with
176
+ * mode 0o600. Filename's timestamp colons are replaced with dashes so the path is portable
177
+ * across filesystems. Returns the absolute path.
178
+ */
179
+ async function writeInviteFile(input: {
180
+ dataDir: string;
181
+ adminEmail: string;
182
+ adminKey: string | null;
183
+ teamSlug: string;
184
+ teamName?: string;
185
+ invites: InviteRecord[];
186
+ serverHost: string;
187
+ }): Promise<string> {
188
+ const ts = new Date().toISOString().replace(/:/g, "-").replace(/\.\d+Z$/, "Z");
189
+ const file = path.join(input.dataDir, ".krimto", `team-invites-${ts}.txt`);
190
+ const teamLabel = input.teamName ? `${input.teamSlug} ("${input.teamName}")` : input.teamSlug;
191
+ const adminBlock = input.adminKey
192
+ ? `━━ Admin ━━\n ${input.adminEmail.padEnd(28)} ${input.adminKey}\n\n`
193
+ : `━━ Admin ━━\n ${input.adminEmail} — existing key kept (no new one minted).\n\n`;
194
+ const inviteBlock =
195
+ input.invites.length > 0
196
+ ? "━━ Teammates (send each their key) ━━\n" +
197
+ input.invites.map((i) => ` ${i.email.padEnd(28)} ${i.key}`).join("\n") +
198
+ "\n\n"
199
+ : "";
200
+ const dmTemplate =
201
+ "━━ DM template (one per teammate) ━━\n" +
202
+ " 1. Install Krimto:\n" +
203
+ ` npx @krimto-labs/krimto join \\\n` +
204
+ ` --server http://${input.serverHost} \\\n` +
205
+ ` --key <your key from above>\n` +
206
+ " 2. Restart your editor.\n" +
207
+ " 3. Test: \"Remember our package manager is pnpm\" → new chat → \"What do we use?\"\n\n" +
208
+ (input.serverHost.startsWith("localhost:")
209
+ ? " Note: `localhost` only works if your teammates are on this same machine.\n" +
210
+ " Swap the --server URL for a reachable address before sharing.\n"
211
+ : "");
212
+ const body =
213
+ `Krimto team mode — saved ${new Date().toISOString()}\n` +
214
+ `Team: ${teamLabel}\n\n` +
215
+ adminBlock +
216
+ inviteBlock +
217
+ dmTemplate;
218
+ await fs.writeFile(file, body, { encoding: "utf8", mode: 0o600 });
219
+ return file;
220
+ }
221
+
138
222
  // === Interactive entry point ===============================================
139
223
 
140
224
  /**
@@ -166,7 +250,14 @@ export async function runTeamInit(opts: TeamInitOptions = {}): Promise<TeamInitR
166
250
  { adminEmail, teamSlug, teamName, gitRemote, teammates },
167
251
  opts,
168
252
  );
169
- printApplyResult(result, io);
253
+
254
+ // Probe runtime + offer to restart the always-running service so team-mode auth takes
255
+ // effect on the SAME machine, in the SAME wizard run. The smoke-6 transcript ended with
256
+ // a copy-paste "Next" recipe (`KRIMTO_BOOTSTRAP_ADMIN=... npx serve`) the user couldn't
257
+ // run because their existing service held the data-dir lock. This closes that gap.
258
+ const restartOutcome = await maybeRestartServiceForTeamMode(result, opts);
259
+
260
+ printApplyResult(result, io, restartOutcome);
170
261
  return result;
171
262
  } catch (e) {
172
263
  if (isExitPrompt(e)) {
@@ -231,7 +322,14 @@ async function askGitRemote(io: WizardIO): Promise<string | undefined> {
231
322
  const url = (
232
323
  await input({
233
324
  message: "Remote URL (e.g. git@github.com:acme/krimto-data.git)",
234
- validate: (v) => (v.trim().length > 0 ? true : "Please paste a URL or pick 'Not yet'"),
325
+ validate: (v) => {
326
+ const t = v.trim();
327
+ if (t.length === 0) return "Please paste a URL or pick 'Not yet'";
328
+ if (!looksLikeRemoteUrl(t)) {
329
+ return "URL must start with git@, https://, http://, ssh://, file://, or / (no bare 'github.com/...')";
330
+ }
331
+ return true;
332
+ },
235
333
  })
236
334
  ).trim();
237
335
  io.out("Will verify the push during apply.\n");
@@ -254,6 +352,74 @@ async function askTeammates(): Promise<string[]> {
254
352
  return list;
255
353
  }
256
354
 
355
+ // === Post-apply service restart =============================================
356
+
357
+ /**
358
+ * What happened when the wizard tried to flip the running service into team mode after apply.
359
+ * Drives the "Next" block in {@link printApplyResult}: when team mode is live, we don't print
360
+ * the copy-paste `KRIMTO_BOOTSTRAP_ADMIN=... npx serve` recipe.
361
+ */
362
+ export type RestartOutcome =
363
+ | { kind: "no-service" }
364
+ | { kind: "declined" }
365
+ | { kind: "restarted"; portReady: boolean }
366
+ | { kind: "failed"; error: string };
367
+
368
+ /**
369
+ * If the running Krimto IS the always-running service we installed, offer to restart it with
370
+ * `KRIMTO_BOOTSTRAP_ADMIN` baked in so team-mode auth takes effect immediately. Returns the
371
+ * outcome for the print step to render. Skipped silently when {@link TeamInitOptions.skipServiceRestart}
372
+ * is set (tests that don't want to exercise this path).
373
+ */
374
+ async function maybeRestartServiceForTeamMode(
375
+ result: TeamInitResult,
376
+ opts: TeamInitOptions,
377
+ ): Promise<RestartOutcome> {
378
+ if (opts.skipServiceRestart) return { kind: "no-service" };
379
+ const io = opts.io ?? defaultIO;
380
+
381
+ const runtime = await inspectRuntime(
382
+ result.dataDir,
383
+ opts.homeDir ? { homeDir: opts.homeDir } : {},
384
+ );
385
+ if (!runtime.service.loaded || runtime.effectiveLaunchedBy !== "service") {
386
+ return { kind: "no-service" };
387
+ }
388
+
389
+ const wantRestart = await confirm({
390
+ message: "Restart the running Krimto service so team mode is enforced now? (~3s of downtime)",
391
+ default: true,
392
+ });
393
+ if (!wantRestart) return { kind: "declined" };
394
+
395
+ io.out("\n Restarting service in team mode...\n");
396
+ try {
397
+ const install = await installService(
398
+ {
399
+ binPath: process.execPath,
400
+ args: [process.argv[1] ?? "krimto", "serve"],
401
+ env: {
402
+ KRIMTO_IDENTITY: result.adminEmail,
403
+ KRIMTO_DATA: result.dataDir,
404
+ KRIMTO_HTTP_PORT: "8080",
405
+ KRIMTO_BOOTSTRAP_ADMIN: result.adminEmail,
406
+ },
407
+ ...(opts.homeDir ? { homeDir: opts.homeDir } : {}),
408
+ },
409
+ {
410
+ platform: detectPlatform(),
411
+ ...(opts.dryRun ? { dryRun: opts.dryRun } : {}),
412
+ },
413
+ );
414
+ return { kind: "restarted", portReady: install.portReady !== false };
415
+ } catch (e) {
416
+ return { kind: "failed", error: e instanceof Error ? e.message : String(e) };
417
+ }
418
+ }
419
+
420
+ // Exposed for the integration test that asserts on the env block written to the plist.
421
+ export { maybeRestartServiceForTeamMode };
422
+
257
423
  // === Pretty printing ========================================================
258
424
 
259
425
  function printPreamble(dataDir: string, io: WizardIO): void {
@@ -273,7 +439,7 @@ function printSummary(a: TeamInitAnswers, io: WizardIO): void {
273
439
  io.out(` Teammates: ${a.teammates.length === 0 ? "(none yet)" : a.teammates.join(", ")}\n\n`);
274
440
  }
275
441
 
276
- function printApplyResult(res: TeamInitResult, io: WizardIO): void {
442
+ function printApplyResult(res: TeamInitResult, io: WizardIO, restart: RestartOutcome): void {
277
443
  io.out(" ✓ Admin promoted + members.yaml updated\n");
278
444
  io.out(` ✓ Team "${res.teamSlug}" created\n`);
279
445
  if (res.invites.length > 0) {
@@ -302,7 +468,7 @@ function printApplyResult(res: TeamInitResult, io: WizardIO): void {
302
468
  }
303
469
  io.out("\n━━ DM template for each teammate ━━\n\n");
304
470
  io.out(" 1. Install Krimto:\n");
305
- io.out(` $ npx @krimto-labs/krimto join \\\n`);
471
+ io.out(` npx @krimto-labs/krimto join \\\n`);
306
472
  io.out(` --server http://${res.serverHost} \\\n`);
307
473
  io.out(` --key <your key from above>\n`);
308
474
  io.out(" 2. Restart your editor.\n");
@@ -316,9 +482,31 @@ function printApplyResult(res: TeamInitResult, io: WizardIO): void {
316
482
  }
317
483
  }
318
484
 
485
+ if (res.inviteFilePath) {
486
+ io.out("━━ Backup ━━\n\n");
487
+ io.out(` All keys + the DM template are also saved to:\n ${res.inviteFilePath}\n`);
488
+ io.out(` (mode 0600 — read by you only. Delete it once teammates have their keys.)\n\n`);
489
+ }
490
+
491
+ // The "Next" block depends on whether the running service was just flipped into team mode.
492
+ // When it was, the user's already done — just hand them the dashboard URL. When it wasn't
493
+ // (no service, declined, or install failed), fall back to the original copy-paste recipe.
319
494
  io.out("━━ Next ━━\n\n");
320
- io.out(" • Start the server in team mode (if not already):\n");
321
- io.out(` $ KRIMTO_BOOTSTRAP_ADMIN=${res.adminEmail} npx @krimto-labs/krimto serve\n`);
322
- io.out(" • View the dashboard at http://localhost:8080/ui/admin\n");
495
+ if (restart.kind === "restarted" && restart.portReady) {
496
+ io.out(` 🟢 Team mode is live on http://localhost:8080\n`);
497
+ io.out(` • View the dashboard at http://localhost:8080/ui/admin\n`);
498
+ } else if (restart.kind === "restarted" && !restart.portReady) {
499
+ io.out(` ⚠ Service restarted in team mode, but the port didn't come up in 10s.\n`);
500
+ io.out(` Check /tmp/com.krimto.server.err.log (macOS) or \`journalctl --user -u krimto\` (Linux).\n`);
501
+ io.out(` • View the dashboard at http://localhost:8080/ui/admin (once the port is up)\n`);
502
+ } else if (restart.kind === "failed") {
503
+ io.out(` ⚠ Service restart failed: ${restart.error}\n`);
504
+ io.out(` Start it yourself: KRIMTO_BOOTSTRAP_ADMIN=${res.adminEmail} npx @krimto-labs/krimto serve\n`);
505
+ } else {
506
+ // "declined" or "no-service" — print the original recipe (still without the literal `$`).
507
+ io.out(" • Start the server in team mode (if not already):\n");
508
+ io.out(` KRIMTO_BOOTSTRAP_ADMIN=${res.adminEmail} npx @krimto-labs/krimto serve\n`);
509
+ io.out(" • View the dashboard at http://localhost:8080/ui/admin\n");
510
+ }
323
511
  io.out(" • Step back to solo with `krimto team disband` (data preserved)\n\n");
324
512
  }
package/src/cli/wizard.ts CHANGED
@@ -258,7 +258,7 @@ async function runFreshWizard(
258
258
  ): Promise<ApplyResult | null> {
259
259
  io.out(`\nKrimto — Setting up your AI's memory · v${KRIMTO_VERSION}\n\n`);
260
260
  const envs = await detectEditorEnvironments(cwd, opts.homeDir);
261
- printScan(envs, io);
261
+ printScan(envs, snapshot?.registeredEditors ?? [], io);
262
262
 
263
263
  // v0.2.31 — Gap D, "Keep current" intermediate prompt. On reconfigure runs (snapshot !==
264
264
  // null), each question is wrapped in a two-stage "Keep current / Reconfigure..." select so
@@ -498,16 +498,22 @@ async function askSearch(
498
498
 
499
499
  // === Pretty printing ========================================================
500
500
 
501
- function printScan(envs: EditorEnvironment[], io: WizardIO): void {
501
+ function printScan(envs: EditorEnvironment[], registered: EditorKind[], io: WizardIO): void {
502
+ // Four states. The crucial split is "detected" vs "connected to Krimto": the smoke-6 user saw
503
+ // four detected editors then "Keep current (Cursor, Claude Code)" and didn't realize the other
504
+ // two weren't actually wired to Krimto's MCP. Now the scan output names that distinction.
505
+ const wired = new Set(registered);
502
506
  io.out(" Scanning your machine ...\n\n");
503
507
  for (const env of envs) {
504
- // v0.2.21: three states — project-level, machine-level only, not found.
505
- const dot = env.present ? "✓" : env.installed ? "~" : "–";
506
- const note = env.present
507
- ? "detected (in this project)"
508
- : env.installed
509
- ? "installed (machine-wide)"
510
- : "not found";
508
+ const isWired = wired.has(env.editor);
509
+ const dot = isWired ? "✓" : env.present ? "✓" : env.installed ? "~" : "–";
510
+ const note = isWired
511
+ ? "connected to Krimto"
512
+ : env.present
513
+ ? "detected, not yet connected"
514
+ : env.installed
515
+ ? "installed (machine-wide), not connected"
516
+ : "not found";
511
517
  io.out(` ${dot} ${EDITOR_LABEL[env.editor].padEnd(14)} ${note}\n`);
512
518
  }
513
519
  io.out("\n");
@@ -3,15 +3,18 @@
3
3
 
4
4
  import { connectSnippets } from "./connect";
5
5
 
6
- /** The placeholder identity that resolves when KRIMTO_IDENTITY is unset. Keep in sync with resolveIdentity(). */
6
+ /** The placeholder identity that resolves when KRIMTO_IDENTITY is unset AND git config user.email is
7
+ * unset/invalid. Keep in sync with resolveIdentity()'s final fallback. */
7
8
  export const DEFAULT_IDENTITY = "user@localhost";
8
9
 
9
10
  /**
10
11
  * G2 — warn when the resolved identity is the unset-placeholder default. Two Krimto processes
11
12
  * on the same data dir (e.g. Cursor's stdio launch + a separate `serve` in her terminal) often
12
13
  * resolve to different identities — the MCP config sets one, the bare shell doesn't. The result
13
- * is scope mismatch: she writes facts under one identity and sees a different scope in /ui.
14
- * Returns an empty string when the identity was explicitly set.
14
+ * is scope mismatch: facts saved under one identity, viewed under another in /ui. After the
15
+ * smoke-6 fix, `resolveIdentity()` falls back to global git user.email before this placeholder,
16
+ * so the warning only fires when both sources are missing.
17
+ * Returns an empty string when a real identity was resolved.
15
18
  */
16
19
  export function identityWarning(identity: string): string {
17
20
  if (identity !== DEFAULT_IDENTITY) return "";
@@ -8,6 +8,11 @@ import { fileURLToPath } from "node:url";
8
8
  import { homedir } from "node:os";
9
9
  import * as path from "node:path";
10
10
  import { promises as fs } from "node:fs";
11
+ import { execFile } from "node:child_process";
12
+ import { promisify } from "node:util";
13
+
14
+ const execFileAsync = promisify(execFile);
15
+ const IDENTITY_EMAIL_RE = /^[^@\s]+@[^@\s]+$/;
11
16
 
12
17
  import { ApiKeyStore } from "../access/auth";
13
18
  import { bootstrapAdmin, reissueKey } from "./bootstrap";
@@ -49,14 +54,36 @@ import { type Requester } from "../access/scope";
49
54
 
50
55
  export type RequesterResolver = (extra: { authInfo?: AuthInfo }) => Requester;
51
56
 
52
- export const KRIMTO_VERSION = "0.2.35";
57
+ export const KRIMTO_VERSION = "0.2.37";
53
58
 
54
59
  export function resolveDataDir(): string {
55
60
  return process.env.KRIMTO_DATA ?? path.join(homedir(), ".krimto");
56
61
  }
57
62
 
58
- export function resolveIdentity(): string {
59
- return process.env.KRIMTO_IDENTITY ?? "user@localhost";
63
+ /**
64
+ * Resolve the caller's identity in three steps: explicit env override, then the user's global
65
+ * git identity, then a last-resort placeholder.
66
+ *
67
+ * The git fallback closes the smoke-6 UX gap. The wizard sets `KRIMTO_IDENTITY` in editor MCP
68
+ * configs and the service plist — but not in the user's shell rc. Without the git fallback,
69
+ * a plain-terminal `krimto notes` ran as `user@localhost` and couldn't see facts the editor
70
+ * had saved under the wizard-configured identity — same data dir, two answers depending on
71
+ * shell env. The CLI now infers the same identity the wizard would have captured.
72
+ *
73
+ * Stays async because the git lookup shells out; every call site is already inside an async
74
+ * handler. Malformed env values fall through (we never persist a non-email as an identity).
75
+ */
76
+ export async function resolveIdentity(): Promise<string> {
77
+ const env = process.env.KRIMTO_IDENTITY;
78
+ if (env && IDENTITY_EMAIL_RE.test(env)) return env;
79
+ try {
80
+ const { stdout } = await execFileAsync("git", ["config", "--global", "user.email"]);
81
+ const email = stdout.trim();
82
+ if (IDENTITY_EMAIL_RE.test(email)) return email;
83
+ } catch {
84
+ /* git missing, no global user.email — fall through to the placeholder */
85
+ }
86
+ return "user@localhost";
60
87
  }
61
88
 
62
89
  function ok(data: unknown): CallToolResult {
@@ -94,7 +121,9 @@ export function buildServer(ctx: ToolContext, resolveRequester?: RequesterResolv
94
121
  "durable fact, or when correcting a mistake you should not repeat. For the user's personal " +
95
122
  "scope use `user/me` (the server resolves it to their identity) — do not guess an email. The " +
96
123
  "write is rejected (with the list of scopes you may write to) if you target a scope you couldn't " +
97
- "read back. Call krimto_recall first to avoid duplicates.",
124
+ "read back. Call krimto_recall first to avoid duplicates — and if the write response includes a " +
125
+ "`related` list, those are near-duplicates already in this scope: prefer krimto_supersede on one " +
126
+ "of them over leaving a second copy.",
98
127
  inputSchema: {
99
128
  scope: z
100
129
  .string()
@@ -281,7 +310,7 @@ export async function main(): Promise<void> {
281
310
 
282
311
  // Load membership AFTER bootstrap so the new admin is present.
283
312
  let membership = await loadMembership(dataDir);
284
- const identity = resolveIdentity();
313
+ const identity = await resolveIdentity();
285
314
  const embedCfg = embeddingConfigFromEnv();
286
315
  const embeddingProvider = createEmbeddingProvider(embedCfg);
287
316
  const indexConfig: IndexConfig = {
@@ -9,7 +9,7 @@ import { isValidScope, type Requester } from "../access/scope";
9
9
  import { canRead, canWrite, isOrgAdmin, type Membership } from "../access/membership";
10
10
  import { FactIndex } from "../index/factIndex";
11
11
  import { Serializer } from "../index/serialize";
12
- import { rankCandidates } from "../retrieval/pipeline";
12
+ import { lexicalSimilarity, rankCandidates } from "../retrieval/pipeline";
13
13
  import { type CommitBatcher } from "../storage/batcher";
14
14
  import { type ActivityLog } from "./activity";
15
15
  import { KrimtoError } from "./errors";
@@ -47,6 +47,12 @@ export interface WriteInput {
47
47
  source?: string;
48
48
  supersedes?: string[];
49
49
  }
50
+ export interface RelatedFact {
51
+ id: string;
52
+ title: string;
53
+ /** Hybrid-retrieval score of the existing fact against the new one's title+body. */
54
+ score: number;
55
+ }
50
56
  export interface WriteResult {
51
57
  id: string;
52
58
  scope: string;
@@ -56,6 +62,12 @@ export interface WriteResult {
56
62
  /** Human-readable hint for the agent to relay back. Teaches "this is just a file you can open." */
57
63
  hint: string;
58
64
  commit_sha: string | null;
65
+ /**
66
+ * Existing facts in the same scope that closely resemble the one just written. Surfaced so a
67
+ * weak agent that skipped krimto_recall still gets a chance to krimto_supersede instead of
68
+ * duplicating. Omitted when nothing similar was found.
69
+ */
70
+ related?: RelatedFact[];
59
71
  }
60
72
 
61
73
  export interface RecallInput {
@@ -148,6 +160,39 @@ function writableScopesFor(ctx: ToolContext): string[] {
148
160
  return scopes;
149
161
  }
150
162
 
163
+ /**
164
+ * Token-cosine bar above which an existing fact is "the same thing, said again" rather than
165
+ * merely sharing a word. Tuned from the smoke-6 cases: a near-duplicate (pizza vs pizza+sushi)
166
+ * scores ~0.8 and a same-topic update (pizza vs tacos) ~0.7, while two facts that only share a
167
+ * generic qualifier (favorite FOOD vs favorite COLOR) score ~0.38. 0.5 sits cleanly between.
168
+ */
169
+ const DUPLICATE_SIMILARITY_THRESHOLD = 0.5;
170
+
171
+ /**
172
+ * Existing facts in `scope` that closely resemble `${title} ${body}` — the server-side backstop
173
+ * for the "call krimto_recall first" rule. FTS narrows the candidate set; token cosine then
174
+ * filters out facts that merely share a generic word. Excludes anything the new write already
175
+ * supersedes (no point nagging about a fact it's replacing). Top 3, most-similar first.
176
+ */
177
+ async function findRelatedFacts(
178
+ ctx: ToolContext,
179
+ scope: string,
180
+ title: string,
181
+ body: string,
182
+ supersedes: string[] | undefined,
183
+ now: Date,
184
+ ): Promise<RelatedFact[]> {
185
+ const text = `${title} ${body}`;
186
+ const candidates = await ctx.index.searchCandidates(text, { readableScopes: [scope], now });
187
+ const excluded = new Set(supersedes ?? []);
188
+ return candidates
189
+ .filter((c) => !excluded.has(c.id))
190
+ .map((c) => ({ id: c.id, title: c.title, score: lexicalSimilarity(text, `${c.title} ${c.body}`) }))
191
+ .filter((r) => r.score >= DUPLICATE_SIMILARITY_THRESHOLD)
192
+ .sort((a, b) => b.score - a.score)
193
+ .slice(0, 3);
194
+ }
195
+
151
196
  /** Create a new fact. Author comes from the requester identity; scope is required. */
152
197
  export async function krimtoWrite(ctx: ToolContext, input: WriteInput): Promise<WriteResult> {
153
198
  // "user/me"/"user/self" (and bare "me"/"self") mean the caller's own personal scope. An agent
@@ -179,6 +224,14 @@ export async function krimtoWrite(ctx: ToolContext, input: WriteInput): Promise<
179
224
  });
180
225
  }
181
226
  return ctx.writeQueue.run(async () => {
227
+ // Run the near-duplicate check BEFORE the new fact is indexed, so it can't match itself.
228
+ // Best-effort: a failure here must never block a write — the hint is observational.
229
+ let related: RelatedFact[] = [];
230
+ try {
231
+ related = await findRelatedFacts(ctx, scope, input.title, input.body, input.supersedes, clock(ctx));
232
+ } catch {
233
+ /* dedup hint is advisory — never let it break the write path */
234
+ }
182
235
  const fact = createFact({
183
236
  scope,
184
237
  title: input.title,
@@ -224,6 +277,12 @@ export async function krimtoWrite(ctx: ToolContext, input: WriteInput): Promise<
224
277
  `git auto-commits every 30s, run \`npx @krimto-labs/krimto --help\` for the full CLI surface, ` +
225
278
  `or \`npx @krimto-labs/krimto storage\` for the storage model.)`;
226
279
  }
280
+ if (related.length > 0) {
281
+ const list = related.map((r) => `"${r.title}" (${r.id})`).join(", ");
282
+ hint +=
283
+ `\n⚠ Similar existing fact${related.length > 1 ? "s" : ""} in this scope: ${list}. ` +
284
+ `If this updates ${related.length > 1 ? "one of them" : "it"}, call krimto_supersede instead of leaving a duplicate.`;
285
+ }
227
286
  return {
228
287
  id: fact.frontmatter.id,
229
288
  scope: fact.frontmatter.scope,
@@ -231,6 +290,7 @@ export async function krimtoWrite(ctx: ToolContext, input: WriteInput): Promise<
231
290
  absolute_path: absolutePath,
232
291
  hint,
233
292
  commit_sha: null,
293
+ ...(related.length > 0 ? { related } : {}),
234
294
  };
235
295
  });
236
296
  }