@krimto-labs/krimto 0.2.36 → 0.2.38

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.36** is the current release — the v0.2.17 wizard redesign is now
13
- > shipped end-to-end, plus nineteen patch releases of correctness fixes and agent-friendly
12
+ > **Where we are:** **v0.2.38** is the current release — the v0.2.17 wizard redesign is now
13
+ > shipped end-to-end, plus twenty-one 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.36).**
44
+ > **The agent story (v0.2.34 → v0.2.38).**
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.
@@ -58,6 +58,14 @@ timestamp, reviewer).
58
58
  > service into team mode itself (no copy-paste recipe, no lock conflict), saves invite keys
59
59
  > to a 0600 backup file, and validates the git remote URL at the prompt. `krimto notes` now
60
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.
65
+ > - **Team mode activates live (v0.2.38)** — `krimto team init` writes `members.yaml` and the
66
+ > running server flips to team mode on its own within ~2s (it polls the file) — no restart, no
67
+ > env var. The wizard verifies auth is genuinely enforced before printing "🟢 live", and
68
+ > auto-reconnects the admin's own editor with their key.
61
69
  >
62
70
  > See [ROADMAP.md](ROADMAP.md), [CHANGELOG.md](CHANGELOG.md), and the proposal-vs-reality
63
71
  > diff in [docs/krimto-v0.2.17-maria-journey.html §09](docs/krimto-v0.2.17-maria-journey.html)
@@ -503,7 +511,7 @@ Cline — is table stakes today, so Krimto ships it but doesn't lead with it.
503
511
  ## Roadmap
504
512
 
505
513
  `v0.2` (teams, v0.2.5) → `v0.2.18` (v0.2.17 wizard redesign — published as one SemVer-clean
506
- release) → `v0.2.36` (correctness + agent-friendly polish — current) → `v0.3` (OAuth + PR approval
514
+ release) → `v0.2.38` (correctness + agent-friendly polish — current) → `v0.3` (OAuth + PR approval
507
515
  flow) → `v1.0` (Krimto Cloud). See [ROADMAP.md](ROADMAP.md) for the per-release breakdown.
508
516
 
509
517
  ## License
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@krimto-labs/krimto",
3
- "version": "0.2.36",
3
+ "version": "0.2.38",
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",
@@ -80,6 +80,21 @@ export function isOrgAdmin(m: Membership, email: string): boolean {
80
80
  return m.org.admins.includes(email);
81
81
  }
82
82
 
