@melandlabs/memory-store 1.1.5 → 1.1.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -49,6 +49,7 @@ endpoints:
49
49
  | Read a single `RawMessage` | `manager.getMessageById(id)` | `GET /v1/raw-messages/:id?userId=…` | `memory.getRawMessage` |
50
50
  | Persist a `RawMessage` | `manager.storeMessages([…])` | `POST /v1/raw-messages` | `memory.writeRawMessage` |
51
51
  | Read-only LLM synthesis over evidence | `store.search({ ...input, synthesize: true })` | `POST /v1/search` (set `synthesize: true`) | `memory.search` (set `synthesize: true`) |
52
+ | LLM reasoning layer (query-rewrite / iterative planner) | `store.search({ ...input, reasoningStrategy: "rewrite" \| "iterative" })` | `POST /v1/search` (set `reasoningStrategy`) | `memory.search` (set `reasoningStrategy`) |
52
53
  | **Agentic write-back** (gather → plan → vet → persist) | `store.consolidate(input)` | `POST /v1/consolidate:apply` | `memory.consolidate` |
53
54
  | Health probe | — | `GET /health` | `memory.health` |
54
55
 
@@ -67,6 +68,39 @@ opencontext-memory-http --port 7421 --host 127.0.0.1
67
68
  opencontext-memory-mcp
68
69
  ```
69
70
 
71
+ Both bins accept the same `--embedding-provider` / `--*-backend` flag surface
72
+ (see `--help` for the full list). Reasoning is **off by default** — opt in
73
+ with `--reasoning`, which reads `OPENCONTEXT_LLM_API_KEY` /
74
+ `OPENCONTEXT_LLM_BASE_URL` / `OPENCONTEXT_LLM_MODEL` from the environment:
75
+
76
+ ```bash
77
+ # HTTP daemon with local embeddings + sqlite-vec memory + LLM reasoning
78
+ opencontext-memory-http \
79
+ --reasoning \
80
+ --embedding-provider local \
81
+ --memory-backend sqlite-vec
82
+
83
+ # Same wiring, stdio MCP daemon (Claude Desktop / Cursor / Claude Code)
84
+ opencontext-memory-mcp \
85
+ --reasoning \
86
+ --embedding-provider local \
87
+ --memory-backend sqlite-vec
88
+ ```
89
+
90
+ Once the daemon is up, `POST /v1/search` (HTTP) and `memory.search` (MCP)
91
+ honor a `reasoningStrategy` field:
92
+
93
+ | `reasoningStrategy` | What runs |
94
+ |---|---|
95
+ | `"none"` (default) | Plain hybrid search — no LLM call. |
96
+ | `"rewrite"` | One LLM call rephrases the query into a first-person memory-check question, then the normal hybrid search runs. |
97
+ | `"iterative"` | An LLM planner searches, notes evidence, searches again — multi-hop / temporal questions benefit most. |
98
+
99
+ The response carries a `reasoning` block (`{ strategy, iterations,
100
+ evidenceCount, degraded }`) so callers can observe what ran. See
101
+ [Recipe 9](#9-http-daemon) and [Recipe 10](#10-mcp-daemon) below for the
102
+ programmatic equivalents.
103
+
70
104
  ## Configuration matrix
71
105
 
72
106
  | Key | Required | Description |
@@ -85,6 +119,10 @@ opencontext-memory-mcp
85
119
  | `unified.searchSummaries` | optional | L1/L2/L3 summary recall (used by `reflect`) |
86
120
  | `unified.peerScopeCheck` | optional | host check that gates `peerFilter` narrowing |
87
121
  | `unified.reranker` | optional | cross-encoder / learned ranker applied after merge |
122
+ | `unified.reasoning.queryRewriter` | optional, enables `"rewrite"` | turns third-person questions into first-person memory checks before embedding |
123
+ | `unified.reasoning.iterativePlanner` | optional, enables `"iterative"` | LLM-driven multi-step recall; planner calls back into `search` and `note` actions |
124
+ | `unified.reasoning.complete` | optional, enables `synthesize` | single-turn LLM callback for evidence synthesis (`reflect`) |
125
+ | `unified.reasoning.defaultStrategy` | optional | `"none" \| "rewrite" \| "iterative"` — applied when callers omit `reasoningStrategy` |
88
126
  | `graphStore` | optional | `MemoryGraphStoreWithOperationHistory`; powers `consolidate` |
89
127
  | `storage` | optional | `MemoryStorageAdapter` for `deprecateRecords` writes |
90
128
  | `logger` | optional | `console`-shaped logger; defaults to `console` |
@@ -396,16 +434,59 @@ Or run the CLI:
396
434
  MEMORY_HTTP_HOST=0.0.0.0 MEMORY_HTTP_PORT=7421 opencontext-memory-http
397
435
  ```
