@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 +24 -14
- package/bin/krimto.mjs +6 -6
- package/package.json +1 -1
- package/src/cli/join.ts +66 -0
- package/src/cli/setupRemote.ts +11 -6
- package/src/cli/teamInit.ts +197 -9
- package/src/cli/wizard.ts +15 -9
- package/src/server/banner.ts +6 -3
- package/src/server/index.ts +34 -5
- package/src/server/tools.ts +61 -1
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.
|
|
13
|
-
> shipped end-to-end, plus
|
|
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.
|
|
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
|
-
|
|
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
|
-
**
|
|
428
|
-
|
|
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
|
-
|
|
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` (
|
|
500
|
-
`v0.
|
|
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
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];
|
package/src/cli/setupRemote.ts
CHANGED
|
@@ -14,13 +14,18 @@ export interface SetupRemoteResult {
|
|
|
14
14
|
url: string;
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
|
|
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
|
-
|
|
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
|
-
`
|
|
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
|
}
|
package/src/cli/teamInit.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
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
|
-
|
|
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) =>
|
|
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(`
|
|
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
|
-
|
|
321
|
-
|
|
322
|
-
|
|
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
|
-
|
|
505
|
-
const dot = env.present ? "✓" : env.installed ? "~" : "–";
|
|
506
|
-
const note =
|
|
507
|
-
? "
|
|
508
|
-
: env.
|
|
509
|
-
? "
|
|
510
|
-
:
|
|
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");
|
package/src/server/banner.ts
CHANGED
|
@@ -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
|
|
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:
|
|
14
|
-
*
|
|
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 "";
|
package/src/server/index.ts
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
59
|
-
|
|
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 = {
|
package/src/server/tools.ts
CHANGED
|
@@ -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
|
}
|