83
+ /** True when the org has at least one admin — the live signal that team mode should be enforced. */
84
+ export function hasOrgAdmin(m: Membership): boolean {
85
+ return m.org.admins.length > 0;
86
+ }
87
+
88
+ /**
89
+ * Guard for LIVE membership reloads: refuse to adopt a reload that would drop the last admin,
90
+ * because that silently turns auth OFF on a running server. A transient/mid-write read that
91
+ * momentarily parses zero admins must NOT open an auth-off window. Turning team mode off is a
92
+ * deliberate, restart-gated operator action — never an automatic side effect of a file watch.
93
+ */
94
+ export function shouldAdoptReload(current: Membership, next: Membership): boolean {
95
+ return !(hasOrgAdmin(current) && !hasOrgAdmin(next));
96
+ }
97
+
83
98
  export function isTeamLead(m: Membership, teamSlug: string, email: string): boolean {
84
99
  const team = m.teams.find((t) => t.slug === teamSlug);
85
100
  return team ? team.leads.includes(email) : false;
@@ -22,9 +22,8 @@ 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
+ import { applyJoin } from "./join";
26
26
  import { defaultIO, isExitPrompt, type WizardIO } from "./promptHelpers";
27
- import { detectPlatform, installService } from "./service";
28
27
  import { looksLikeRemoteUrl, runSetupRemote } from "./setupRemote";
29
28
 
30
29
  /** Everything the wizard collects before it calls {@link applyTeamInit}. */
@@ -77,18 +76,26 @@ export interface TeamInitOptions {
77
76
  keysPath?: string;
78
77
  /** Override KRIMTO_HTTP_PORT detection. */
79
78
  port?: number;
80
- /** Override os.homedir() — passed to inspectRuntime + installService for tests. */
79
+ /** Override os.homedir() — forwarded to the admin-reconnect `applyJoin` for editor detection. */
81
80
  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. */
81
+ /** When true, the admin-reconnect `applyJoin` writes nothing to real editor configs. Tests use this. */
85
82
  dryRun?: boolean;
86
- /** Suppress the post-apply service-restart probe entirely (tests that don't exercise it). */
87
- skipServiceRestart?: boolean;
83
+ /** Inject the live-team-mode probe (tests). Defaults to the real {@link confirmTeamModeLive}
84
+ * which polls the running server's /mcp for a 401. */
85
+ confirmLive?: (host: string) => Promise<LiveOutcome>;
88
86
  /** Skip `runSetupRemote` even when a URL was given. Tests use this. */
89
87
  skipRemoteSetup?: boolean;
90
88
  }
91
89
 
90
+ /**
91
+ * Result of verifying that the running server is ACTUALLY enforcing team mode (not just that a
92
+ * port is open). The old wizard printed "🟢 live" off a TCP probe and lied; this is the honest signal.
93
+ * - "live" — the server returned 401 on /mcp ⇒ bearer required ⇒ team mode enforced.
94
+ * - "no-server" — nothing reachable at the host (start one with `krimto serve`).
95
+ * - "timeout" — a server responded but never 401 within the window (it polls members.yaml ~2s).
96
+ */
97
+ export type LiveOutcome = "live" | "no-server" | "timeout";
98
+
92
99
  const SLUG_RE = /^[a-z0-9](?:[a-z0-9_-]*[a-z0-9])?$/;
93
100
  const EMAIL_RE = /^[^@\s]+@[^@\s]+$/;
94
101
 
@@ -251,13 +258,31 @@ export async function runTeamInit(opts: TeamInitOptions = {}): Promise<TeamInitR
251
258
  opts,
252
259
  );
253
260
 
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);
261
+ // No restart, no env var: writing members.yaml IS the switch. The running server polls the
262
+ // file (~2s) and flips to team mode on its own. We just VERIFY it actually happened — poll
263
+ // /mcp for a 401 instead of the old TCP probe that falsely printed "🟢 live".
264
+ const live = await (opts.confirmLive ?? confirmTeamModeLive)(result.serverHost);
265
+
266
+ // When team mode is confirmed live, reconnect the admin's OWN editors in team mode (their
267
+ // solo/no-key connection would otherwise start getting 401s). Reuses the teammate join path.
268
+ let reconnected = false;
269
+ if (live === "live" && result.adminKey) {
270
+ try {
271
+ const join = await applyJoin(
272
+ { server: `http://${result.serverHost}`, key: result.adminKey },
273
+ {
274
+ cwd: process.cwd(),
275
+ ...(opts.homeDir ? { homeDir: opts.homeDir } : {}),
276
+ ...(opts.dryRun ? { dryRun: opts.dryRun } : {}),
277
+ },
278
+ );
279
+ reconnected = join.editorOutcomes.some((o) => o.mcpAction !== "manual");
280
+ } catch {
281
+ /* best-effort — the admin can run `krimto join` manually if this fails */
282
+ }
283
+ }
259
284
 
260
- printApplyResult(result, io, restartOutcome);
285
+ printApplyResult(result, io, { live, reconnected });
261
286
  return result;