398
436
 
437
+ To opt into LLM reasoning from the CLI, add `--reasoning`. The bin reads
438
+ `OPENCONTEXT_LLM_API_KEY` / `OPENCONTEXT_LLM_BASE_URL` / `OPENCONTEXT_LLM_MODEL`
439
+ from the environment — your existing `.env` works as-is:
440
+
441
+ ```bash
442
+ OPENCONTEXT_LLM_API_KEY=sk-... \
443
+ opencontext-memory-http \
444
+ --reasoning \
445
+ --embedding-provider local \
446
+ --memory-backend sqlite-vec
447
+ ```
448
+
449
+ Programmatically, wire the reasoning layer via `unified.reasoning`:
450
+
451
+ ```ts
452
+ import { startHttpServer } from "@melandlabs/memory-store/http";
453
+ import {
454
+ createUserVoiceRewriter,
455
+ createIterativeRecallPlanner,
456
+ } from "@melandlabs/memory-store";
457
+
458
+ const complete = async (prompt: string) => callMyLlm(prompt); // any OpenAI-compatible chat completions endpoint
459
+
460
+ const { url, stop } = await startHttpServer({
461
+ port: 7421,
462
+ host: "127.0.0.1",
463
+ unified: {
464
+ embedQuery: myEmbedder,
465
+ reasoning: {
466
+ queryRewriter: createUserVoiceRewriter({ complete }),
467
+ iterativePlanner: createIterativeRecallPlanner({ complete }),
468
+ // defaultStrategy: "iterative", // uncomment to enable by default
469
+ },
470
+ },
471
+ });
472
+ ```
473
+
399
474
  Endpoints:
400
475
 
401
476
  | Method + path | Body |
402
477
  | -------------------------------------- | --------------------------------------------------------------------------------------------------- |
403
478
  | `GET /health` | — |
404
- | `POST /v1/search` | `{ userId, query, limit?, threshold?, sources?, botIds?, documentIds?, factTypes?, synthesize? }` |
479
+ | `POST /v1/search` | `{ userId, query, limit?, threshold?, sources?, botIds?, documentIds?, factTypes?, synthesize?, reasoningStrategy? }` |
405
480
  | `POST /v1/consolidate:apply` | `{ userId, query, ownerScope, tiers?, limit?, threshold?, dryRun?, expectedVersion?, llmPlanReview? }` |
406
481
  | `POST /v1/raw-messages` | `{ userId, messages: RawMessage[] }` |
407
482
  | `GET /v1/raw-messages/:id?userId=...` | — |
408
483
 
484
+ `reasoningStrategy` accepts `"none"` (default — no LLM call),
485
+ `"rewrite"` (single LLM rephrase), or `"iterative"` (multi-step planner).
486
+ When set and the server is not wired with the corresponding provider, the
487
+ response includes a `*_not_configured` warning and the search falls back to
488
+ the default path.
489
+
409
490
  For container / LAN deployment, put it behind a reverse proxy:
410
491
 
411
492
  ```nginx
@@ -422,15 +503,25 @@ location /memory/ {
422
503
  opencontext-memory-mcp
423
504
  ```
424
505
 
506
+ To expose LLM reasoning to MCP clients (Claude Desktop, Cursor, Claude Code):
507
+
508
+ ```bash
509
+ OPENCONTEXT_LLM_API_KEY=sk-... \
510
+ opencontext-memory-mcp \
511
+ --reasoning \
512
+ --embedding-provider local \
513
+ --memory-backend sqlite-vec
514
+ ```
515
+
425
516
  Tools exposed over stdio:
426
517
 
