@kontextmind/kxm 0.7.92 → 0.7.93
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/.claude-plugin/marketplace.json +1 -1
- package/.kxm/workflows/default.yaml +1 -1
- package/CHANGELOG.md +204 -0
- package/README.md +3 -0
- package/docs/README.md +3 -0
- package/docs/agent-skills.md +123 -60
- package/docs/architecture.md +5 -2
- package/docs/cli-reference.md +3527 -0
- package/docs/config-reference.md +1943 -0
- package/docs/configuration.md +29 -3
- package/docs/continuous-improvement.md +122 -10
- package/docs/contracts/routing.md +95 -11
- package/docs/harness-routing.md +616 -0
- package/docs/kxm-handbook.md +106 -19
- package/docs/templates/README.md +1 -1
- package/docs/test-matrix.md +12 -6
- package/docs/troubleshooting.md +2 -2
- package/examples/project/.kxm/workflows/fix.yaml +1 -1
- package/examples/project/.kxm/workflows/improve.yaml +1 -1
- package/package.json +1 -1
- package/plugins/kxm/.claude-plugin/plugin.json +9 -10
- package/plugins/kxm/README.md +238 -56
- package/plugins/kxm/dist/claude-hook.js +10083 -0
- package/plugins/kxm/dist/cli.js +3068 -2446
- package/plugins/kxm/dist/client.js +64 -0
- package/plugins/kxm/dist/core.js +102 -9
- package/plugins/kxm/dist/extension.js +210 -68
- package/plugins/kxm/dist/mcp-server.js +217 -40
- package/plugins/kxm/dist/runtime-supervisor.js +1628 -157
- package/plugins/kxm/dist/runtime.js +1874 -298
- package/plugins/kxm/dist/server.js +416 -82
- package/plugins/kxm/package.json +1 -1
- package/plugins/kxm/skills/hints.json +1 -1
- package/plugins/kxm/skills/kxm/SKILL.md +48 -24
- package/plugins/kxm/skills/kxm/references/protocol.md +3 -3
- package/plugins/kxm/skills/kxm-context-memory/SKILL.md +61 -21
- package/plugins/kxm/skills/kxm-definitions/SKILL.md +9 -0
- package/plugins/kxm/skills/kxm-harness-auth/SKILL.md +82 -16
- package/plugins/kxm/skills/kxm-harvest/SKILL.md +1 -1
- package/plugins/kxm/skills/kxm-hub-ops/SKILL.md +55 -27
- package/plugins/kxm/skills/kxm-insights/SKILL.md +1 -1
- package/plugins/kxm/skills/kxm-mind/SKILL.md +2 -2
- package/plugins/kxm/skills/{kxm-setup → kxm-mind-setup}/SKILL.md +4 -4
- package/plugins/kxm/skills/kxm-peer/SKILL.md +68 -93
- package/plugins/kxm/skills/kxm-project-setup/SKILL.md +156 -23
- package/plugins/kxm/skills/kxm-projects/SKILL.md +1 -1
- package/plugins/kxm/skills/kxm-protocol/SKILL.md +1 -1
- package/plugins/kxm/skills/kxm-query/SKILL.md +1 -1
- package/plugins/kxm/skills/kxm-routing-improve/SKILL.md +74 -15
- package/plugins/kxm/skills/kxm-runs/SKILL.md +46 -17
- package/plugins/kxm/skills/kxm-session/SKILL.md +64 -36
- package/plugins/kxm/skills/kxm-skill-lifecycle/SKILL.md +44 -15
- package/plugins/kxm/skills/kxm-tasks/SKILL.md +16 -4
- package/plugins/kxm/skills/kxm-triage/SKILL.md +1 -1
- package/plugins/kxm/skills/kxm-work/SKILL.md +1 -1
- package/plugins/kxm/skills/kxm-workflow/SKILL.md +60 -19
- package/plugins/kxm/src/arbiter.ts +67 -22
- package/plugins/kxm/src/autocomplete.ts +1 -1
- package/plugins/kxm/src/claude-hook.ts +192 -0
- package/plugins/kxm/src/cli/project.ts +11 -5
- package/plugins/kxm/src/cli/system.ts +85 -13
- package/plugins/kxm/src/cli/types.ts +4 -1
- package/plugins/kxm/src/cli/workflows.ts +18 -16
- package/plugins/kxm/src/cli.ts +23 -13
- package/plugins/kxm/src/client.ts +15 -4
- package/plugins/kxm/src/commands.ts +19 -9
- package/plugins/kxm/src/config.ts +42 -7
- package/plugins/kxm/src/context-packet.ts +14 -2
- package/plugins/kxm/src/context.ts +16 -5
- package/plugins/kxm/src/dispatch-context.ts +286 -0
- package/plugins/kxm/src/engine-plan.ts +40 -0
- package/plugins/kxm/src/engine.ts +138 -6
- package/plugins/kxm/src/hub-env.ts +17 -1
- package/plugins/kxm/src/hub.ts +92 -29
- package/plugins/kxm/src/improve-sources.ts +228 -0
- package/plugins/kxm/src/improve.ts +325 -140
- package/plugins/kxm/src/local-snapshot.ts +101 -42
- package/plugins/kxm/src/mcp-server.ts +129 -30
- package/plugins/kxm/src/project-config.ts +25 -0
- package/plugins/kxm/src/protocol.ts +11 -0
- package/plugins/kxm/src/relevance.ts +138 -0
- package/plugins/kxm/src/retrospective.ts +16 -10
- package/plugins/kxm/src/runtime-service.ts +8 -1
- package/plugins/kxm/src/runtime-supervisor.ts +16 -2
- package/plugins/kxm/src/session-token-hint.ts +17 -0
- package/plugins/kxm/src/suggest.ts +7 -7
- package/plugins/kxm/src/workflow-manager.ts +80 -78
- package/plugins/kxm/src/workflow.ts +202 -12
- package/scripts/build-runtime.mjs +7 -1
- package/scripts/check-generated.mjs +1 -0
- package/scripts/emit-codex-artifacts.mjs +1 -1
|
@@ -56,6 +56,31 @@ export interface LocalMeshSnapshot {
|
|
|
56
56
|
spend?: Array<{ recordedAt: string; routing: RoutingRecord | RoutingRecordV2 }> | undefined;
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
+
/** Limits a snapshot to one project and one recipient.
|
|
60
|
+
*
|
|
61
|
+
* `hubProject` is the hub project key stored on legacy messages and runs.
|
|
62
|
+
* `runtimeProjectId` is the `.kxm/project.yaml` id that keys Runtime runs;
|
|
63
|
+
* without it the snapshot reads no Runtime runs. `recipientName` selects the
|
|
64
|
+
* open messages addressed to this agent; without it no messages are read. A
|
|
65
|
+
* scoped snapshot never reads journal plans. */
|
|
66
|
+
export interface LocalMeshSnapshotScope {
|
|
67
|
+
hubProject: string;
|
|
68
|
+
runtimeProjectId?: string | undefined;
|
|
69
|
+
recipientName?: string | undefined;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface LocalMeshSnapshotOptions {
|
|
73
|
+
env?: NodeJS.ProcessEnv;
|
|
74
|
+
projectRoot?: string;
|
|
75
|
+
kxmStateRoot?: string;
|
|
76
|
+
/** SQLite busy timeout for every database this snapshot opens. Default 5000. */
|
|
77
|
+
busyTimeoutMs?: number;
|
|
78
|
+
scope?: LocalMeshSnapshotScope;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const DEFAULT_BUSY_TIMEOUT_MS = 5000;
|
|
82
|
+
const RUNTIME_PROJECT_KEY = /^[A-Za-z0-9_-][A-Za-z0-9._-]*$/;
|
|
83
|
+
|
|
59
84
|
function processExists(pid: number): boolean {
|
|
60
85
|
try {
|
|
61
86
|
process.kill(pid, 0);
|
|
@@ -65,8 +90,8 @@ function processExists(pid: number): boolean {
|
|
|
65
90
|
}
|
|
66
91
|
}
|
|
67
92
|
|
|
68
|
-
function readJsonRows<T>(database: DatabaseSync, sql: string): T[] {
|
|
69
|
-
const rows = database.prepare(sql).all() as Array<{ record: string }>;
|
|
93
|
+
function readJsonRows<T>(database: DatabaseSync, sql: string, params: string[] = []): T[] {
|
|
94
|
+
const rows = database.prepare(sql).all(...params) as Array<{ record: string }>;
|
|
70
95
|
const out: T[] = [];
|
|
71
96
|
for (const row of rows) {
|
|
72
97
|
try {
|
|
@@ -155,12 +180,17 @@ function readPlanMetadata(database: DatabaseSync): MeshTuiPlan[] {
|
|
|
155
180
|
}
|
|
156
181
|
}
|
|
157
182
|
|
|
158
|
-
function countRows(database: DatabaseSync, table: "messages" | "workflow_runs", where = ""): number {
|
|
159
|
-
const row = database.prepare(`SELECT COUNT(*) AS count FROM ${table}${where}`).get() as { count?: number | bigint } | undefined;
|
|
183
|
+
function countRows(database: DatabaseSync, table: "messages" | "workflow_runs", where = "", params: string[] = []): number {
|
|
184
|
+
const row = database.prepare(`SELECT COUNT(*) AS count FROM ${table}${where}`).get(...params) as { count?: number | bigint } | undefined;
|
|
160
185
|
return Number(row?.count ?? 0);
|
|
161
186
|
}
|
|
162
187
|
|
|
163
|
-
|
|
188
|
+
const OPEN_MESSAGE_WHERE = " WHERE json_extract(record, '$.status') IN ('queued', 'delivered')";
|
|
189
|
+
const SCOPED_MESSAGE_WHERE = `${OPEN_MESSAGE_WHERE}
|
|
190
|
+
AND json_extract(record, '$.project') = ?
|
|
191
|
+
AND COALESCE(json_extract(record, '$.toName'), json_extract(record, '$.to')) = ?`;
|
|
192
|
+
|
|
193
|
+
function readOpenMessageMetadata(database: DatabaseSync, where = OPEN_MESSAGE_WHERE, params: string[] = []): MeshTuiOpenMessage[] {
|
|
164
194
|
const rows = database.prepare(`
|
|
165
195
|
SELECT
|
|
166
196
|
json_extract(record, '$.id') AS id,
|
|
@@ -170,11 +200,10 @@ function readOpenMessageMetadata(database: DatabaseSync): MeshTuiOpenMessage[] {
|
|
|
170
200
|
json_extract(record, '$.delivery') AS delivery,
|
|
171
201
|
json_extract(record, '$.createdAt') AS createdAt,
|
|
172
202
|
json_extract(record, '$.correlationId') AS correlationId
|
|
173
|
-
FROM messages
|
|
174
|
-
WHERE json_extract(record, '$.status') IN ('queued', 'delivered')
|
|
203
|
+
FROM messages${where}
|
|
175
204
|
ORDER BY json_extract(record, '$.createdAt') DESC
|
|
176
205
|
LIMIT 16
|
|
177
|
-
`).all() as Array<Record<string, unknown>>;
|
|
206
|
+
`).all(...params) as Array<Record<string, unknown>>;
|
|
178
207
|
const messages: MeshTuiOpenMessage[] = [];
|
|
179
208
|
for (const row of rows) {
|
|
180
209
|
if (
|
|
@@ -236,11 +265,41 @@ function resolveKxmStateRoot(
|
|
|
236
265
|
return undefined;
|
|
237
266
|
}
|
|
238
267
|
|
|
268
|
+
function readRuntimeRuns(eventDb: DatabaseSync, projectId: string | undefined): { runs: MeshTuiRun[]; total: number } {
|
|
269
|
+
const where = projectId === undefined ? "" : " WHERE project_id = ?";
|
|
270
|
+
const params = projectId === undefined ? [] : [projectId];
|
|
271
|
+
const runRows = eventDb.prepare(`
|
|
272
|
+
SELECT run_id, project_id, workflow_id, status, created_at, updated_at
|
|
273
|
+
FROM runs${where} ORDER BY created_at DESC, run_id DESC LIMIT 8
|
|
274
|
+
`).all(...params) as Array<{
|
|
275
|
+
run_id: string;
|
|
276
|
+
project_id: string;
|
|
277
|
+
workflow_id: string;
|
|
278
|
+
status: string;
|
|
279
|
+
created_at: string;
|
|
280
|
+
updated_at: string;
|
|
281
|
+
}>;
|
|
282
|
+
const countRow = eventDb.prepare(`SELECT COUNT(*) AS total FROM runs${where}`).get(...params) as { total: number } | undefined;
|
|
283
|
+
return {
|
|
284
|
+
total: Number(countRow?.total ?? runRows.length),
|
|
285
|
+
runs: runRows.map((r) => ({
|
|
286
|
+
id: r.run_id,
|
|
287
|
+
status: r.status,
|
|
288
|
+
definitionId: r.workflow_id,
|
|
289
|
+
project: r.project_id,
|
|
290
|
+
updatedAt: r.updated_at || r.created_at,
|
|
291
|
+
})),
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
|
|
239
295
|
export function loadLocalMeshSnapshot(
|
|
240
296
|
dataPath: string,
|
|
241
297
|
stateDir: string,
|
|
242
|
-
options?:
|
|
298
|
+
options?: LocalMeshSnapshotOptions,
|
|
243
299
|
): LocalMeshSnapshot {
|
|
300
|
+
const busyTimeoutMs = Math.max(0, Math.trunc(Number(options?.busyTimeoutMs ?? DEFAULT_BUSY_TIMEOUT_MS)) || 0);
|
|
301
|
+
const busyTimeout = `PRAGMA busy_timeout = ${busyTimeoutMs}`;
|
|
302
|
+
const scope = options?.scope;
|
|
244
303
|
let hasLegacy = false;
|
|
245
304
|
let agents: AgentRecord[] = [];
|
|
246
305
|
let openMessages: MeshTuiOpenMessage[] = [];
|
|
@@ -252,7 +311,7 @@ export function loadLocalMeshSnapshot(
|
|
|
252
311
|
hasLegacy = true;
|
|
253
312
|
const database = openReadOnlyDatabase(dataPath);
|
|
254
313
|
try {
|
|
255
|
-
database.exec(
|
|
314
|
+
database.exec(busyTimeout);
|
|
256
315
|
// Stored rows carry identity only. Presence is derived here against the
|
|
257
316
|
// default lease window, because the hub's configured `staleAfterMs` is
|
|
258
317
|
// not in the file; a reachable hub's own records replace these.
|
|
@@ -262,11 +321,24 @@ export function loadLocalMeshSnapshot(
|
|
|
262
321
|
// `online` boolean is the only durable truth; the hub's /v1/agents or the
|
|
263
322
|
// ops snapshot is the authoritative presence source.
|
|
264
323
|
agents = readJsonRows<AgentIdentity>(database, "SELECT record FROM agents");
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
324
|
+
if (!scope) {
|
|
325
|
+
openMessages = readOpenMessageMetadata(database);
|
|
326
|
+
openMessageTotal = countRows(database, "messages", OPEN_MESSAGE_WHERE);
|
|
327
|
+
legacyRuns = readJsonRows<WorkflowRun>(database, "SELECT record FROM workflow_runs ORDER BY rowid DESC LIMIT 8");
|
|
328
|
+
legacyRunTotal = countRows(database, "workflow_runs");
|
|
329
|
+
plans = readPlanMetadata(database);
|
|
330
|
+
} else {
|
|
331
|
+
// Scoped: this project's runs and this recipient's open messages only.
|
|
332
|
+
// Plans stay empty: journal text belongs to other agents' runs.
|
|
333
|
+
if (scope.recipientName) {
|
|
334
|
+
const messageParams = [scope.hubProject, scope.recipientName];
|
|
335
|
+
openMessages = readOpenMessageMetadata(database, SCOPED_MESSAGE_WHERE, messageParams);
|
|
336
|
+
openMessageTotal = countRows(database, "messages", SCOPED_MESSAGE_WHERE, messageParams);
|
|
337
|
+
}
|
|
338
|
+
const runWhere = " WHERE json_extract(record, '$.project') = ?";
|
|
339
|
+
legacyRuns = readJsonRows<WorkflowRun>(database, `SELECT record FROM workflow_runs${runWhere} ORDER BY rowid DESC LIMIT 8`, [scope.hubProject]);
|
|
340
|
+
legacyRunTotal = countRows(database, "workflow_runs", runWhere, [scope.hubProject]);
|
|
341
|
+
}
|
|
270
342
|
} finally {
|
|
271
343
|
database.close();
|
|
272
344
|
}
|
|
@@ -276,7 +348,8 @@ export function loadLocalMeshSnapshot(
|
|
|
276
348
|
let hasKxm = false;
|
|
277
349
|
const kxmRuns: MeshTuiRun[] = [];
|
|
278
350
|
let kxmRunTotal = 0;
|
|
279
|
-
|
|
351
|
+
// A scoped snapshot without a Runtime project id reads no Runtime runs.
|
|
352
|
+
const kxmStateRoot = scope && !scope.runtimeProjectId ? undefined : resolveKxmStateRoot(stateDir, options);
|
|
280
353
|
if (kxmStateRoot) {
|
|
281
354
|
const runtimeDir = join(kxmStateRoot, "runtime");
|
|
282
355
|
const registryDbPath = join(runtimeDir, "registry.db");
|
|
@@ -289,8 +362,10 @@ export function loadLocalMeshSnapshot(
|
|
|
289
362
|
try {
|
|
290
363
|
const regDb = openReadOnlyDatabase(registryDbPath);
|
|
291
364
|
try {
|
|
292
|
-
regDb.exec(
|
|
293
|
-
const pRows =
|
|
365
|
+
regDb.exec(busyTimeout);
|
|
366
|
+
const pRows = (scope
|
|
367
|
+
? regDb.prepare("SELECT project_key FROM projects WHERE project_id = ?").all(scope.runtimeProjectId)
|
|
368
|
+
: regDb.prepare("SELECT project_key FROM projects").all()) as Array<{ project_key: string }>;
|
|
294
369
|
for (const row of pRows) {
|
|
295
370
|
if (row.project_key) projectKeys.add(row.project_key);
|
|
296
371
|
}
|
|
@@ -300,7 +375,9 @@ export function loadLocalMeshSnapshot(
|
|
|
300
375
|
} catch { /* registry error */ }
|
|
301
376
|
}
|
|
302
377
|
|
|
303
|
-
|
|
378
|
+
// Scoped: only the registry's key for this project, never every project
|
|
379
|
+
// directory on the machine.
|
|
380
|
+
if (!scope && existsSync(projectsDir)) {
|
|
304
381
|
try {
|
|
305
382
|
for (const entry of readdirSync(projectsDir, { withFileTypes: true })) {
|
|
306
383
|
if (entry.isDirectory()) {
|
|
@@ -311,35 +388,17 @@ export function loadLocalMeshSnapshot(
|
|
|
311
388
|
}
|
|
312
389
|
|
|
313
390
|
for (const key of projectKeys) {
|
|
391
|
+
if (scope && !RUNTIME_PROJECT_KEY.test(key)) continue;
|
|
314
392
|
const eventDbPath = join(projectsDir, key, "run-events.db");
|
|
315
393
|
if (existsSync(eventDbPath)) {
|
|
316
394
|
hasKxm = true;
|
|
317
395
|
try {
|
|
318
396
|
const eventDb = openReadOnlyDatabase(eventDbPath);
|
|
319
397
|
try {
|
|
320
|
-
eventDb.exec(
|
|
321
|
-
const
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
`).all() as Array<{
|
|
325
|
-
run_id: string;
|
|
326
|
-
project_id: string;
|
|
327
|
-
workflow_id: string;
|
|
328
|
-
status: string;
|
|
329
|
-
created_at: string;
|
|
330
|
-
updated_at: string;
|
|
331
|
-
}>;
|
|
332
|
-
const countRow = eventDb.prepare("SELECT COUNT(*) AS total FROM runs").get() as { total: number } | undefined;
|
|
333
|
-
kxmRunTotal += Number(countRow?.total ?? runRows.length);
|
|
334
|
-
for (const r of runRows) {
|
|
335
|
-
kxmRuns.push({
|
|
336
|
-
id: r.run_id,
|
|
337
|
-
status: r.status,
|
|
338
|
-
definitionId: r.workflow_id,
|
|
339
|
-
project: r.project_id,
|
|
340
|
-
updatedAt: r.updated_at || r.created_at,
|
|
341
|
-
});
|
|
342
|
-
}
|
|
398
|
+
eventDb.exec(busyTimeout);
|
|
399
|
+
const { runs, total } = readRuntimeRuns(eventDb, scope?.runtimeProjectId);
|
|
400
|
+
kxmRunTotal += total;
|
|
401
|
+
kxmRuns.push(...runs);
|
|
343
402
|
} finally {
|
|
344
403
|
eventDb.close();
|
|
345
404
|
}
|
|
@@ -1,14 +1,18 @@
|
|
|
1
|
+
import { statSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
1
3
|
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
2
4
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
3
5
|
import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
|
|
4
|
-
import { HubClient } from "./client.ts";
|
|
5
|
-
import {
|
|
6
|
+
import { HubClient, HubHttpError } from "./client.ts";
|
|
7
|
+
import { resolveAgentHubAuthToken } from "./hub-env.ts";
|
|
6
8
|
import { defaultProjectName } from "./project-name.ts";
|
|
7
9
|
import { AGENT_COMMANDS_MAP, enforceToolPolicy, getMcpTools, reconcileInbox } from "./commands.ts";
|
|
8
10
|
import { deliverInboxNotification } from "./inbox.ts";
|
|
9
11
|
import type { HubEvent, MessageRecord } from "./protocol.ts";
|
|
12
|
+
import { sessionTokenFixHint } from "./session-token-hint.ts";
|
|
10
13
|
|
|
11
|
-
const VERSION = "0.7.
|
|
14
|
+
const VERSION = "0.7.93";
|
|
15
|
+
const CONFIGURE_PLUGIN = "/plugin configure kxm@kxm";
|
|
12
16
|
const inbox = new Map<string, MessageRecord>();
|
|
13
17
|
const notifiedInbox = new Set<string>();
|
|
14
18
|
let meshClient: HubClient | undefined;
|
|
@@ -22,11 +26,11 @@ const mcp = new Server(
|
|
|
22
26
|
tools: {},
|
|
23
27
|
},
|
|
24
28
|
instructions: [
|
|
25
|
-
'
|
|
26
|
-
"
|
|
27
|
-
"
|
|
28
|
-
"
|
|
29
|
-
"If
|
|
29
|
+
'Peer requests arrive as <channel source="kxm" message_id="..."> events, or in kxm_inbox; handle each under normal safety rules, then call kxm_reply with message_id and the final response.',
|
|
30
|
+
"For workflow requests, call kxm_workflow_get, record material knowledge with kxm_workflow_record in its ten categories (plan, decision, contradiction, error, lesson, observation, hypothesis, experiment, state-change, skill-candidate), pass the stageId each entry belongs to, and pass every checkpoint before replying.",
|
|
31
|
+
"For external work, call kxm_workflow_wait, then kxm_reply; a signed callback resumes the run.",
|
|
32
|
+
"In a KXM project, call kxm_context with your role and task before planning.",
|
|
33
|
+
"If a KXM tool reports a problem with the hub or token, continue without KXM and tell the user the next step it names.",
|
|
30
34
|
].join(" "),
|
|
31
35
|
},
|
|
32
36
|
);
|
|
@@ -41,6 +45,16 @@ function asRecord(value: unknown): Record<string, unknown> {
|
|
|
41
45
|
: {};
|
|
42
46
|
}
|
|
43
47
|
|
|
48
|
+
/** Where this session works and which hub it talks to, read from the launch environment. */
|
|
49
|
+
function sessionIdentity(): { projectDir: string; project: string; serverUrl: string } {
|
|
50
|
+
const projectDir = process.env.KXM_PROJECT_DIR || process.env.CLAUDE_PROJECT_DIR || process.cwd();
|
|
51
|
+
return {
|
|
52
|
+
projectDir,
|
|
53
|
+
project: defaultProjectName(projectDir, process.env),
|
|
54
|
+
serverUrl: process.env.KXM_SERVER_URL?.trim() || "http://127.0.0.1:7331",
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
44
58
|
async function onHubEvent(event: HubEvent): Promise<void> {
|
|
45
59
|
if (event.type === "cancelled" || event.type === "expired") {
|
|
46
60
|
inbox.delete(event.message.id);
|
|
@@ -75,28 +89,48 @@ async function onHubEvent(event: HubEvent): Promise<void> {
|
|
|
75
89
|
});
|
|
76
90
|
}
|
|
77
91
|
|
|
92
|
+
async function startClient(project: string, serverUrl: string, name: string, authToken: string): Promise<HubClient> {
|
|
93
|
+
const candidate = new HubClient({
|
|
94
|
+
serverUrl,
|
|
95
|
+
name,
|
|
96
|
+
purpose: process.env.KXM_AGENT_PURPOSE?.trim() || "Claude Code implementation and review agent",
|
|
97
|
+
project,
|
|
98
|
+
model: "claude-code",
|
|
99
|
+
authToken,
|
|
100
|
+
});
|
|
101
|
+
try {
|
|
102
|
+
await candidate.start(onHubEvent);
|
|
103
|
+
meshClient = candidate;
|
|
104
|
+
return candidate;
|
|
105
|
+
} catch (error) {
|
|
106
|
+
await candidate.stop();
|
|
107
|
+
throw error;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
78
111
|
async function ensureClient(): Promise<HubClient> {
|
|
79
112
|
if (meshClient?.agent) return meshClient;
|
|
80
113
|
if (starting) return starting;
|
|
81
114
|
starting = (async () => {
|
|
82
|
-
const
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
}
|
|
115
|
+
const { project, serverUrl } = sessionIdentity();
|
|
116
|
+
// An agent session registers with a project token only. The hub admits its admin token
|
|
117
|
+
// to any project missing from its token map, so no project token fails here, before the
|
|
118
|
+
// hub is contacted.
|
|
119
|
+
const authToken = resolveAgentHubAuthToken(process.env, project);
|
|
120
|
+
if (!authToken) {
|
|
121
|
+
throw new Error(
|
|
122
|
+
`KXM has no project token for project ${project} on this machine. Ask the user to set the kxm plugin auth_token (${CONFIGURE_PLUGIN}) or to add ${project} to the hub KXM_PROJECT_TOKENS, listing every existing project too because that variable replaces the saved map.`,
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
const name = process.env.KXM_AGENT_NAME?.trim() || `claude-${process.pid}`;
|
|
93
126
|
try {
|
|
94
|
-
await
|
|
95
|
-
meshClient = candidate;
|
|
96
|
-
return candidate;
|
|
127
|
+
return await startClient(project, serverUrl, name, authToken);
|
|
97
128
|
} catch (error) {
|
|
98
|
-
|
|
99
|
-
|
|
129
|
+
if (!(error instanceof HubHttpError && error.code === "duplicate_agent_name")) throw error;
|
|
130
|
+
// Another live session in this project holds the name; register beside it once.
|
|
131
|
+
const substitute = `${name}-${process.pid}`;
|
|
132
|
+
process.stderr.write(`kxm: agent name ${name} is already active in project ${project}; this session registers as ${substitute}\n`);
|
|
133
|
+
return await startClient(project, serverUrl, substitute, authToken);
|
|
100
134
|
}
|
|
101
135
|
})();
|
|
102
136
|
try {
|
|
@@ -106,6 +140,40 @@ async function ensureClient(): Promise<HubClient> {
|
|
|
106
140
|
}
|
|
107
141
|
}
|
|
108
142
|
|
|
143
|
+
/** The reason the hub could not be reached at all, or undefined for any other failure. */
|
|
144
|
+
function unreachableCause(error: unknown): string | undefined {
|
|
145
|
+
if (!(error instanceof Error)) return undefined;
|
|
146
|
+
if (error.message.startsWith("request timed out after")) return error.message;
|
|
147
|
+
const cause = (error as { cause?: unknown }).cause;
|
|
148
|
+
const causeCode = cause && typeof cause === "object" ? (cause as { code?: unknown }).code : undefined;
|
|
149
|
+
if (error.message === "fetch failed") {
|
|
150
|
+
if (typeof causeCode === "string") return causeCode;
|
|
151
|
+
return cause instanceof Error && cause.message ? cause.message : error.message;
|
|
152
|
+
}
|
|
153
|
+
if ((error as NodeJS.ErrnoException).code === "ECONNREFUSED" || error.message.includes("ECONNREFUSED")) return "ECONNREFUSED";
|
|
154
|
+
return undefined;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async function connectedClient(): Promise<HubClient> {
|
|
158
|
+
try {
|
|
159
|
+
return await ensureClient();
|
|
160
|
+
} catch (error) {
|
|
161
|
+
const cause = unreachableCause(error);
|
|
162
|
+
if (!cause) throw error;
|
|
163
|
+
throw new Error(
|
|
164
|
+
`KXM hub unreachable at ${sessionIdentity().serverUrl} (${cause}). Ask the user to start the hub (\`kxm hub start\`) or to correct the kxm plugin server_url with ${CONFIGURE_PLUGIN}.`,
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Tool errors are read by the model, so each names the next step and who takes it. */
|
|
170
|
+
function toolErrorText(error: unknown): string {
|
|
171
|
+
if (error instanceof HubHttpError && error.code === "invalid_auth") {
|
|
172
|
+
return `KXM hub rejected the project token for project ${sessionIdentity().project}. Ask the user to set the kxm plugin auth_token (${CONFIGURE_PLUGIN}) to that project's token from the hub KXM_PROJECT_TOKENS.`;
|
|
173
|
+
}
|
|
174
|
+
return error instanceof Error ? error.message : String(error);
|
|
175
|
+
}
|
|
176
|
+
|
|
109
177
|
const tools = getMcpTools();
|
|
110
178
|
|
|
111
179
|
mcp.setRequestHandler(ListToolsRequestSchema, async () => ({ tools }));
|
|
@@ -114,9 +182,11 @@ mcp.setRequestHandler(CallToolRequestSchema, async (request, extra) => {
|
|
|
114
182
|
try {
|
|
115
183
|
const policy = enforceToolPolicy(request.params.name);
|
|
116
184
|
if (!policy.allowed) {
|
|
117
|
-
|
|
185
|
+
// The denial stays fail closed; the hint only tells the user how to lift it.
|
|
186
|
+
const hint = sessionTokenFixHint(policy);
|
|
187
|
+
throw new Error(hint ? `tool_policy_denied: ${policy.detail}. ${hint}` : `tool_policy_denied: ${policy.detail ?? policy.error}`);
|
|
118
188
|
}
|
|
119
|
-
const client = await
|
|
189
|
+
const client = await connectedClient();
|
|
120
190
|
const cmd = AGENT_COMMANDS_MAP.get(request.params.name);
|
|
121
191
|
if (!cmd) throw new Error(`unknown tool: ${request.params.name}`);
|
|
122
192
|
const args = asRecord(request.params.arguments);
|
|
@@ -124,18 +194,47 @@ mcp.setRequestHandler(CallToolRequestSchema, async (request, extra) => {
|
|
|
124
194
|
return textResult(result);
|
|
125
195
|
} catch (error) {
|
|
126
196
|
return {
|
|
127
|
-
content: [{ type: "text" as const, text:
|
|
197
|
+
content: [{ type: "text" as const, text: toolErrorText(error) }],
|
|
128
198
|
isError: true,
|
|
129
199
|
};
|
|
130
200
|
}
|
|
131
201
|
});
|
|
132
202
|
|
|
203
|
+
/** Register before the first tool call, so peers see this session and its requests arrive,
|
|
204
|
+
* only where a tool call would register anyway: a KXM project with a project token whose tool
|
|
205
|
+
* policy lets the session receive and answer peer requests. */
|
|
206
|
+
function registersAtStartup(): boolean {
|
|
207
|
+
const { projectDir, project } = sessionIdentity();
|
|
208
|
+
try {
|
|
209
|
+
if (!statSync(join(projectDir, ".kxm")).isDirectory()) return false;
|
|
210
|
+
if (!resolveAgentHubAuthToken(process.env, project)) return false;
|
|
211
|
+
} catch {
|
|
212
|
+
return false;
|
|
213
|
+
}
|
|
214
|
+
return enforceToolPolicy("kxm_inbox").allowed && enforceToolPolicy("kxm_reply").allowed;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Wait for the client's initialized notification: a channel event sent before the handshake
|
|
218
|
+
// completes would be marked delivered while the client could still drop it.
|
|
219
|
+
mcp.oninitialized = () => {
|
|
220
|
+
if (registersAtStartup()) void ensureClient().catch(() => undefined);
|
|
221
|
+
};
|
|
222
|
+
|
|
133
223
|
await mcp.connect(new StdioServerTransport());
|
|
134
224
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
225
|
+
let shuttingDown: Promise<void> | undefined;
|
|
226
|
+
|
|
227
|
+
function shutdown(): Promise<void> {
|
|
228
|
+
shuttingDown ??= (async () => {
|
|
229
|
+
const client = meshClient ?? await starting?.catch(() => undefined);
|
|
230
|
+
await client?.stop();
|
|
231
|
+
await mcp.close();
|
|
232
|
+
})();
|
|
233
|
+
return shuttingDown;
|
|
138
234
|
}
|
|
139
235
|
|
|
140
236
|
process.once("SIGINT", () => void shutdown());
|
|
141
237
|
process.once("SIGTERM", () => void shutdown());
|
|
238
|
+
// A client that exits without signalling closes stdin. Leave the hub then, so this session is
|
|
239
|
+
// not listed as online and does not hold its agent name against the next session.
|
|
240
|
+
process.stdin.once("end", () => void shutdown());
|
|
@@ -813,6 +813,29 @@ function validateAgentScope(
|
|
|
813
813
|
}
|
|
814
814
|
}
|
|
815
815
|
|
|
816
|
+
/** The outcomes a gate step settles on, by `expect` (computeGateEvidenceOutcome
|
|
817
|
+
* in runtime-store.ts): nothing else ever reaches a gate step's `on` map. */
|
|
818
|
+
const GATE_STEP_OUTCOMES = {
|
|
819
|
+
pass: ["passed", "implementation-failure"],
|
|
820
|
+
fail: ["passed", "repro-missing"],
|
|
821
|
+
} as const satisfies Record<"pass" | "fail", readonly string[]>;
|
|
822
|
+
|
|
823
|
+
/** A gate step that routes on an outcome it never produces (such as `failed`)
|
|
824
|
+
* instead of one it does can never settle: the Runtime records the produced
|
|
825
|
+
* outcome, finds no transition for it, and leaves the attempt unsettled. An
|
|
826
|
+
* extra dead key next to every produced outcome still settles, and the built-in
|
|
827
|
+
* template has always declared `failed` beside `implementation-failure`, so only
|
|
828
|
+
* the combination is refused. Misspellings are gate_outcome_renamed's. */
|
|
829
|
+
function gateOutcomeImpossible(step: JsonObject, stepId: string, file: string): KxmConfigIssue | undefined {
|
|
830
|
+
const expect = step.expect === "fail" ? "fail" : "pass";
|
|
831
|
+
const produced: readonly string[] = GATE_STEP_OUTCOMES[expect];
|
|
832
|
+
const declared = Object.keys(objectValue(step.on) ?? {});
|
|
833
|
+
const impossible = declared.filter((outcome) => !produced.includes(outcome) && outcome !== "implementation_failure" && outcome !== "repro_missing");
|
|
834
|
+
const missing = produced.filter((outcome) => !declared.includes(outcome));
|
|
835
|
+
if (impossible.length === 0 || missing.length === 0) return undefined;
|
|
836
|
+
return issue("semantic", "gate_outcome_impossible", file, `${stepId} declares ${impossible.join(", ")}, which a gate step with expect ${expect} never produces; it settles on ${produced.join(" or ")}, so declare ${missing.join(" and ")}`);
|
|
837
|
+
}
|
|
838
|
+
|
|
816
839
|
function transition(value: JsonValue): { target?: string; maxTransitions?: number; terminalStatus?: string } {
|
|
817
840
|
if (typeof value === "string") return { target: value };
|
|
818
841
|
const object = objectValue(value);
|
|
@@ -887,6 +910,8 @@ function validateWorkflow(
|
|
|
887
910
|
if (kind === "gate" && Object.keys(objectValue(step.on) ?? {}).some((outcome) => outcome === "implementation_failure" || outcome === "repro_missing")) {
|
|
888
911
|
issues.push(issue("semantic", "gate_outcome_renamed", file, `${stepId} must use implementation-failure and repro-missing`));
|
|
889
912
|
}
|
|
913
|
+
const impossibleOutcome = kind === "gate" ? gateOutcomeImpossible(step, stepId, file) : undefined;
|
|
914
|
+
if (impossibleOutcome) issues.push(impossibleOutcome);
|
|
890
915
|
for (const repositoryId of Object.keys(objectValue(step.repositories) ?? {})) {
|
|
891
916
|
if (!repositories.has(repositoryId)) issues.push(issue("reference", "repository_unknown", file, `${stepId} references unknown repository ${repositoryId}`));
|
|
892
917
|
}
|
|
@@ -149,6 +149,17 @@ export type JournalCategory =
|
|
|
149
149
|
| "state-change"
|
|
150
150
|
| "skill-candidate";
|
|
151
151
|
export type ImprovementArea = "harness" | "gates" | "implementation" | "workflow" | "documentation" | "security" | "other";
|
|
152
|
+
/** Every improvement area, in report order. The order also breaks ties when
|
|
153
|
+
* a signal's modal area is ambiguous. */
|
|
154
|
+
export const IMPROVEMENT_AREAS: readonly ImprovementArea[] = [
|
|
155
|
+
"harness",
|
|
156
|
+
"gates",
|
|
157
|
+
"implementation",
|
|
158
|
+
"workflow",
|
|
159
|
+
"documentation",
|
|
160
|
+
"security",
|
|
161
|
+
"other",
|
|
162
|
+
];
|
|
152
163
|
|
|
153
164
|
/** Evidence submitted for one checkpoint or external signal, keyed by a
|
|
154
165
|
* requirement from WorkflowStageDefinition.requiredEvidence. */
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import type { ContextItem } from "./context.ts";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Deterministic lexical relevance (BM25) for context ranking and recall.
|
|
5
|
+
*
|
|
6
|
+
* No model, no clock, no randomness: the same query and documents always
|
|
7
|
+
* produce bit-identical scores, so arbiter packets and recall results stay
|
|
8
|
+
* reproducible and testable.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** Words that carry no task signal. Dropped before scoring. */
|
|
12
|
+
export const RELEVANCE_STOPWORDS: ReadonlySet<string> = Object.freeze(new Set([
|
|
13
|
+
"a", "an", "and", "are", "as", "at", "be", "been", "but", "by", "can", "could",
|
|
14
|
+
"did", "do", "does", "for", "from", "had", "has", "have", "how", "if", "in",
|
|
15
|
+
"into", "is", "it", "its", "of", "on", "or", "our", "should", "so", "than",
|
|
16
|
+
"that", "the", "their", "them", "then", "there", "these", "they", "this",
|
|
17
|
+
"those", "to", "was", "we", "were", "what", "when", "where", "which", "while",
|
|
18
|
+
"who", "why", "will", "with", "would", "you", "your",
|
|
19
|
+
]));
|
|
20
|
+
|
|
21
|
+
/** BM25 term-frequency saturation. */
|
|
22
|
+
export const RELEVANCE_K1 = 1.2;
|
|
23
|
+
/** BM25 document-length normalization. */
|
|
24
|
+
export const RELEVANCE_B = 0.75;
|
|
25
|
+
|
|
26
|
+
const MIN_TOKEN_CHARS = 2;
|
|
27
|
+
const MAX_TOKEN_CHARS = 64;
|
|
28
|
+
|
|
29
|
+
function foldPlural(token: string): string {
|
|
30
|
+
if (/^\p{N}+$/u.test(token)) return token;
|
|
31
|
+
if (token.length > 4 && token.endsWith("ies")) return `${token.slice(0, -3)}y`;
|
|
32
|
+
if (token.length > 4 && token.endsWith("sses")) return token.slice(0, -2);
|
|
33
|
+
if (
|
|
34
|
+
token.length > 3
|
|
35
|
+
&& token.endsWith("s")
|
|
36
|
+
&& !token.endsWith("ss")
|
|
37
|
+
&& !token.endsWith("us")
|
|
38
|
+
&& !token.endsWith("is")
|
|
39
|
+
) {
|
|
40
|
+
return token.slice(0, -1);
|
|
41
|
+
}
|
|
42
|
+
return token;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** NFKC-normalized, lowercased, stopword-free, plural-folded tokens in text
|
|
46
|
+
* order, duplicates kept. */
|
|
47
|
+
export function relevanceTokens(text: string): string[] {
|
|
48
|
+
const tokens: string[] = [];
|
|
49
|
+
for (const raw of text.normalize("NFKC").toLowerCase().split(/[^\p{L}\p{N}]+/u)) {
|
|
50
|
+
if (raw.length < MIN_TOKEN_CHARS || raw.length > MAX_TOKEN_CHARS) continue;
|
|
51
|
+
if (RELEVANCE_STOPWORDS.has(raw)) continue;
|
|
52
|
+
tokens.push(foldPlural(raw));
|
|
53
|
+
}
|
|
54
|
+
return tokens;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Index-aligned BM25 scores of each document against the query. Terms are
|
|
58
|
+
* summed in first-occurrence query order so the float result is bit-stable. */
|
|
59
|
+
export function scoreRelevance(query: string, documents: readonly string[]): number[] {
|
|
60
|
+
const scores = documents.map(() => 0);
|
|
61
|
+
const terms = [...new Set(relevanceTokens(query))];
|
|
62
|
+
if (terms.length === 0 || documents.length === 0) return scores;
|
|
63
|
+
|
|
64
|
+
const indexed = documents.map((document) => {
|
|
65
|
+
const tokens = relevanceTokens(document);
|
|
66
|
+
const frequencies = new Map<string, number>();
|
|
67
|
+
for (const token of tokens) frequencies.set(token, (frequencies.get(token) ?? 0) + 1);
|
|
68
|
+
return { length: tokens.length, frequencies };
|
|
69
|
+
});
|
|
70
|
+
const count = indexed.length;
|
|
71
|
+
let totalLength = 0;
|
|
72
|
+
for (const document of indexed) totalLength += document.length;
|
|
73
|
+
const averageLength = totalLength > 0 ? totalLength / count : 1;
|
|
74
|
+
|
|
75
|
+
const inverseFrequency = new Map<string, number>();
|
|
76
|
+
for (const term of terms) {
|
|
77
|
+
let documentFrequency = 0;
|
|
78
|
+
for (const document of indexed) {
|
|
79
|
+
if (document.frequencies.has(term)) documentFrequency += 1;
|
|
80
|
+
}
|
|
81
|
+
inverseFrequency.set(term, Math.log(1 + (count - documentFrequency + 0.5) / (documentFrequency + 0.5)));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
indexed.forEach((document, index) => {
|
|
85
|
+
let score = 0;
|
|
86
|
+
for (const term of terms) {
|
|
87
|
+
const frequency = document.frequencies.get(term) ?? 0;
|
|
88
|
+
if (frequency === 0) continue;
|
|
89
|
+
const lengthNorm = 1 - RELEVANCE_B + RELEVANCE_B * (document.length / averageLength);
|
|
90
|
+
score += inverseFrequency.get(term)! * ((frequency * (RELEVANCE_K1 + 1)) / (frequency + RELEVANCE_K1 * lengthNorm));
|
|
91
|
+
}
|
|
92
|
+
scores[index] = score;
|
|
93
|
+
});
|
|
94
|
+
return scores;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Relevance rounded to three decimals for audits and responses. */
|
|
98
|
+
export function roundRelevance(score: number): number {
|
|
99
|
+
return Math.round(score * 1000) / 1000;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** The text a context item is ranked on: its summary plus its state key.
|
|
103
|
+
* Kind, id and sourceRef are excluded. */
|
|
104
|
+
export function contextItemRelevanceText(item: ContextItem): string {
|
|
105
|
+
return item.stateKey !== undefined ? `${item.summary} ${item.stateKey}` : item.summary;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Locale-independent code-unit order for identifiers. */
|
|
109
|
+
export function compareCodeUnitIds(left: string, right: string): number {
|
|
110
|
+
return left < right ? -1 : left > right ? 1 : 0;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export interface RankedRecallItem {
|
|
114
|
+
item: ContextItem;
|
|
115
|
+
relevance: number;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Rank recall candidates: exact-phrase hits first, then BM25 any-token hits,
|
|
119
|
+
* then id. An empty query is a phrase hit for every item (id order). Items
|
|
120
|
+
* that neither contain the phrase nor share a token are dropped. */
|
|
121
|
+
export function rankRecall(query: string, items: readonly ContextItem[], limit: number): RankedRecallItem[] {
|
|
122
|
+
const needle = query.toLowerCase();
|
|
123
|
+
const scores = scoreRelevance(query, items.map(contextItemRelevanceText));
|
|
124
|
+
const ranked: { item: ContextItem; score: number; phraseHit: boolean }[] = [];
|
|
125
|
+
items.forEach((item, index) => {
|
|
126
|
+
const score = scores[index] ?? 0;
|
|
127
|
+
const phraseHit = needle === ""
|
|
128
|
+
|| item.summary.toLowerCase().includes(needle)
|
|
129
|
+
|| (item.stateKey ?? "").toLowerCase().includes(needle);
|
|
130
|
+
if (phraseHit || score > 0) ranked.push({ item, score, phraseHit });
|
|
131
|
+
});
|
|
132
|
+
ranked.sort((left, right) =>
|
|
133
|
+
(right.phraseHit ? 1 : 0) - (left.phraseHit ? 1 : 0)
|
|
134
|
+
|| right.score - left.score
|
|
135
|
+
|| compareCodeUnitIds(left.item.id, right.item.id),
|
|
136
|
+
);
|
|
137
|
+
return ranked.slice(0, limit).map(({ item, score }) => ({ item, relevance: roundRelevance(score) }));
|
|
138
|
+
}
|