262
287
  } catch (e) {
263
288
  if (isExitPrompt(e)) {
@@ -352,74 +377,33 @@ async function askTeammates(): Promise<string[]> {
352
377
  return list;
353
378
  }
354
379
 
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 };
380
+ // === Verify team mode is actually live ======================================
367
381
 
368
382
  /**
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).
383
+ * Poll the running server's `/mcp` until it returns 401 the precise signal that bearer auth is
384
+ * being enforced (team mode is genuinely ON). Writing members.yaml flips the server within its
385
+ * ~2s membership-watch tick, so we give it a short window. Returns "live" on 401, "no-server"
386
+ * when nothing was ever reachable, "timeout" when a server responded but never 401 in time.
387
+ *
388
+ * This replaces the v0.2.36 restart dance: there's nothing to restart anymore — the file is the
389
+ * switch. We only confirm it took effect (the old code printed "🟢 live" off a TCP probe and lied).
373
390
  */
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) };
391
+ export async function confirmTeamModeLive(host: string, timeoutMs = 8000): Promise<LiveOutcome> {
392
+ const deadline = Date.now() + timeoutMs;
393
+ let reached = false;
394
+ while (Date.now() < deadline) {
395
+ try {
396
+ const res = await fetch(`http://${host}/mcp`, { method: "GET" });
397
+ reached = true;
398
+ if (res.status === 401) return "live";
399
+ } catch {
400
+ /* not reachable yet */
401
+ }
402
+ await new Promise((r) => setTimeout(r, 500));
417
403
  }
404
+ return reached ? "timeout" : "no-server";
418
405
  }
419
406
 
420
- // Exposed for the integration test that asserts on the env block written to the plist.
421
- export { maybeRestartServiceForTeamMode };
422
-
423
407
  // === Pretty printing ========================================================
424
408
 
