@noir-ai/daemon 1.0.0-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +21 -0
- package/dist/index.d.ts +324 -0
- package/dist/index.js +759 -0
- package/dist/index.js.map +1 -0
- package/package.json +64 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,759 @@
|
|
|
1
|
+
// src/context-seam.ts
|
|
2
|
+
import { ContextEngine } from "@noir-ai/context";
|
|
3
|
+
function buildContextEngine(store, root, projectId, embedderCfg, storeDegraded) {
|
|
4
|
+
return new ContextEngine({ store, root, projectId, embedderCfg, storeDegraded });
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
// src/http.ts
|
|
8
|
+
import { createServer } from "http";
|
|
9
|
+
import {
|
|
10
|
+
localhostHostValidation,
|
|
11
|
+
localhostOriginValidation,
|
|
12
|
+
NodeStreamableHTTPServerTransport
|
|
13
|
+
} from "@modelcontextprotocol/node";
|
|
14
|
+
import { createEmbedFn, resolveEmbedderConfig } from "@noir-ai/context";
|
|
15
|
+
import { resolveMemoryConfig } from "@noir-ai/memory";
|
|
16
|
+
import { resolveModelConfig } from "@noir-ai/model";
|
|
17
|
+
|
|
18
|
+
// src/lifecycle.ts
|
|
19
|
+
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "fs";
|
|
20
|
+
import { homedir } from "os";
|
|
21
|
+
import { join } from "path";
|
|
22
|
+
function noirHome() {
|
|
23
|
+
return join(homedir(), ".noir");
|
|
24
|
+
}
|
|
25
|
+
function daemonJsonPath() {
|
|
26
|
+
return process.env.NOIR_DAEMON_JSON ?? join(noirHome(), "daemon.json");
|
|
27
|
+
}
|
|
28
|
+
function readDaemonRecord() {
|
|
29
|
+
try {
|
|
30
|
+
const raw = readFileSync(daemonJsonPath(), "utf8");
|
|
31
|
+
const rec = JSON.parse(raw);
|
|
32
|
+
if (typeof rec.pid === "number" && typeof rec.port === "number") return rec;
|
|
33
|
+
return null;
|
|
34
|
+
} catch {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
function writeDaemonRecord(rec) {
|
|
39
|
+
mkdirSync(noirHome(), { recursive: true });
|
|
40
|
+
writeFileSync(daemonJsonPath(), `${JSON.stringify(rec)}
|
|
41
|
+
`, "utf8");
|
|
42
|
+
}
|
|
43
|
+
function clearDaemonRecord() {
|
|
44
|
+
if (existsSync(daemonJsonPath())) rmSync(daemonJsonPath(), { force: true });
|
|
45
|
+
}
|
|
46
|
+
function pidAlive(pid) {
|
|
47
|
+
try {
|
|
48
|
+
process.kill(pid, 0);
|
|
49
|
+
return true;
|
|
50
|
+
} catch {
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// src/memory-seam.ts
|
|
56
|
+
import {
|
|
57
|
+
createMemoryEngine
|
|
58
|
+
} from "@noir-ai/memory";
|
|
59
|
+
import { complete } from "@noir-ai/model";
|
|
60
|
+
function resolveMemoryConsolidation(modelCfg) {
|
|
61
|
+
if (!modelCfg) return null;
|
|
62
|
+
const providerKey = modelCfg.tiers.consolidate ?? modelCfg.defaultProvider;
|
|
63
|
+
if (!providerKey) return null;
|
|
64
|
+
const block = modelCfg.providers[providerKey];
|
|
65
|
+
if (!block || !block.model) return null;
|
|
66
|
+
return { provider: providerKey, model: block.model };
|
|
67
|
+
}
|
|
68
|
+
function resolveConsolidationCapability(resolvedMemory, modelCfg) {
|
|
69
|
+
const cons = resolvedMemory?.consolidation;
|
|
70
|
+
if (cons?.enabled !== true) return null;
|
|
71
|
+
if (cons.provider) {
|
|
72
|
+
return cons.model ? { provider: cons.provider, model: cons.model } : null;
|
|
73
|
+
}
|
|
74
|
+
return resolveMemoryConsolidation(modelCfg);
|
|
75
|
+
}
|
|
76
|
+
function bindMemoryModel(modelCfg) {
|
|
77
|
+
return {
|
|
78
|
+
async complete(req) {
|
|
79
|
+
const result = await complete(
|
|
80
|
+
{
|
|
81
|
+
system: req.system,
|
|
82
|
+
prompt: req.prompt,
|
|
83
|
+
provider: req.provider,
|
|
84
|
+
model: req.model,
|
|
85
|
+
...req.maxTokens !== void 0 ? { maxTokens: req.maxTokens } : {},
|
|
86
|
+
...req.tier !== void 0 ? { tier: req.tier } : {}
|
|
87
|
+
},
|
|
88
|
+
modelCfg
|
|
89
|
+
);
|
|
90
|
+
if (result === null) return null;
|
|
91
|
+
if (!result.ok) return { ok: false, reason: result.reason };
|
|
92
|
+
return { ok: true, text: result.text };
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
function buildMemoryEngine(store, root, projectId, embed, modelCfg, storeDegraded, resolvedMemory) {
|
|
97
|
+
const cons = resolveConsolidationCapability(resolvedMemory, modelCfg);
|
|
98
|
+
if (!cons) {
|
|
99
|
+
return createMemoryEngine({
|
|
100
|
+
store,
|
|
101
|
+
root,
|
|
102
|
+
projectId,
|
|
103
|
+
embed,
|
|
104
|
+
storeDegraded,
|
|
105
|
+
...resolvedMemory ? { config: resolvedMemory } : {}
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
const model = modelCfg ? bindMemoryModel(modelCfg) : void 0;
|
|
109
|
+
const config = {
|
|
110
|
+
consolidation: { enabled: true, provider: cons.provider, model: cons.model }
|
|
111
|
+
};
|
|
112
|
+
return createMemoryEngine({
|
|
113
|
+
store,
|
|
114
|
+
root,
|
|
115
|
+
projectId,
|
|
116
|
+
embed,
|
|
117
|
+
...model ? { model } : {},
|
|
118
|
+
config,
|
|
119
|
+
storeDegraded
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// src/server.ts
|
|
124
|
+
import { McpServer } from "@modelcontextprotocol/server";
|
|
125
|
+
import { NOIR_VERSION as NOIR_VERSION2 } from "@noir-ai/core";
|
|
126
|
+
import { PHASES } from "@noir-ai/workflow";
|
|
127
|
+
import { z } from "zod";
|
|
128
|
+
|
|
129
|
+
// src/status.ts
|
|
130
|
+
import { NOIR_VERSION } from "@noir-ai/core";
|
|
131
|
+
function buildStatus(project, ctx) {
|
|
132
|
+
const status = {
|
|
133
|
+
noir: NOIR_VERSION,
|
|
134
|
+
project: { id: project.id, name: project.name },
|
|
135
|
+
host: project.config.host,
|
|
136
|
+
transport: ctx.transport,
|
|
137
|
+
daemon: ctx.daemon
|
|
138
|
+
};
|
|
139
|
+
if (ctx.pid !== void 0) status.pid = ctx.pid;
|
|
140
|
+
if (ctx.startedAt !== void 0) {
|
|
141
|
+
status.uptimeSec = Math.max(0, Math.floor((Date.now() - ctx.startedAt) / 1e3));
|
|
142
|
+
}
|
|
143
|
+
return status;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// src/server.ts
|
|
147
|
+
var GATE_PHASES = ["spec", "plan", "verify"];
|
|
148
|
+
function buildStoreStatus(store, dbPath, degraded = false) {
|
|
149
|
+
return {
|
|
150
|
+
ok: true,
|
|
151
|
+
projectId: store.projectId,
|
|
152
|
+
docCount: store.countDocs(),
|
|
153
|
+
vecCount: store.countVecs(),
|
|
154
|
+
dbPath: dbPath ?? null,
|
|
155
|
+
degraded
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
function nextGateAfter(phase) {
|
|
159
|
+
const cur = PHASES.indexOf(phase);
|
|
160
|
+
for (const p of GATE_PHASES) {
|
|
161
|
+
if (PHASES.indexOf(p) > cur) return p;
|
|
162
|
+
}
|
|
163
|
+
return null;
|
|
164
|
+
}
|
|
165
|
+
function buildWorkflowStatus(engine, taskId, degraded = false) {
|
|
166
|
+
const task = engine.status(taskId);
|
|
167
|
+
if (!task) return null;
|
|
168
|
+
const stopped = task.state === "blocked" || task.state === "abandoned";
|
|
169
|
+
return {
|
|
170
|
+
ok: true,
|
|
171
|
+
taskId: task.taskId,
|
|
172
|
+
phase: task.phase,
|
|
173
|
+
state: task.state,
|
|
174
|
+
nextGate: stopped ? null : nextGateAfter(task.phase),
|
|
175
|
+
mode: task.mode,
|
|
176
|
+
history: task.history,
|
|
177
|
+
updatedAt: task.updatedAt,
|
|
178
|
+
degraded
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
function textResult(obj) {
|
|
182
|
+
return { content: [{ type: "text", text: JSON.stringify(obj, null, 2) }] };
|
|
183
|
+
}
|
|
184
|
+
function errorMessage(err) {
|
|
185
|
+
return err instanceof Error ? err.message : String(err);
|
|
186
|
+
}
|
|
187
|
+
function createNoirServer(ctx) {
|
|
188
|
+
const server = new McpServer({ name: "noir", version: NOIR_VERSION2 });
|
|
189
|
+
server.registerTool(
|
|
190
|
+
"host_status",
|
|
191
|
+
{
|
|
192
|
+
description: "Report Noir's runtime status: project id/name, host CLI, transport, and daemon state.",
|
|
193
|
+
// Empty ZodRawShape => no input parameters (MCP SDK v2 registerTool overload 2).
|
|
194
|
+
inputSchema: {}
|
|
195
|
+
},
|
|
196
|
+
async () => {
|
|
197
|
+
const status = buildStatus(ctx.project, ctx);
|
|
198
|
+
return { content: [{ type: "text", text: JSON.stringify(status, null, 2) }] };
|
|
199
|
+
}
|
|
200
|
+
);
|
|
201
|
+
if (ctx.store) {
|
|
202
|
+
const store = ctx.store;
|
|
203
|
+
const dbPath = ctx.dbPath;
|
|
204
|
+
const degraded = ctx.storeDegraded === true;
|
|
205
|
+
server.registerTool(
|
|
206
|
+
"store_status",
|
|
207
|
+
{
|
|
208
|
+
description: "Report the Noir embedded store's health: project id, document and vector counts, DB path, and degraded state.",
|
|
209
|
+
inputSchema: {}
|
|
210
|
+
},
|
|
211
|
+
async () => {
|
|
212
|
+
const payload = buildStoreStatus(store, dbPath, degraded);
|
|
213
|
+
return {
|
|
214
|
+
content: [{ type: "text", text: JSON.stringify(payload, null, 2) }]
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
if (ctx.engine) {
|
|
220
|
+
const engine = ctx.engine;
|
|
221
|
+
const degraded = ctx.storeDegraded === true;
|
|
222
|
+
server.registerTool(
|
|
223
|
+
"workflow_status",
|
|
224
|
+
{
|
|
225
|
+
description: "Report the active Noir SDD task's phase, state, the next gate ahead, mode, and observable gate history. Omit taskId to read the active task.",
|
|
226
|
+
inputSchema: {
|
|
227
|
+
taskId: z.string().optional().describe("Task id; defaults to the active task (workflow:active).")
|
|
228
|
+
}
|
|
229
|
+
},
|
|
230
|
+
async ({ taskId }) => {
|
|
231
|
+
const id = taskId ?? engine.activeTaskId();
|
|
232
|
+
if (!id) return textResult({ ok: false, error: "no active task" });
|
|
233
|
+
const payload = buildWorkflowStatus(engine, id, degraded);
|
|
234
|
+
if (!payload) return textResult({ ok: false, taskId: id, error: "unknown task" });
|
|
235
|
+
return textResult(payload);
|
|
236
|
+
}
|
|
237
|
+
);
|
|
238
|
+
server.registerTool(
|
|
239
|
+
"checkpoint",
|
|
240
|
+
{
|
|
241
|
+
description: "Checkpoint a Noir SDD task: `save` flushes the in-flight state to the store KV; `restore` reads it back. Omit taskId to target the active task.",
|
|
242
|
+
inputSchema: {
|
|
243
|
+
action: z.enum(["save", "restore"]).describe("'save' flushes state; 'restore' returns the in-flight task state."),
|
|
244
|
+
taskId: z.string().optional().describe("Task id; defaults to the active task (workflow:active).")
|
|
245
|
+
}
|
|
246
|
+
},
|
|
247
|
+
async ({ action, taskId }) => {
|
|
248
|
+
const id = taskId ?? engine.activeTaskId();
|
|
249
|
+
if (!id) return textResult({ ok: false, error: "no active task" });
|
|
250
|
+
if (action === "save") {
|
|
251
|
+
try {
|
|
252
|
+
await engine.checkpoint(id);
|
|
253
|
+
} catch (err) {
|
|
254
|
+
return textResult({
|
|
255
|
+
ok: false,
|
|
256
|
+
action: "save",
|
|
257
|
+
taskId: id,
|
|
258
|
+
degraded: true,
|
|
259
|
+
error: errorMessage(err)
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
const payload = buildWorkflowStatus(engine, id, degraded);
|
|
264
|
+
if (!payload) return textResult({ ok: false, taskId: id, error: "unknown task" });
|
|
265
|
+
return textResult({ action, ...payload });
|
|
266
|
+
}
|
|
267
|
+
);
|
|
268
|
+
server.registerTool(
|
|
269
|
+
"workflow_start",
|
|
270
|
+
{
|
|
271
|
+
description: "Start a Noir SDD task at draft/intake and make it the active task (workflow:active). Re-starting an existing taskId overwrites it (the KV is the source of truth, not a journal). Defaults to full mode.",
|
|
272
|
+
inputSchema: {
|
|
273
|
+
taskId: z.string().min(1).describe("Stable task handle (re-starting overwrites)."),
|
|
274
|
+
slug: z.string().min(1).describe('Human-readable slug, e.g. "add-login".'),
|
|
275
|
+
mode: z.enum(["full", "quick"]).optional().describe("Mode: 'full' (default) or 'quick'.")
|
|
276
|
+
}
|
|
277
|
+
},
|
|
278
|
+
async ({ taskId, slug, mode }) => {
|
|
279
|
+
if (degraded) {
|
|
280
|
+
return textResult({
|
|
281
|
+
ok: false,
|
|
282
|
+
degraded: true,
|
|
283
|
+
error: "store is read-only (daemon down) \u2014 workflow_start is unavailable"
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
try {
|
|
287
|
+
const resolvedMode = mode ?? "full";
|
|
288
|
+
await engine.startTask(taskId, slug, resolvedMode);
|
|
289
|
+
const payload = buildWorkflowStatus(engine, taskId, degraded);
|
|
290
|
+
if (!payload) return textResult({ ok: false, taskId, error: "unknown task" });
|
|
291
|
+
return textResult(payload);
|
|
292
|
+
} catch (err) {
|
|
293
|
+
return textResult({ ok: false, degraded: true, error: errorMessage(err) });
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
);
|
|
297
|
+
server.registerTool(
|
|
298
|
+
"workflow_advance",
|
|
299
|
+
{
|
|
300
|
+
description: "Advance a Noir SDD task to its next phase, or jump with `to`. At a gate-landing state (entering specified/planned/done) a gate is recorded \u2014 approved by default, forced (with reason) via `force`, or skipped via `skip`. Omit taskId to target the active task. `force` and `skip` are mutually exclusive.",
|
|
301
|
+
inputSchema: {
|
|
302
|
+
taskId: z.string().optional().describe("Task id; defaults to the active task (workflow:active)."),
|
|
303
|
+
force: z.object({ reason: z.string().min(1) }).optional().describe(
|
|
304
|
+
"Pass the next gate without satisfying its criteria; requires a non-empty reason. Mutually exclusive with skip."
|
|
305
|
+
),
|
|
306
|
+
to: z.enum(["intake", "clarify", "spec", "plan", "execute", "verify", "document"]).optional().describe("Jump directly to a phase, bypassing the FSM."),
|
|
307
|
+
skip: z.boolean().optional().describe(
|
|
308
|
+
"Quick-mode: record the landing gate as 'skipped' instead of 'approved'. Mutually exclusive with force."
|
|
309
|
+
)
|
|
310
|
+
}
|
|
311
|
+
},
|
|
312
|
+
async ({ taskId, force, to, skip }) => {
|
|
313
|
+
if (degraded) {
|
|
314
|
+
return textResult({
|
|
315
|
+
ok: false,
|
|
316
|
+
degraded: true,
|
|
317
|
+
error: "store is read-only (daemon down) \u2014 workflow_advance is unavailable"
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
try {
|
|
321
|
+
const id = taskId ?? engine.activeTaskId();
|
|
322
|
+
if (!id) return textResult({ ok: false, error: "no active task" });
|
|
323
|
+
const opts = {};
|
|
324
|
+
if (force) opts.force = { reason: force.reason };
|
|
325
|
+
if (to) opts.to = to;
|
|
326
|
+
if (skip) opts.skip = true;
|
|
327
|
+
await engine.advance(id, opts);
|
|
328
|
+
const payload = buildWorkflowStatus(engine, id, degraded);
|
|
329
|
+
if (!payload) return textResult({ ok: false, taskId: id, error: "unknown task" });
|
|
330
|
+
return textResult(payload);
|
|
331
|
+
} catch (err) {
|
|
332
|
+
return textResult({ ok: false, degraded: true, error: errorMessage(err) });
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
);
|
|
336
|
+
}
|
|
337
|
+
if (ctx.context) {
|
|
338
|
+
const context = ctx.context;
|
|
339
|
+
const storeDegraded = ctx.storeDegraded === true;
|
|
340
|
+
server.registerTool(
|
|
341
|
+
"context_search",
|
|
342
|
+
{
|
|
343
|
+
description: "Hybrid search over the Noir context index: BM25 \u222A cosine-kNN fused by Reciprocal Rank Fusion (k=60), packed into a token budget with window-extracted snippets (never truncated). Returns ranked hits with path, snippet, and score.",
|
|
344
|
+
inputSchema: {
|
|
345
|
+
query: z.string().describe('Natural-language or identifier query (e.g. "ContextEngine").'),
|
|
346
|
+
limit: z.number().int().positive().optional().describe("Max hits requested from each leg before fusion (default 10)."),
|
|
347
|
+
budgetTokens: z.number().int().positive().optional().describe("Token budget for the packed result set (default 4096)."),
|
|
348
|
+
// Singular `source` (not the spec's plural `sources`): both store
|
|
349
|
+
// primitives (SearchFtOpts.source / VecOpts.source) and the engine's
|
|
350
|
+
// SearchOptions.source take a single string, so a plural array is not
|
|
351
|
+
// honor-able here. Spec F9 source-filtering surfaces as one bucket.
|
|
352
|
+
source: z.string().optional().describe('Restrict both legs to a single source bucket (e.g. "docs", "codebase").')
|
|
353
|
+
}
|
|
354
|
+
},
|
|
355
|
+
async ({ query, limit, budgetTokens, source }) => {
|
|
356
|
+
try {
|
|
357
|
+
const result = await context.search(query, { limit, budgetTokens, source });
|
|
358
|
+
return textResult({ ok: true, ...result });
|
|
359
|
+
} catch (err) {
|
|
360
|
+
return textResult({ ok: false, degraded: true, error: errorMessage(err) });
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
);
|
|
364
|
+
server.registerTool(
|
|
365
|
+
"context_index",
|
|
366
|
+
{
|
|
367
|
+
description: "Incrementally index files/directories into the Noir context store (SHA-256 content-hash; unchanged files are skipped). Indexes docs + 384-dim vectors into the existing tables (no schema migration). Omit paths to index the project root.",
|
|
368
|
+
inputSchema: {
|
|
369
|
+
paths: z.array(z.string()).optional().describe('Files/directories to index (repo-relative or absolute); defaults to ["."].')
|
|
370
|
+
}
|
|
371
|
+
},
|
|
372
|
+
async ({ paths: paths2 }) => {
|
|
373
|
+
if (storeDegraded) {
|
|
374
|
+
return textResult({
|
|
375
|
+
ok: false,
|
|
376
|
+
degraded: true,
|
|
377
|
+
error: "store is read-only (daemon down) \u2014 context_index is unavailable"
|
|
378
|
+
});
|
|
379
|
+
}
|
|
380
|
+
try {
|
|
381
|
+
const result = await context.indexPaths(paths2 && paths2.length > 0 ? paths2 : ["."]);
|
|
382
|
+
return textResult({ ok: true, ...result });
|
|
383
|
+
} catch (err) {
|
|
384
|
+
return textResult({ ok: false, degraded: true, error: errorMessage(err) });
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
);
|
|
388
|
+
server.registerTool(
|
|
389
|
+
"context_status",
|
|
390
|
+
{
|
|
391
|
+
description: "Report the Noir context index's health: project id, document + vector counts, indexed file count, the active embedder (kind/model/dim), and degraded state.",
|
|
392
|
+
inputSchema: {}
|
|
393
|
+
},
|
|
394
|
+
async () => {
|
|
395
|
+
try {
|
|
396
|
+
return textResult(context.status());
|
|
397
|
+
} catch (err) {
|
|
398
|
+
return textResult({ ok: false, degraded: true, error: errorMessage(err) });
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
);
|
|
402
|
+
}
|
|
403
|
+
if (ctx.memory) {
|
|
404
|
+
const memory = ctx.memory;
|
|
405
|
+
const storeDegraded = ctx.storeDegraded === true;
|
|
406
|
+
server.registerTool(
|
|
407
|
+
"memory_save",
|
|
408
|
+
{
|
|
409
|
+
description: "Persist a cross-session memory observation (pattern / preference / architecture / bug / workflow / fact / decision). Stored locally on top of the Noir store (FTS5 + vectors + KV) \u2014 never truncated, never sent to an LLM. Returns the full saved observation.",
|
|
410
|
+
inputSchema: {
|
|
411
|
+
content: z.string().min(1).describe("The insight to remember (full text; never truncated)."),
|
|
412
|
+
// Open enum (DS-3): unknown types are accepted + stored, so this is a
|
|
413
|
+
// free-form string (with the known values described) rather than a
|
|
414
|
+
// closed zod enum that would reject forward-compatible types.
|
|
415
|
+
type: z.string().optional().describe(
|
|
416
|
+
"Observation type \u2014 pattern | preference | architecture | bug | workflow | fact | decision. Unknown values are accepted."
|
|
417
|
+
),
|
|
418
|
+
concepts: z.array(z.string()).optional().describe("User tags (no auto-LLM tagging in v1 \u2014 explicit only)."),
|
|
419
|
+
files: z.array(z.string()).optional().describe("Repo-relative paths mentioned."),
|
|
420
|
+
importance: z.number().min(0).max(1).optional().describe("Salience 0..1 (defaults to 0.5)."),
|
|
421
|
+
sessionId: z.string().optional().describe("Host session id (recorded when known).")
|
|
422
|
+
}
|
|
423
|
+
},
|
|
424
|
+
async (input) => {
|
|
425
|
+
if (storeDegraded) {
|
|
426
|
+
return textResult({
|
|
427
|
+
ok: false,
|
|
428
|
+
degraded: true,
|
|
429
|
+
error: "store is read-only (daemon down) \u2014 memory_save is unavailable"
|
|
430
|
+
});
|
|
431
|
+
}
|
|
432
|
+
try {
|
|
433
|
+
const observation = await memory.save(input);
|
|
434
|
+
return textResult({ ok: true, id: observation.id, observation });
|
|
435
|
+
} catch (err) {
|
|
436
|
+
return textResult({ ok: false, degraded: true, error: errorMessage(err) });
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
);
|
|
440
|
+
server.registerTool(
|
|
441
|
+
"memory_recall",
|
|
442
|
+
{
|
|
443
|
+
description: 'Hybrid recall over cross-session memory: BM25 \u222A cosine-kNN fused by Reciprocal Rank Fusion (k=60) scoped to source:"memory", plus a cheap entity-boost. Returns ranked observations with FULL content (hydrated from the authoritative KV row \u2014 never the truncated FTS snippet). Degrades to BM25-only when the embedder is unavailable.',
|
|
444
|
+
inputSchema: {
|
|
445
|
+
query: z.string().describe("Natural-language or identifier query."),
|
|
446
|
+
limit: z.number().int().positive().optional().describe("Max results (default 10)."),
|
|
447
|
+
type: z.string().optional().describe("Filter to a single observation type."),
|
|
448
|
+
sessionId: z.string().optional().describe("Filter to a single host session.")
|
|
449
|
+
}
|
|
450
|
+
},
|
|
451
|
+
async ({ query, limit, type, sessionId }) => {
|
|
452
|
+
try {
|
|
453
|
+
const results = await memory.recall(query, { limit, type, sessionId });
|
|
454
|
+
return textResult({ ok: true, results });
|
|
455
|
+
} catch (err) {
|
|
456
|
+
return textResult({ ok: false, degraded: true, error: errorMessage(err) });
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
);
|
|
460
|
+
server.registerTool(
|
|
461
|
+
"memory_search",
|
|
462
|
+
{
|
|
463
|
+
description: 'Instant BM25-only lookup over cross-session memory (no embedding cost). Returns ranked observations with FULL content, scoped to source:"memory". Use memory_recall for the hybrid (vector + BM25) path.',
|
|
464
|
+
inputSchema: {
|
|
465
|
+
query: z.string().describe("Natural-language or identifier query."),
|
|
466
|
+
limit: z.number().int().positive().optional().describe("Max results (default 10).")
|
|
467
|
+
}
|
|
468
|
+
},
|
|
469
|
+
async ({ query, limit }) => {
|
|
470
|
+
try {
|
|
471
|
+
const hits = await memory.search(query, { limit });
|
|
472
|
+
return textResult({ ok: true, hits });
|
|
473
|
+
} catch (err) {
|
|
474
|
+
return textResult({ ok: false, degraded: true, error: errorMessage(err) });
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
);
|
|
478
|
+
server.registerTool(
|
|
479
|
+
"memory_sessions",
|
|
480
|
+
{
|
|
481
|
+
description: "List per-session memory rollups (session id, observation count, most-recent timestamp) for this project's cross-session memory.",
|
|
482
|
+
inputSchema: {}
|
|
483
|
+
},
|
|
484
|
+
async () => {
|
|
485
|
+
try {
|
|
486
|
+
return textResult({ ok: true, sessions: memory.sessions() });
|
|
487
|
+
} catch (err) {
|
|
488
|
+
return textResult({ ok: false, degraded: true, error: errorMessage(err) });
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
);
|
|
492
|
+
server.registerTool(
|
|
493
|
+
"memory_forget",
|
|
494
|
+
{
|
|
495
|
+
description: "Remove observations from cross-session memory: deletes the authoritative KV row + best-effort FTS/vector purge. Returns the count actually removed.",
|
|
496
|
+
inputSchema: {
|
|
497
|
+
ids: z.array(z.string()).min(1).describe("Observation ids to remove.")
|
|
498
|
+
}
|
|
499
|
+
},
|
|
500
|
+
async ({ ids }) => {
|
|
501
|
+
if (storeDegraded) {
|
|
502
|
+
return textResult({
|
|
503
|
+
ok: false,
|
|
504
|
+
degraded: true,
|
|
505
|
+
error: "store is read-only (daemon down) \u2014 memory_forget is unavailable"
|
|
506
|
+
});
|
|
507
|
+
}
|
|
508
|
+
try {
|
|
509
|
+
const result = memory.forget(ids);
|
|
510
|
+
return textResult({ ok: true, ...result });
|
|
511
|
+
} catch (err) {
|
|
512
|
+
return textResult({ ok: false, degraded: true, error: errorMessage(err) });
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
);
|
|
516
|
+
if (ctx.memoryConsolidation === true) {
|
|
517
|
+
server.registerTool(
|
|
518
|
+
"memory_consolidate",
|
|
519
|
+
{
|
|
520
|
+
description: 'Explicitly consolidate recent memory observations into ONE derived lesson (append-only; originals are never mutated). Provider-gated: refuses + logs if no provider is configured \u2014 NEVER a silent paid call. Emits a type:"lesson" observation with provenance.',
|
|
521
|
+
inputSchema: {
|
|
522
|
+
types: z.array(z.string()).optional().describe("Restrict candidates to these observation types."),
|
|
523
|
+
limit: z.number().int().positive().optional().describe("Cap on candidate observations.")
|
|
524
|
+
}
|
|
525
|
+
},
|
|
526
|
+
async ({ types, limit }) => {
|
|
527
|
+
try {
|
|
528
|
+
const result = await memory.consolidate?.({ types, limit });
|
|
529
|
+
if (result === void 0) {
|
|
530
|
+
return textResult({
|
|
531
|
+
ok: false,
|
|
532
|
+
degraded: true,
|
|
533
|
+
error: "consolidation is not wired on this engine"
|
|
534
|
+
});
|
|
535
|
+
}
|
|
536
|
+
return textResult(result);
|
|
537
|
+
} catch (err) {
|
|
538
|
+
return textResult({ ok: false, degraded: true, error: errorMessage(err) });
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
);
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
return server;
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
// src/store-seam.ts
|
|
548
|
+
import { paths } from "@noir-ai/core";
|
|
549
|
+
import { openStore } from "@noir-ai/store";
|
|
550
|
+
async function openStoreForDaemon(projectId, root) {
|
|
551
|
+
const dbPath = paths.storeDb(root, projectId);
|
|
552
|
+
try {
|
|
553
|
+
const store = await openStore({ projectId, root });
|
|
554
|
+
return { store, dbPath, degraded: false };
|
|
555
|
+
} catch {
|
|
556
|
+
const store = await openStore({ projectId, root, readonly: true });
|
|
557
|
+
return { store, dbPath, degraded: true };
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
// src/workflow-seam.ts
|
|
562
|
+
import { WorkflowEngine } from "@noir-ai/workflow";
|
|
563
|
+
function buildWorkflowEngine(store, root, projectId) {
|
|
564
|
+
return new WorkflowEngine(store, root, projectId);
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
// src/http.ts
|
|
568
|
+
async function startHttpServer(opts) {
|
|
569
|
+
const startedAt = Date.now();
|
|
570
|
+
const pid = process.pid;
|
|
571
|
+
const validateHost = localhostHostValidation();
|
|
572
|
+
const validateOrigin = localhostOriginValidation();
|
|
573
|
+
let lastActivity = Date.now();
|
|
574
|
+
let idleTimer = setInterval(() => {
|
|
575
|
+
if (Date.now() - lastActivity > opts.idleTimeoutSec * 1e3) void shutdown();
|
|
576
|
+
}, 1e4);
|
|
577
|
+
const daemonStore = await openStoreForDaemon(opts.project.id, opts.project.root).catch(
|
|
578
|
+
() => void 0
|
|
579
|
+
);
|
|
580
|
+
const engine = daemonStore ? buildWorkflowEngine(daemonStore.store, opts.project.root, opts.project.id) : void 0;
|
|
581
|
+
const embedderCfg = resolveEmbedderConfig(opts.project.config.context);
|
|
582
|
+
const embed = createEmbedFn(embedderCfg).embed;
|
|
583
|
+
const context = daemonStore ? buildContextEngine(
|
|
584
|
+
daemonStore.store,
|
|
585
|
+
opts.project.root,
|
|
586
|
+
opts.project.id,
|
|
587
|
+
embedderCfg,
|
|
588
|
+
daemonStore.degraded
|
|
589
|
+
) : void 0;
|
|
590
|
+
const modelCfg = resolveModelConfig(opts.project.config.model);
|
|
591
|
+
const resolvedMemory = resolveMemoryConfig(opts.project.config.memory);
|
|
592
|
+
const memoryConsolidation = resolveConsolidationCapability(resolvedMemory, modelCfg) !== null;
|
|
593
|
+
const memory = daemonStore ? buildMemoryEngine(
|
|
594
|
+
daemonStore.store,
|
|
595
|
+
opts.project.root,
|
|
596
|
+
opts.project.id,
|
|
597
|
+
embed,
|
|
598
|
+
modelCfg,
|
|
599
|
+
daemonStore.degraded,
|
|
600
|
+
resolvedMemory
|
|
601
|
+
) : void 0;
|
|
602
|
+
const httpServer = createServer(async (req, res) => {
|
|
603
|
+
lastActivity = Date.now();
|
|
604
|
+
if (req.method === "GET" && req.url === "/health") {
|
|
605
|
+
res.writeHead(200, { "content-type": "application/json" });
|
|
606
|
+
res.end(
|
|
607
|
+
JSON.stringify({ ok: true, pid, uptimeSec: Math.floor((Date.now() - startedAt) / 1e3) })
|
|
608
|
+
);
|
|
609
|
+
return;
|
|
610
|
+
}
|
|
611
|
+
if (!validateHost(req, res) || !validateOrigin(req, res)) return;
|
|
612
|
+
if (req.url === "/mcp") {
|
|
613
|
+
const server = createNoirServer({
|
|
614
|
+
project: opts.project,
|
|
615
|
+
transport: "streamable-http",
|
|
616
|
+
daemon: true,
|
|
617
|
+
pid,
|
|
618
|
+
startedAt,
|
|
619
|
+
...daemonStore ? {
|
|
620
|
+
store: daemonStore.store,
|
|
621
|
+
dbPath: daemonStore.dbPath,
|
|
622
|
+
storeDegraded: daemonStore.degraded
|
|
623
|
+
} : {},
|
|
624
|
+
...engine ? { engine } : {},
|
|
625
|
+
...context ? { context } : {},
|
|
626
|
+
...memory ? { memory, memoryConsolidation } : {}
|
|
627
|
+
});
|
|
628
|
+
const transport = new NodeStreamableHTTPServerTransport({ sessionIdGenerator: void 0 });
|
|
629
|
+
await server.connect(transport);
|
|
630
|
+
await transport.handleRequest(req, res);
|
|
631
|
+
return;
|
|
632
|
+
}
|
|
633
|
+
res.writeHead(404).end("not found");
|
|
634
|
+
});
|
|
635
|
+
const port = await new Promise((resolve, reject) => {
|
|
636
|
+
httpServer.once("error", reject);
|
|
637
|
+
httpServer.listen(opts.port ?? 0, "127.0.0.1", () => {
|
|
638
|
+
const addr = httpServer.address();
|
|
639
|
+
httpServer.removeListener("error", reject);
|
|
640
|
+
resolve(typeof addr === "object" && addr ? addr.port : 0);
|
|
641
|
+
});
|
|
642
|
+
});
|
|
643
|
+
const rec = { pid, port, startedAt };
|
|
644
|
+
writeDaemonRecord(rec);
|
|
645
|
+
async function shutdown() {
|
|
646
|
+
if (idleTimer) {
|
|
647
|
+
clearInterval(idleTimer);
|
|
648
|
+
idleTimer = void 0;
|
|
649
|
+
}
|
|
650
|
+
await new Promise((r) => httpServer.close(() => r()));
|
|
651
|
+
await daemonStore?.store.close().catch(() => void 0);
|
|
652
|
+
clearDaemonRecord();
|
|
653
|
+
}
|
|
654
|
+
for (const sig of ["SIGTERM", "SIGINT"]) {
|
|
655
|
+
process.once(sig, () => void shutdown().then(() => process.exit(0)));
|
|
656
|
+
}
|
|
657
|
+
return { port, pid, startedAt, stop: shutdown };
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
// src/ensure.ts
|
|
661
|
+
async function isHealthy(port) {
|
|
662
|
+
try {
|
|
663
|
+
const res = await fetch(`http://127.0.0.1:${port}/health`);
|
|
664
|
+
return res.status === 200;
|
|
665
|
+
} catch {
|
|
666
|
+
return false;
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
async function ensureDaemonRunning(opts) {
|
|
670
|
+
const rec = readDaemonRecord();
|
|
671
|
+
if (rec) {
|
|
672
|
+
if (pidAlive(rec.pid) && await isHealthy(rec.port)) {
|
|
673
|
+
return {
|
|
674
|
+
port: rec.port,
|
|
675
|
+
url: `http://127.0.0.1:${rec.port}/mcp`,
|
|
676
|
+
started: false,
|
|
677
|
+
stop: async () => {
|
|
678
|
+
}
|
|
679
|
+
};
|
|
680
|
+
}
|
|
681
|
+
clearDaemonRecord();
|
|
682
|
+
}
|
|
683
|
+
const running = await startHttpServer({
|
|
684
|
+
project: opts.project,
|
|
685
|
+
idleTimeoutSec: opts.idleTimeoutSec
|
|
686
|
+
});
|
|
687
|
+
return {
|
|
688
|
+
port: running.port,
|
|
689
|
+
url: `http://127.0.0.1:${running.port}/mcp`,
|
|
690
|
+
started: true,
|
|
691
|
+
stop: running.stop
|
|
692
|
+
};
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
// src/stdio.ts
|
|
696
|
+
import { StdioServerTransport } from "@modelcontextprotocol/server/stdio";
|
|
697
|
+
import { createEmbedFn as createEmbedFn2, resolveEmbedderConfig as resolveEmbedderConfig2 } from "@noir-ai/context";
|
|
698
|
+
import { resolveMemoryConfig as resolveMemoryConfig2 } from "@noir-ai/memory";
|
|
699
|
+
import { resolveModelConfig as resolveModelConfig2 } from "@noir-ai/model";
|
|
700
|
+
async function startStdioServer(ctx) {
|
|
701
|
+
const daemonStore = await openStoreForDaemon(ctx.project.id, ctx.project.root).catch(
|
|
702
|
+
() => void 0
|
|
703
|
+
);
|
|
704
|
+
const engine = daemonStore ? buildWorkflowEngine(daemonStore.store, ctx.project.root, ctx.project.id) : void 0;
|
|
705
|
+
const embedderCfg = resolveEmbedderConfig2(ctx.project.config.context);
|
|
706
|
+
const embed = createEmbedFn2(embedderCfg).embed;
|
|
707
|
+
const context = daemonStore ? buildContextEngine(
|
|
708
|
+
daemonStore.store,
|
|
709
|
+
ctx.project.root,
|
|
710
|
+
ctx.project.id,
|
|
711
|
+
embedderCfg,
|
|
712
|
+
daemonStore.degraded
|
|
713
|
+
) : void 0;
|
|
714
|
+
const modelCfg = resolveModelConfig2(ctx.project.config.model);
|
|
715
|
+
const resolvedMemory = resolveMemoryConfig2(ctx.project.config.memory);
|
|
716
|
+
const memoryConsolidation = resolveConsolidationCapability(resolvedMemory, modelCfg) !== null;
|
|
717
|
+
const memory = daemonStore ? buildMemoryEngine(
|
|
718
|
+
daemonStore.store,
|
|
719
|
+
ctx.project.root,
|
|
720
|
+
ctx.project.id,
|
|
721
|
+
embed,
|
|
722
|
+
modelCfg,
|
|
723
|
+
daemonStore.degraded,
|
|
724
|
+
resolvedMemory
|
|
725
|
+
) : void 0;
|
|
726
|
+
const server = createNoirServer({
|
|
727
|
+
...ctx,
|
|
728
|
+
...daemonStore ? {
|
|
729
|
+
store: daemonStore.store,
|
|
730
|
+
dbPath: daemonStore.dbPath,
|
|
731
|
+
storeDegraded: daemonStore.degraded
|
|
732
|
+
} : {},
|
|
733
|
+
...engine ? { engine } : {},
|
|
734
|
+
...context ? { context } : {},
|
|
735
|
+
...memory ? { memory, memoryConsolidation } : {}
|
|
736
|
+
});
|
|
737
|
+
const transport = new StdioServerTransport();
|
|
738
|
+
await server.connect(transport);
|
|
739
|
+
}
|
|
740
|
+
export {
|
|
741
|
+
buildContextEngine,
|
|
742
|
+
buildMemoryEngine,
|
|
743
|
+
buildStatus,
|
|
744
|
+
buildWorkflowEngine,
|
|
745
|
+
clearDaemonRecord,
|
|
746
|
+
createNoirServer,
|
|
747
|
+
daemonJsonPath,
|
|
748
|
+
ensureDaemonRunning,
|
|
749
|
+
noirHome,
|
|
750
|
+
openStoreForDaemon,
|
|
751
|
+
pidAlive,
|
|
752
|
+
readDaemonRecord,
|
|
753
|
+
resolveConsolidationCapability,
|
|
754
|
+
resolveMemoryConsolidation,
|
|
755
|
+
startHttpServer,
|
|
756
|
+
startStdioServer,
|
|
757
|
+
writeDaemonRecord
|
|
758
|
+
};
|
|
759
|
+
//# sourceMappingURL=index.js.map
|