427
- | Tool | Required args |
428
- | ------------------------ | ----------------------------------------------------------------------------------------------------------------------------------- |
429
- | `memory.health` | — |
430
- | `memory.search` | `userId`, `query`, optional `limit`, `threshold`, `sources`, `botIds`, `documentIds`, `factTypes`, `synthesize`, `tiers` |
431
- | `memory.writeRawMessage` | `userId`, `message: { role, content, platform?, botId?, factType?, peer?, ... }` |
432
- | `memory.getRawMessage` | `userId`, `messageId` |
433
- | `memory.consolidate` | `userId`, `query`, `ownerScope`, optional `tiers`, `dryRun`, `expectedVersion`, `llmPlanReview`, `plan` |
518
+ | Tool | Required args |
519
+ | ------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
520
+ | `memory.health` | — |
521
+ | `memory.search` | `userId`, `query`, optional `limit`, `threshold`, `sources`, `botIds`, `documentIds`, `factTypes`, `synthesize`, `tiers`, **`reasoningStrategy`** (`"none" \| "rewrite" \| "iterative"`) |
522
+ | `memory.writeRawMessage` | `userId`, `message: { role, content, platform?, botId?, factType?, peer?, ... }` |
523
+ | `memory.getRawMessage` | `userId`, `messageId` |
524
+ | `memory.consolidate` | `userId`, `query`, `ownerScope`, optional `tiers`, `dryRun`, `expectedVersion`, `llmPlanReview`, `plan` |
434
525
 
435
526
  #### Claude Desktop (`claude_desktop_config.json`)
436
527
 