425
409
  function printPreamble(dataDir: string, io: WizardIO): void {
@@ -439,7 +423,11 @@ function printSummary(a: TeamInitAnswers, io: WizardIO): void {
439
423
  io.out(` Teammates: ${a.teammates.length === 0 ? "(none yet)" : a.teammates.join(", ")}\n\n`);
440
424
  }
441
425
 
442
- function printApplyResult(res: TeamInitResult, io: WizardIO, restart: RestartOutcome): void {
426
+ function printApplyResult(
427
+ res: TeamInitResult,
428
+ io: WizardIO,
429
+ status: { live: LiveOutcome; reconnected: boolean },
430
+ ): void {
443
431
  io.out(" ✓ Admin promoted + members.yaml updated\n");
444
432
  io.out(` ✓ Team "${res.teamSlug}" created\n`);
445
433
  if (res.invites.length > 0) {
@@ -488,25 +476,24 @@ function printApplyResult(res: TeamInitResult, io: WizardIO, restart: RestartOut
488
476
  io.out(` (mode 0600 — read by you only. Delete it once teammates have their keys.)\n\n`);
489
477
  }
490
478
 
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.
479
+ // The "Next" block reflects the VERIFIED state of the running server (members.yaml is the
480
+ // switch no restart). "🟢 live" is only printed when /mcp actually returned 401.
494
481
  io.out("━━ Next ━━\n\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`);
482
+ if (status.live === "live") {
483
+ io.out(` 🟢 Team mode is live on http://${res.serverHost}\n`);
484
+ if (status.reconnected) {
485
+ io.out(` Reconnected your editor in team mode — restart it once to pick up the key.\n`);
486
+ }
487
+ io.out(` View the dashboard at http://${res.serverHost}/ui/admin (sign in with your admin key)\n`);
488
+ } else if (status.live === "timeout") {
489
+ io.out(` A server is running at ${res.serverHost} but team mode hasn't taken effect yet.\n`);
490
+ io.out(` It re-reads members.yaml every ~2s — give it a moment, then reload /ui/admin.\n`);
505
491
  } 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");
492
+ // "no-server" — nothing reachable. With members.yaml written, a plain serve starts in team mode.
493
+ io.out(`No running server found at ${res.serverHost}. Start one (it reads members.yaml and\n`);
494
+ io.out(` comes up in team mode — no env var needed):\n`);
495
+ io.out(` npx @krimto-labs/krimto serve\n`);
496
+ io.out(` • Then open http://${res.serverHost}/ui/admin and sign in with your admin key.\n`);
510
497
  }
511
498
  io.out(" • Step back to solo with `krimto team disband` (data preserved)\n\n");
512
499
  }
package/src/index/db.ts CHANGED
@@ -21,7 +21,21 @@ export function openIndexDb(path: string, config: IndexConfig): Db {
21
21
  if (path !== ":memory:") db.pragma("journal_mode = WAL");
22
22
  db.pragma("foreign_keys = ON");
23
23
  db.transaction(() => {
24
+ // A virtual table's tokenizer is fixed at CREATE; `CREATE ... IF NOT EXISTS` won't change an
25
+ // existing facts_fts. So when an older index is opened, drop facts_fts + its sync triggers,
26
+ // let SCHEMA_SQL recreate them with the current tokenizer, then rebuild the FTS index from the
27
+ // (untouched) content table. The `facts` rows survive — only the derived FTS index is rebuilt.
28
+ const prior = readStoredSchemaVersion(db);
29
+ const ftsMigration = prior !== null && prior < SCHEMA_VERSION;
30
+ if (ftsMigration) {
31
+ db.exec(
32
+ "DROP TRIGGER IF EXISTS facts_ai; DROP TRIGGER IF EXISTS facts_ad; DROP TRIGGER IF EXISTS facts_au; DROP TABLE IF EXISTS facts_fts;",
33
+ );
34
+ }
24
35
  db.exec(SCHEMA_SQL);
36
+ if (ftsMigration) {
37
+ db.exec("INSERT INTO facts_fts(facts_fts) VALUES('rebuild');");
38
+ }
25
39
  if (config.provider !== "none" && config.dimensions > 0) {
26
40
  db.exec(vecTableSql(config.dimensions));
27
41
  }
@@ -35,6 +49,18 @@ export function openIndexDb(path: string, config: IndexConfig): Db {
35
49
  return db;
36
50
  }
37
51
 
52
+ /** The schema_version stored in a pre-existing index, or null when the table isn't there yet. */
53
+ function readStoredSchemaVersion(db: Db): number | null {
54
+ try {
55
+ const row = db.prepare("SELECT value FROM schema_meta WHERE key='schema_version'").get() as
56
+ | { value: string }
57
+ | undefined;
58
+ return row ? Number(row.value) : null;
59
+ } catch {
60
+ return null; // schema_meta doesn't exist yet (fresh database)
61
+ }
62
+ }
63
+
38
64
  /** True when the stored embedding space differs from the configured one (forces rebuild). */
39
65
  export function embeddingSpaceChanged(db: Db, config: IndexConfig): boolean {
40
66
  const get = db.prepare("select value from schema_meta where key=?");
@@ -2,7 +2,10 @@
2
2
  // (divergence: spec hardcodes float[1536]); FTS5 is an external-content table kept in
3
3
  // sync by triggers (canonical sqlite.org pattern).
4
4
 
5
- export const SCHEMA_VERSION = 1;
5
+ // Bumped to 2 in v0.2.38: facts_fts gains the Porter stemmer so a singular query ("favorite
6
+ // color") matches a plural-titled fact ("Favorite colors"). openIndexDb migrates older indexes
7
+ // by dropping + recreating facts_fts and rebuilding it from the content table.
8
+ export const SCHEMA_VERSION = 2;
6
9
 
7
10
  /** Static DDL (everything except the dimension-parameterized vec table). */
8
11
  export const SCHEMA_SQL = `
@@ -22,7 +25,7 @@ CREATE TABLE IF NOT EXISTS facts (
22
25
  );
23
26
 
24
27
  CREATE VIRTUAL TABLE IF NOT EXISTS facts_fts USING fts5(
25
- title, body, tags, content='facts', content_rowid='rowid'
28
+ title, body, tags, content='facts', content_rowid='rowid', tokenize='porter unicode61'
26
29
  );
27
30
 
28
31
  CREATE TRIGGER IF NOT EXISTS facts_ai AFTER INSERT ON facts BEGIN
@@ -38,8 +38,12 @@ export interface HttpAppDeps {
38
38
  rateLimiter?: RateLimiter;
39
39
  /** When set, mounts the admin-only membership/key API at /admin and enables /ui/admin. */
40
40
  admin?: AdminContext;
41
- /** True = team mode (auth on /mcp + /ui login + /admin). False = local mode (no auth). */
42
- requireAuth: boolean;
41
+ /**
42
+ * Live predicate for team mode (auth on /mcp + /ui login + /admin). Evaluated PER REQUEST, not
43
+ * captured once — so when `members.yaml` gains an admin the running server flips to team mode
44
+ * without a restart. True ⇒ enforce auth; false ⇒ local/solo mode (no auth).
45
+ */
46
+ teamModeActive: () => boolean;
43
47
  /** Live status snapshot for the /ui dashboard status panel. */
44
48
  status?: () => StatusPanelOpts;
45
49
  /** Called once, on the first request to /mcp (any verb). Powers the "🟢 client connected" boot hint. */
@@ -86,7 +90,7 @@ export function buildHttpApp(deps: HttpAppDeps): Express {
86
90
  // call. The resolver enriches the Requester with `source` so krimtoWrite can stamp facts
87
91
  // with "cursor" / "claude-code" / etc. when the caller didn't pass `source` explicitly.
88
92
  const source = userAgentToSource(req.get("User-Agent"));
89
- const baseResolver = deps.requireAuth
93
+ const baseResolver = deps.teamModeActive()
90
94
  ? (extra: { authInfo?: AuthInfo }) => requesterFromAuth(extra.authInfo)
91
95
  : (() => deps.ctx.requester) as (extra: { authInfo?: AuthInfo }) => Requester;
92
96
  const resolver: (extra: { authInfo?: AuthInfo }) => Requester = source
@@ -116,15 +120,38 @@ export function buildHttpApp(deps: HttpAppDeps): Express {
116
120
  }
117
121
  next();
118
122
  };
119
- const mcpChain: RequestHandler[] = deps.requireAuth ? [auth, rateLimit] : [rateLimit];
120
- app.post("/mcp", ...mcpChain, (req, res) => {
123
+ // Per-request gate: run bearer auth only when team mode is active right now. In solo mode the
124
+ // request falls straight through to rateLimit + handler (req.auth stays unset; rateLimit keys
125
+ // on "anonymous"). Mounted always so a live flip to team mode takes effect with no rebuild.
126
+ const maybeAuth: RequestHandler = (req, res, next) => {
127
+ if (deps.teamModeActive()) {
128
+ auth(req, res, next);
129
+ } else {
130
+ next();
131
+ }
132
+ };
133
+ app.post("/mcp", maybeAuth, rateLimit, (req, res) => {
121
134
  void handleMcp(req, res);
122
135
  });
123
- app.get("/mcp", ...mcpChain, (req, res) => {
136
+ app.get("/mcp", maybeAuth, rateLimit, (req, res) => {
124
137
  void handleMcp(req, res);
125
138
  });
126
139
 
127
- if (deps.requireAuth && deps.admin) app.use("/admin", auth, buildAdminRouter(deps.admin));
140
+ // /admin is mounted whenever an admin context exists, but each request is gated on live team
141
+ // mode: in solo mode it 404s (invisible), in team mode it requires the admin bearer key.
142
+ if (deps.admin) {
143
+ app.use(
144
+ "/admin",
145
+ (req, res, next) => {
146
+ if (deps.teamModeActive()) {
147
+ auth(req, res, next);
148
+ } else {
149
+ res.sendStatus(404);
150
+ }
151
+ },
152
+ buildAdminRouter(deps.admin),
153
+ );
154
+ }
128
155
 
129
156
  app.use(
130
157
  "/ui",
@@ -133,8 +160,9 @@ export function buildHttpApp(deps: HttpAppDeps): Express {
133
160
  keys: deps.keys,
134
161
  membership: deps.membership,
135
162
  sessionSecret: sessionConfigFromEnv().secret,
136
- admin: deps.requireAuth ? deps.admin : undefined,
137
- localIdentity: deps.requireAuth ? undefined : deps.ctx.requester.identity,
163
+ admin: deps.admin,
164
+ teamModeActive: deps.teamModeActive,
165
+ localIdentity: deps.ctx.requester.identity,
138
166
  status: deps.status,
139
167
  }),
140
168
  );
@@ -34,12 +34,13 @@ import { z } from "zod";
34
34
  import { FactStore } from "../storage/store";
35
35
  import { GitRepo } from "../storage/git";
36
36
  import { CommitBatcher, batcherConfigFromEnv } from "../storage/batcher";
37
- import { loadMembership, requesterFor } from "../access/membership";
37
+ import { hasOrgAdmin, loadMembership, parseMembership, requesterFor, shouldAdoptReload } from "../access/membership";
38
38
  import { createEmbeddingProvider, embeddingConfigFromEnv } from "../index/providers";
39
39
  import { openIndexDb, embeddingSpaceChanged, type IndexConfig } from "../index/db";
40
40
  import { FactIndex } from "../index/factIndex";
41
41
  import { Serializer } from "../index/serialize";
42
42
  import { RemoteSync, syncConfigFromEnv } from "../storage/sync";
43
+ import { MembershipWatcher } from "./membershipWatcher";
43
44
  import { KrimtoError } from "./errors";
44
45
  import {
45
46
  krimtoListScopes,
@@ -54,7 +55,7 @@ import { type Requester } from "../access/scope";
54
55
 
55
56
  export type RequesterResolver = (extra: { authInfo?: AuthInfo }) => Requester;
56
57
 
57
- export const KRIMTO_VERSION = "0.2.36";
58
+ export const KRIMTO_VERSION = "0.2.38";
58
59
 
59
60
  export function resolveDataDir(): string {
60
61
  return process.env.KRIMTO_DATA ?? path.join(homedir(), ".krimto");
@@ -121,7 +122,9 @@ export function buildServer(ctx: ToolContext, resolveRequester?: RequesterResolv
121
122
  "durable fact, or when correcting a mistake you should not repeat. For the user's personal " +
122
123
  "scope use `user/me` (the server resolves it to their identity) — do not guess an email. The " +
123
124
  "write is rejected (with the list of scopes you may write to) if you target a scope you couldn't " +
124
- "read back. Call krimto_recall first to avoid duplicates.",
125
+ "read back. Call krimto_recall first to avoid duplicates — and if the write response includes a " +
126
+ "`related` list, those are near-duplicates already in this scope: prefer krimto_supersede on one " +
127
+ "of them over leaving a second copy.",
125
128
  inputSchema: {
126
129
  scope: z
127
130
  .string()
@@ -344,13 +347,29 @@ export async function main(): Promise<void> {
344
347
  process.stderr.write(`Krimto embeddings: ${embeddingProvider.name} (${embeddingProvider.dimensions}d)\n`);
345
348
  }
346
349
 
350
+ const membersPath = path.join(dataDir, ".krimto", "members.yaml");
347
351
  const reloadMembership = async (): Promise<void> => {
348
- membership = await loadMembership(dataDir);
349
- ctx.membership = membership;
352
+ let next: ReturnType<typeof parseMembership>;
353
+ try {
354
+ next = parseMembership(await fs.readFile(membersPath, "utf8"));
355
+ } catch {
356
+ return; // read/parse failure — keep the current membership; never blank out auth
357
+ }
358
+ // Safety: never flip team→solo on a (possibly transient, mid-write) zero-admin read — that
359
+ // would open an auth-off window. See shouldAdoptReload for the rationale.
360
+ if (!shouldAdoptReload(membership, next)) return;
361
+ membership = next;
362
+ ctx.membership = next;
350
363
  };
351
364
 
352
365
  batcher.start((fn) => ctx.writeQueue.run(fn));
353
366
 
367
+ // Live team-mode trigger: poll members.yaml so a `krimto team init` (a separate CLI process)
368
+ // flips this running server into team mode within ~2s — no restart. Runs unconditionally so a
369
+ // solo→team transition is picked up. Reload goes through the write serializer.
370
+ const memberWatch = new MembershipWatcher(membersPath, reloadMembership);
371
+ memberWatch.start((fn) => ctx.writeQueue.run(fn));
372
+
354
373
  const sync = new RemoteSync(
355
374
  repo,
356
375
  async () => {
@@ -394,6 +413,7 @@ export async function main(): Promise<void> {
394
413
  shuttingDown = true;
395
414
  telemetry.stop();
396
415
  sync.stop();
416
+ memberWatch.stop();
397
417
  batcher.stop();
398
418
  void ctx.writeQueue
399
419
  .run(() => batcher.flush())
@@ -408,8 +428,12 @@ export async function main(): Promise<void> {
408
428
  const httpPort = process.env.KRIMTO_HTTP_PORT ? Number(process.env.KRIMTO_HTTP_PORT) : undefined;
409
429
  if (httpPort !== undefined && Number.isInteger(httpPort) && httpPort > 0) {
410
430
  const rlConfig = rateLimitConfigFromEnv();
411
- // Team mode (auth) when an admin is bootstrapped or auth is explicitly required; else local mode.
412
- const requireAuth = Boolean(process.env.KRIMTO_BOOTSTRAP_ADMIN || process.env.KRIMTO_REQUIRE_AUTH === "1");
431
+ // Team mode is derived LIVE from membership: any org admin enforce auth. KRIMTO_BOOTSTRAP_ADMIN
432
+ // still works because it seeds an admin into members.yaml before this point; the explicit
433
+ // KRIMTO_REQUIRE_AUTH=1 override remains. Evaluated per request (via the getter), so a later
434
+ // `members.yaml` edit flips the running server with no restart.
435
+ const teamModeActive = (): boolean =>
436
+ hasOrgAdmin(membership) || process.env.KRIMTO_REQUIRE_AUTH === "1";
413
437
  const app = buildHttpApp({
414
438
  ctx,
415
439
  keys,
@@ -423,7 +447,7 @@ export async function main(): Promise<void> {
423
447
  gitRemoteStatus: () => batcher.lastPushStatus(),
424
448
  rateLimiter: rlConfig.enabled ? new RateLimiter(rlConfig) : undefined,
425
449
  admin,
426
- requireAuth,
450
+ teamModeActive,
427
451
  // Live status for the /ui dashboard panel — built per request so it never goes stale.
428
452
  status: () => ({
429
453
  gitRemoteUrl: process.env.KRIMTO_GIT_REMOTE,
@@ -443,7 +467,7 @@ export async function main(): Promise<void> {
443
467
  });
444
468
  app.listen(httpPort, () => {
445
469
  process.stderr.write(`Krimto ${KRIMTO_VERSION} HTTP server on :${httpPort} (data: ${dataDir})\n`);
446
- if (!requireAuth) {
470
+ if (!teamModeActive()) {
447
471
  process.stderr.write(localModeBanner(httpPort, dataDir, identity));
448
472
  } else {
449
473
  process.stderr.write(teamModeBanner({ host: `localhost:${httpPort}`, key: bootstrapKey, dataDir }));
@@ -0,0 +1,55 @@
1
+ // Live team-mode trigger. `krimto team init` writes members.yaml from a SEPARATE CLI process, so
2
+ // the running server never learns about the new admin on its own. This watcher polls the file's
3
+ // mtime (~2s) and fires a reload when it changes — flipping the server into team mode without a
4
+ // restart. An mtime poll (not fs.watch) is deliberate: fs.watch is flaky with atomic-rename writes
5
+ // and varies across platforms; a poll matches the existing setInterval pattern (batcher/sync).
6
+
7
+ import { promises as fs } from "node:fs";
8
+
9
+ /** Runs a task with exclusive access (the write serializer in production). Mirrors RemoteSync. */
10
+ export type RunExclusive = (task: () => Promise<unknown>) => Promise<unknown>;
11
+
12
+ export const DEFAULT_MEMBERSHIP_WATCH_MS = 2000;
13
+
14
+ export class MembershipWatcher {
15
+ private timer: ReturnType<typeof setInterval> | undefined;
16
+ private lastMtime = 0;
17
+
18
+ constructor(
19
+ private readonly file: string,
20
+ private readonly onChange: () => Promise<void>,
21
+ private readonly intervalMs: number = DEFAULT_MEMBERSHIP_WATCH_MS,
22
+ ) {}
23
+
24
+ /**
25
+ * One poll: fire `onChange` iff the file's mtime differs from the last seen value. An absent or
26
+ * unreadable file is a no-op (solo stays solo). Exposed so tests can drive it deterministically.
27
+ */
28
+ async checkOnce(): Promise<void> {
29
+ let mtime: number;
30
+ try {
31
+ mtime = (await fs.stat(this.file)).mtimeMs;
32
+ } catch {
33
+ return; // file not there yet — nothing to flip
34
+ }
35
+ if (mtime === this.lastMtime) return; // debounce: only react to a real change
36
+ this.lastMtime = mtime;
37
+ await this.onChange();
38
+ }
39
+
40
+ /** Begin polling. `runExclusive` serializes each reload against the batcher/sync write path. */
41
+ start(runExclusive: RunExclusive): void {
42
+ if (this.timer) return;
43
+ this.timer = setInterval(() => {
44
+ void runExclusive(() => this.checkOnce());
45
+ }, this.intervalMs);
46
+ if (typeof this.timer.unref === "function") this.timer.unref();
47
+ }
48
+
49
+ stop(): void {
50
+ if (this.timer) {
51
+ clearInterval(this.timer);
52
+ this.timer = undefined;
53
+ }
54
+ }
55
+ }
@@ -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
  }
package/src/web/router.ts CHANGED
@@ -20,10 +20,12 @@ export interface WebRouterDeps {
20
20
  keys: ApiKeyStore;
21
21
  membership: () => Membership;
22
22
  sessionSecret: string;
23
- /** When set, enables the admin-only /ui/admin page. */
23
+ /** When set, enables the admin-only /ui/admin page (only reachable in team mode). */
24
24
  admin?: AdminContext;
25
- /** When set (local mode), skip login and use this identity for every request. */
26
- localIdentity?: string;
25
+ /** Live team-mode predicate. Evaluated per request: team require login; solo use localIdentity. */
26
+ teamModeActive: () => boolean;
27
+ /** The local/solo identity — used (no login) whenever team mode is NOT active. Always provided. */
28
+ localIdentity: string;
27
29
  /** Live status snapshot for the /ui/facts status panel. Called per request so it's never stale. */
28
30
  status?: () => StatusPanelOpts;
29
31
  }
@@ -64,8 +66,8 @@ export function buildWebRouter(deps: WebRouterDeps): Router {
64
66
  });
65
67
 
66
68
  router.use((req, res, next) => {
67
- if (deps.localIdentity) {
68
- (req as WithIdentity).identity = deps.localIdentity; // local mode: no login
69
+ if (!deps.teamModeActive()) {
70
+ (req as WithIdentity).identity = deps.localIdentity; // solo mode: no login
69
71
  next();
70
72
  return;
71
73
  }
@@ -84,7 +86,7 @@ export function buildWebRouter(deps: WebRouterDeps): Router {
84
86
 
85
87
  router.get("/connect", (req, res) => {
86
88
  const host = typeof req.headers.host === "string" ? req.headers.host : "localhost:8080";
87
- page(res, 200, "Connect", connectPanel({ host, requireAuth: !deps.localIdentity }), idOf(req));
89
+ page(res, 200, "Connect", connectPanel({ host, requireAuth: deps.teamModeActive() }), idOf(req));
88
90
  });
89
91
 
90
92
  router.get("/settings", (req, res) => {