package/dist/http.js CHANGED
@@ -1839,6 +1839,7 @@ async function startHttpServer(options = {}) {
1839
1839
  authToken: typeof body.authToken === "string" ? body.authToken : void 0,
1840
1840
  dateFrom: typeof body.dateFrom === "string" ? body.dateFrom : void 0,
1841
1841
  dateTo: typeof body.dateTo === "string" ? body.dateTo : void 0,
1842
+ reasoningStrategy: body.reasoningStrategy === "rewrite" || body.reasoningStrategy === "iterative" ? body.reasoningStrategy : void 0,
1842
1843
  ...wantsSynthesis ? {
1843
1844
  synthesize: {
1844
1845
  ...responseSchema ? { responseSchema } : {}
package/dist/mcp.js CHANGED
@@ -1814,6 +1814,9 @@ async function startMcpServer(options = {}) {
1814
1814
  tiers: z.array(z.enum(["summary", "raw", "insight", "knowledge"])).optional().describe(
1815
1815
  "Per-tier evidence scope for synthesis. Default: ['summary','raw','insight','knowledge']. Ignored when `synthesize` is falsy."
1816
1816
  ),
1817
+ reasoningStrategy: z.enum(["none", "rewrite", "iterative"]).optional().describe(
1818
+ "LLM reasoning strategy. `rewrite` rephrases the query before embedding; `iterative` runs a planner that searches, notes evidence, and searches again. Requires `unified.reasoning.{queryRewriter,iterativePlanner}` to be wired into the server."
1819
+ ),
1817
1820
  synthesize: z.union([z.boolean(), z.object({ responseSchema: z.record(z.string(), z.unknown()).optional() })]).optional().describe(
1818
1821
  "Opt-in LLM synthesis. `true` returns `{ answer, evidence, results, warnings }`. `synthesize.responseSchema` extracts `answer` from a JSON payload."
1819
1822
  ),
@@ -1889,6 +1892,7 @@ async function startMcpServer(options = {}) {
1889
1892
  documentIds: a.documentIds,
1890
1893
  authToken: a.authToken,
1891
1894
  includeArchivedInsights: a.includeArchivedInsights,
1895
+ reasoningStrategy: a.reasoningStrategy === "rewrite" || a.reasoningStrategy === "iterative" ? a.reasoningStrategy : void 0,
1892
1896
  ...a.tiers ? { tiers: a.tiers } : {},
1893
1897
  ...wantsSynthesis ? {
1894
1898
  synthesize: {
@@ -1840,6 +1840,7 @@ async function startHttpServer(options = {}) {
1840
1840
  authToken: typeof body.authToken === "string" ? body.authToken : void 0,
1841
1841
  dateFrom: typeof body.dateFrom === "string" ? body.dateFrom : void 0,
1842
1842
  dateTo: typeof body.dateTo === "string" ? body.dateTo : void 0,
1843
+ reasoningStrategy: body.reasoningStrategy === "rewrite" || body.reasoningStrategy === "iterative" ? body.reasoningStrategy : void 0,
1843
1844
  ...wantsSynthesis ? {
1844
1845
  synthesize: {
1845
1846
  ...responseSchema ? { responseSchema } : {}
@@ -2046,6 +2047,345 @@ async function startHttpServer(options = {}) {
2046
2047
  };
2047
2048
  }
2048
2049
 
2050
+ // src/search/iterative-recall.ts
2051
+ var SYSTEM_PROMPT = `You are a research assistant collecting evidence from a user's conversation history. You are NOT answering the question; only gather the most useful memory.
2052
+
2053
+ Actions:
2054
+ search \u2014 search by concise keywords. Previously returned chunks are automatically excluded.
2055
+ Action Input: {"keywords":["keyword1","keyword2"],"date_from":"YYYY-MM-DD","date_to":"YYYY-MM-DD"}
2056
+ The date fields are optional; omit them unless an absolute date range is useful.
2057
+
2058
+ note \u2014 save relevant results from the LAST search by their 1-based result number.
2059
+ Action Input: {"indices":[1,3]}
2060
+
2061
+ finish \u2014 finish collecting evidence.
2062
+ Action Input: {}
2063
+
2064
+ Workflow: search -> note -> search again -> note -> finish.
2065
+ Review every result. Call note before another search because the prior result list is replaced. Use diverse keywords on later searches. Never answer the question.
2066
+
2067
+ Output exactly:
2068
+ Thought: one short sentence
2069
+ Action: one action name
2070
+ Action Input: valid JSON object`;
2071
+ function deriveKeywordsFromQuery(query) {
2072
+ return query.toLowerCase().split(/[^\p{L}\p{N}]+/u).filter((token) => token.length >= 2);
2073
+ }
2074
+ function formatDateHint(dateFrom, dateTo) {
2075
+ if (!dateFrom && !dateTo) {
2076
+ return void 0;
2077
+ }
2078
+ const fromText = dateFrom ?? "the beginning";
2079
+ const toText = dateTo ?? "now";
2080
+ return `Restrict searches to the date range ${fromText} to ${toText}. You may still emit narrower date_from/date_to bounds when useful.`;
2081
+ }
2082
+ function buildInitialPrompt(query, dateHint) {
2083
+ const hintText = dateHint ? `
2084
+ ${dateHint}` : "";
2085
+ return `${SYSTEM_PROMPT}
2086
+
2087
+ Question to research: ${query}${hintText}
2088
+ Search the conversation history and collect all relevant evidence. Start with a broad keyword search.`;
2089
+ }
2090
+ function buildObservationPrompt(hits) {
2091
+ if (hits.length === 0) {
2092
+ return "Observation: No matching memory found.\nRespond with the next action only.";
2093
+ }
2094
+ const blocks = hits.map((hit, index) => {
2095
+ const lines = [`Result ${index + 1} | score=${hit.similarity.toFixed(4)}`];
2096
+ for (const line of hit.content.split("\n")) {
2097
+ if (line.trim()) lines.push(line);
2098
+ }
2099
+ return lines.join("\n");
2100
+ });
2101
+ return `Observation:
2102
+ ${blocks.join("\n\n")}
2103
+
2104
+ Respond with the next action only.`;
2105
+ }
2106
+ function stripCodeFences(text) {
2107
+ return text.replace(/```(?:json)?\s*([\s\S]*?)\s*```/g, "$1");
2108
+ }
2109
+ function extractJsonObject(text) {
2110
+ const start = text.indexOf("{");
2111
+ if (start === -1) {
2112
+ return void 0;
2113
+ }
2114
+ let depth = 0;
2115
+ let inString = false;
2116
+ let escaped = false;
2117
+ for (let i = start; i < text.length; i += 1) {
2118
+ const ch = text[i];
2119
+ if (escaped) {
2120
+ escaped = false;
2121
+ continue;
2122
+ }
2123
+ if (inString) {
2124
+ if (ch === "\\") {
2125
+ escaped = true;
2126
+ } else if (ch === '"') {
2127
+ inString = false;
2128
+ }
2129
+ continue;
2130
+ }
2131
+ if (ch === '"') {
2132
+ inString = true;
2133
+ } else if (ch === "{") {
2134
+ depth += 1;
2135
+ } else if (ch === "}") {
2136
+ depth -= 1;
2137
+ if (depth === 0) {
2138
+ return text.slice(start, i + 1);
2139
+ }
2140
+ }
2141
+ }
2142
+ return void 0;
2143
+ }
2144
+ function parseAction(text) {
2145
+ const cleaned = stripCodeFences(text);
2146
+ const actionMatch = cleaned.match(/Action:\s*([\w_]+)/);
2147
+ if (!actionMatch) return null;
2148
+ const action = actionMatch[1].trim().toLowerCase();
2149
+ if (action === "finish") {
2150
+ return { type: "finish" };
2151
+ }
2152
+ const inputMatch = cleaned.match(/Action Input:\s*/s);
2153
+ if (!inputMatch || inputMatch.index === void 0) return null;
2154
+ const jsonText = extractJsonObject(cleaned.slice(inputMatch.index + inputMatch[0].length));
2155
+ if (!jsonText) return null;
2156
+ let parsedJson = null;
2157
+ for (const candidate of [jsonText, jsonText.replace(/'/g, '"')]) {
2158
+ try {
2159
+ parsedJson = JSON.parse(candidate);
2160
+ break;
2161
+ } catch {
2162
+ }
2163
+ }
2164
+ if (!parsedJson) return null;
2165
+ if (action === "search") {
2166
+ const keywords = parsedJson.keywords;
2167
+ if (!Array.isArray(keywords) || !keywords.every((k) => typeof k === "string")) {
2168
+ return null;
2169
+ }
2170
+ return {
2171
+ type: "search",
2172
+ keywords,
2173
+ dateFrom: typeof parsedJson.date_from === "string" ? parsedJson.date_from : void 0,
2174
+ dateTo: typeof parsedJson.date_to === "string" ? parsedJson.date_to : void 0
2175
+ };
2176
+ }
2177
+ if (action === "note") {
2178
+ const indices = parsedJson.indices;
2179
+ if (!Array.isArray(indices) || !indices.every((i) => typeof i === "number")) {
2180
+ return null;
2181
+ }
2182
+ return { type: "note", indices };
2183
+ }
2184
+ return null;
2185
+ }
2186
+ function createIterativeRecallPlanner(deps) {
2187
+ const { complete, options = {} } = deps;
2188
+ const defaults = {
2189
+ maxIterations: 4,
2190
+ searchTopK: 5,
2191
+ disabled: false,
2192
+ fallbackToBaseline: true
2193
+ };
2194
+ let lastDegraded = false;
2195
+ let iterationsWithValidAction = 0;
2196
+ return {
2197
+ lastDegraded: () => lastDegraded,
2198
+ async plan(input) {
2199
+ const opts = {
2200
+ ...defaults,
2201
+ ...options,
2202
+ ...input.options
2203
+ };
2204
+ lastDegraded = false;
2205
+ iterationsWithValidAction = 0;
2206
+ if (opts.disabled || !input.query.trim()) {
2207
+ return { evidence: [], stats: { iterations: 0, searches: 0, notes: 0 } };
2208
+ }
2209
+ const messages = [
2210
+ { role: "system", content: SYSTEM_PROMPT },
2211
+ {
2212
+ role: "user",
2213
+ content: buildInitialPrompt(input.query, formatDateHint(input.dateFrom, input.dateTo))
2214
+ }
2215
+ ];
2216
+ const evidence = /* @__PURE__ */ new Map();
2217
+ const excludedIds = /* @__PURE__ */ new Set();
2218
+ let lastHits = [];
2219
+ let searches = 0;
2220
+ let notes = 0;
2221
+ let iterations = 0;
2222
+ let completeThrew = false;
2223
+ for (let i = 0; i < opts.maxIterations; i += 1) {
2224
+ iterations = i + 1;
2225
+ let reply;
2226
+ try {
2227
+ reply = await complete(messages.map((m) => `${m.role}: ${m.content}`).join("\n\n"));
2228
+ } catch {
2229
+ completeThrew = true;
2230
+ break;
2231
+ }
2232
+ messages.push({ role: "assistant", content: reply });
2233
+ const action = parseAction(reply);
2234
+ if (!action) {
2235
+ messages.push({
2236
+ role: "user",
2237
+ content: "Observation: Use exactly one valid action: search, note, or finish. Respond with the next action only."
2238
+ });
2239
+ continue;
2240
+ }
2241
+ iterationsWithValidAction += 1;
2242
+ if (action.type === "finish") {
2243
+ break;
2244
+ }
2245
+ if (action.type === "search") {
2246
+ const result = await input.executor.search({
2247
+ keywords: action.keywords,
2248
+ dateFrom: action.dateFrom ?? input.dateFrom,
2249
+ dateTo: action.dateTo ?? input.dateTo
2250
+ });
2251
+ lastHits = result.candidates.filter((c) => !excludedIds.has(c.id)).slice(0, opts.searchTopK);
2252
+ for (const hit of lastHits) {
2253
+ excludedIds.add(hit.id);
2254
+ }
2255
+ searches += 1;
2256
+ messages.push({ role: "user", content: buildObservationPrompt(lastHits) });
2257
+ continue;
2258
+ }
2259
+ if (action.type === "note") {
2260
+ let saved = 0;
2261
+ for (const index of action.indices) {
2262
+ if (index >= 1 && index <= lastHits.length) {
2263
+ const hit = lastHits[index - 1];
2264
+ if (!evidence.has(hit.id)) {
2265
+ evidence.set(hit.id, hit);
2266
+ saved += 1;
2267
+ }
2268
+ }
2269
+ }
2270
+ notes += saved;
2271
+ messages.push({
2272
+ role: "user",
2273
+ content: `Observation: Saved ${saved} new result(s). Total notes: ${evidence.size}.
2274
+ Respond with the next action only.`
2275
+ });
2276
+ }
2277
+ }
2278
+ let fallbackRan = false;
2279
+ if (evidence.size === 0 && opts.fallbackToBaseline) {
2280
+ fallbackRan = true;
2281
+ if (lastHits.length > 0) {
2282
+ for (const hit of lastHits) {
2283
+ if (!evidence.has(hit.id)) {
2284
+ evidence.set(hit.id, hit);
2285
+ }
2286
+ }
2287
+ } else {
2288
+ const baseline = await input.executor.search({
2289
+ keywords: deriveKeywordsFromQuery(input.query),
2290
+ dateFrom: input.dateFrom,
2291
+ dateTo: input.dateTo
2292
+ });
2293
+ searches += 1;
2294
+ for (const hit of baseline.candidates.slice(0, opts.searchTopK)) {
2295
+ if (!evidence.has(hit.id)) {
2296
+ evidence.set(hit.id, hit);
2297
+ }
2298
+ }
2299
+ }
2300
+ }
2301
+ lastDegraded = completeThrew || iterationsWithValidAction === 0 || fallbackRan;
2302
+ return {
2303
+ evidence: Array.from(evidence.values()),
2304
+ stats: { iterations, searches, notes }
2305
+ };
2306
+ }
2307
+ };
2308
+ }
2309
+
2310
+ // src/search/query-rewriter.ts
2311
+ var SYSTEM_PROMPT2 = `You are a memory retrieval assistant.
2312
+
2313
+ The memory store contains a user's past messages, written in the first person (e.g. "I told you about my sister's wedding", "I prefer dark mode").
2314
+
2315
+ Your job is NOT to answer the question. Your job is to rewrite the assistant's question into a short, natural question that the USER would ask their own memory log to find the relevant entry.
2316
+
2317
+ Rules:
2318
+ - Use first person: "Did I tell you about ...?", "What did I say about ...?", "Have I mentioned ...?"
2319
+ - Do NOT address the user as "you".
2320
+ - Do NOT answer the question.
2321
+ - Output each rephrasing on its own line, prefixed with "- ".
2322
+ - Keep each rephrasing under 20 words.
2323
+ - Output exactly the requested number of lines.`;
2324
+ function buildPrompt(query, numVariants) {
2325
+ const label = numVariants === 1 ? "1 alternative rephrasing" : `${numVariants} alternative rephrasings`;
2326
+ return `${SYSTEM_PROMPT2}
2327
+
2328
+ Output exactly ${label}.
2329
+
2330
+ Example:
2331
+ Question: What is the name of my cat?
2332
+ - Did I tell you the name of my cat?
2333
+
2334
+ Question: ${query}
2335
+ Output:`;
2336
+ }
2337
+ function sanitizeLine(text) {
2338
+ return text.replace(/^\s*["'`]+|["'`]+\s*$/g, "").replace(/^Rewritten:\s*/i, "").trim();
2339
+ }
2340
+ function parseVariants(raw, maxVariants, original) {
2341
+ const lines = raw.split(/\r?\n/).map((line) => sanitizeLine(line.replace(/^\s*-\s+/, ""))).filter((line) => line.length > 0);
2342
+ if (lines.length === 0) return [];
2343
+ const seen = /* @__PURE__ */ new Set([original.toLowerCase()]);
2344
+ const variants = [];
2345
+ for (const line of lines) {
2346
+ if (variants.length >= maxVariants) break;
2347
+ const key = line.toLowerCase();
2348
+ if (seen.has(key)) continue;
2349
+ seen.add(key);
2350
+ variants.push(line);
2351
+ }
2352
+ return variants;
2353
+ }
2354
+ function createUserVoiceRewriter(options) {
2355
+ const { complete, maxVariants = 1, disabled = false } = options;
2356
+ const wantedVariants = Math.max(0, Math.floor(maxVariants));
2357
+ let lastDegraded = false;
2358
+ return {
2359
+ async rewrite(input) {
2360
+ if (disabled || !input.query || !input.query.trim()) {
2361
+ lastDegraded = false;
2362
+ return [input.query];
2363
+ }
2364
+ const original = input.query.trim();
2365
+ if (wantedVariants === 0) {
2366
+ lastDegraded = false;
2367
+ return [original];
2368
+ }
2369
+ try {
2370
+ const raw = await complete(buildPrompt(original, wantedVariants));
2371
+ const variants = parseVariants(raw, wantedVariants, original);
2372
+ if (variants.length === 0) {
2373
+ lastDegraded = true;
2374
+ return [original];
2375
+ }
2376
+ lastDegraded = false;
2377
+ return [original, ...variants];
2378
+ } catch {
2379
+ lastDegraded = true;
2380
+ return [original];
2381
+ }
2382
+ },
2383
+ lastDegraded() {
2384
+ return lastDegraded;
2385
+ }
2386
+ };
2387
+ }
2388
+
2049
2389
  // src/server/cli-shared.ts
2050
2390
  init_raw_message_store();
2051
2391
 
@@ -2108,7 +2448,11 @@ function parseUnifiedArgs(argv) {
2108
2448
  insightsBackend: env.INSIGHTS_BACKEND ?? "none",
2109
2449
  insightsCollection: env.INSIGHTS_COLLECTION ?? "opencontext_insights",
2110
2450
  knowledgeBackend: env.KNOWLEDGE_BACKEND ?? "none",
2111
- knowledgeCollection: env.KNOWLEDGE_COLLECTION ?? "opencontext_knowledge"
2451
+ knowledgeCollection: env.KNOWLEDGE_COLLECTION ?? "opencontext_knowledge",
2452
+ reasoning: env.REASONING === "1" || env.REASONING === "true",
2453
+ reasoningBaseUrl: env.OPENCONTEXT_LLM_BASE_URL,
2454
+ reasoningModel: env.OPENCONTEXT_LLM_MODEL,
2455
+ reasoningTimeoutMs: env.OPENCONTEXT_LLM_TIMEOUT_MS ? Number.parseInt(env.OPENCONTEXT_LLM_TIMEOUT_MS, 10) : void 0
2112
2456
  };
2113
2457
  for (let i = 0; i < argv.length; i += 1) {
2114
2458
  const arg = argv[i];
@@ -2143,6 +2487,21 @@ function parseUnifiedArgs(argv) {
2143
2487
  case "--knowledge-collection":
2144
2488
  args.knowledgeCollection = takeValue();
2145
2489
  break;
2490
+ case "--reasoning":
2491
+ args.reasoning = true;
2492
+ break;
2493
+ case "--no-reasoning":
2494
+ args.reasoning = false;
2495
+ break;
2496
+ case "--reasoning-base-url":
2497
+ args.reasoningBaseUrl = takeValue();
2498
+ break;
2499
+ case "--reasoning-model":
2500
+ args.reasoningModel = takeValue();
2501
+ break;
2502
+ case "--reasoning-timeout-ms":
2503
+ args.reasoningTimeoutMs = Number.parseInt(takeValue(), 10);
2504
+ break;
2146
2505
  }
2147
2506
  }
2148
2507
  for (const [name, value] of [
@@ -2316,6 +2675,52 @@ async function buildUnified(args) {
2316
2675
  };
2317
2676
  log(`knowledge backend wired via chroma (collection=${args.knowledgeCollection})`);
2318
2677
  }
2678
+ if (args.reasoning) {
2679
+ const apiKey = process.env.OPENCONTEXT_LLM_API_KEY;
2680
+ if (!apiKey) {
2681
+ throw new Error(
2682
+ "--reasoning requires OPENCONTEXT_LLM_API_KEY (and ideally OPENCONTEXT_LLM_BASE_URL / OPENCONTEXT_LLM_MODEL) in the environment"
2683
+ );
2684
+ }
2685
+ const baseUrl = args.reasoningBaseUrl ?? process.env.OPENCONTEXT_LLM_BASE_URL ?? "https://openrouter.ai/api/v1";
2686
+ const model = args.reasoningModel ?? process.env.OPENCONTEXT_LLM_MODEL ?? "openai/gpt-4o-mini";
2687
+ const timeoutMs = args.reasoningTimeoutMs ?? 3e4;
2688
+ const complete = async (prompt) => {
2689
+ const controller = new AbortController();
2690
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
2691
+ timer.unref?.();
2692
+ try {
2693
+ const res = await fetch(`${baseUrl}/chat/completions`, {
2694
+ method: "POST",
2695
+ headers: {
2696
+ "content-type": "application/json",
2697
+ authorization: `Bearer ${apiKey}`,
2698
+ "HTTP-Referer": process.env.NEXT_PUBLIC_APP_URL ?? "https://opencontext.ai",
2699
+ "X-Title": "opencontext AI"
2700
+ },
2701
+ body: JSON.stringify({
2702
+ model,
2703
+ messages: [{ role: "user", content: prompt }],
2704
+ temperature: 0
2705
+ }),
2706
+ signal: controller.signal
2707
+ });
2708
+ if (!res.ok) throw new Error(`reasoning LLM ${res.status}: ${await res.text()}`);
2709
+ const body = await res.json();
2710
+ const text = body.choices?.[0]?.message?.content?.trim();
2711
+ if (!text) throw new Error("reasoning LLM response missing choices[0].message.content");
2712
+ return text;
2713
+ } finally {
2714
+ clearTimeout(timer);
2715
+ }
2716
+ };
2717
+ const queryRewriter = createUserVoiceRewriter({ complete });
2718
+ const iterativePlanner = createIterativeRecallPlanner({ complete });
2719
+ if (!unified.reasoning) unified.reasoning = {};
2720
+ unified.reasoning.queryRewriter = queryRewriter;
2721
+ unified.reasoning.iterativePlanner = iterativePlanner;
2722
+ log(`reasoning wired (model=${model}, baseUrl=${baseUrl})`);
2723
+ }
2319
2724
  return unified;
2320
2725
  }
2321
2726
  function printUnifiedHelp() {
@@ -2338,7 +2743,26 @@ Cross-source search (wires unified.searchKnowledge / searchInsights / searchRawM
2338
2743
  --knowledge-backend <name> chroma | none
2339
2744
  (env: KNOWLEDGE_BACKEND, default: none)
2340
2745
  --knowledge-collection <name> Chroma collection (default: opencontext_knowledge)
2341
- Note: the chroma backends dynamically import @melandlabs/ai-rag.`);
2746
+ Note: the chroma backends dynamically import @melandlabs/ai-rag.
2747
+
2748
+ Reasoning (wires unified.reasoning.{queryRewriter, iterativePlanner}):
2749
+ --reasoning Enable the LLM reasoning layer so /v1/search and
2750
+ memory.search can honor reasoningStrategy:
2751
+ "rewrite" \u2014 first-person memory-check rephrase
2752
+ "iterative" \u2014 planner that searches, notes evidence,
2753
+ searches again
2754
+ (env: REASONING=1, default: off)
2755
+ --no-reasoning Explicitly disable even if REASONING=1 is set.
2756
+ --reasoning-base-url <url> OpenAI-compatible base URL for the reasoning LLM.
2757
+ (env: OPENCONTEXT_LLM_BASE_URL, default: https://openrouter.ai/api/v1)
2758
+ --reasoning-model <name> Reasoning LLM model identifier.
2759
+ (env: OPENCONTEXT_LLM_MODEL, default: openai/gpt-4o-mini)
2760
+ --reasoning-timeout-ms <int> Per-request timeout (default: 30000).
2761
+
2762
+ Required env when --reasoning is set:
2763
+ OPENCONTEXT_LLM_API_KEY Bearer token (no default)
2764
+ OPENCONTEXT_LLM_BASE_URL (optional) overrides --reasoning-base-url
2765
+ OPENCONTEXT_LLM_MODEL (optional) overrides --reasoning-model`);
2342
2766
  }
2343
2767
 
2344
2768
  // src/server/cli-http.ts
@@ -2399,6 +2823,15 @@ Examples:
2399
2823
  --embedding-provider local \\
2400
2824
  --memory-backend sqlite-vec
2401
2825
 
2826
+ # Same as above + LLM reasoning (query-rewriter + iterative planner).
2827
+ # Reads OPENCONTEXT_LLM_API_KEY / OPENCONTEXT_LLM_BASE_URL /
2828
+ # OPENCONTEXT_LLM_MODEL from the environment so .env Just Works.
2829
+ # After this, POST /v1/search honors body.reasoningStrategy: rewrite|iterative.
2830
+ opencontext-memory-http \\
2831
+ --embedding-provider local \\
2832
+ --memory-backend sqlite-vec \\
2833
+ --reasoning
2834
+
2402
2835
  # Wire everything via a running Chroma server
2403
2836
  opencontext-memory-http \\
2404
2837
  --embedding-provider openrouter \\