memoryrouter-codex 1.0.0 → 1.2.0
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 +30 -13
- package/RESEARCH.md +6 -2
- package/bin/cli.cjs +12 -1
- package/lib/hooks.cjs +0 -1
- package/lib/installer.cjs +3 -2
- package/package.json +1 -1
- package/runtime/api.cjs +85 -2
- package/runtime/config.cjs +0 -5
- package/runtime/constants.cjs +1 -5
- package/runtime/hook.cjs +42 -65
- package/runtime/mcp-stdio.cjs +127 -5
- package/runtime/memory.cjs +14 -4
- package/runtime/reflect.cjs +250 -0
package/README.md
CHANGED
|
@@ -2,15 +2,17 @@
|
|
|
2
2
|
|
|
3
3
|
Native Codex integration for durable MemoryRouter memory. It uses Codex's current MCP, lifecycle hooks, and `AGENTS.md` surfaces rather than pretending that model-selected MCP calls alone are automatic memory.
|
|
4
4
|
|
|
5
|
+
> **Release status:** `memoryrouter-codex@1.1.0` is the published npm release. Its automatic capture is message-only: one user request plus the final assistant response, with no tool payloads.
|
|
6
|
+
|
|
5
7
|
## What this integration does
|
|
6
8
|
|
|
7
9
|
| Layer | Behavior |
|
|
8
10
|
| --- | --- |
|
|
9
11
|
| `UserPromptSubmit` hook | Searches MemoryRouter before every prompt and injects bounded, project-scoped context as developer context. |
|
|
10
|
-
| `
|
|
12
|
+
| `Stop` hook | Captures only the user request and final assistant response after each completed turn. Tool names, inputs, and results are excluded. |
|
|
11
13
|
| `SubagentStop` hook | Captures the stable final outcome of completed subagents without parsing their unstable transcript files. |
|
|
12
|
-
| `PreCompact` / compact `SessionStart` | Checkpoints
|
|
13
|
-
| `SessionEnd` | Best-effort final checkpoint within Codex's three-second SessionEnd budget. |
|
|
14
|
+
| `PreCompact` / compact `SessionStart` | Checkpoints only the current user request and final assistant response when present, then recalls context before Codex continues after compaction. |
|
|
15
|
+
| `SessionEnd` | Best-effort message-only final checkpoint within Codex's three-second SessionEnd budget. |
|
|
14
16
|
| Remote MCP | OAuth-based explicit search/store through `https://mcp.memoryrouter.ai/mcp`. |
|
|
15
17
|
| Local MCP fallback | API-key-authenticated stdio tools with strict project filtering, handles, local suppression, status, and full-vault clear confirmation. |
|
|
16
18
|
| `AGENTS.md` | Tells Codex when to recall/store and how MemoryRouter coexists with native Memories. |
|
|
@@ -37,7 +39,7 @@ Obtain a MemoryRouter memory key from your account. Pass it on stdin so it does
|
|
|
37
39
|
|
|
38
40
|
```bash
|
|
39
41
|
printf '%s' "$MEMORYROUTER_API_KEY" | \
|
|
40
|
-
npx -y memoryrouter-codex@1.
|
|
42
|
+
npx -y memoryrouter-codex@1.1.0 init --scope user --auth oauth --api-key-stdin
|
|
41
43
|
|
|
42
44
|
codex mcp login memoryrouter
|
|
43
45
|
memoryrouter-codex doctor --scope user
|
|
@@ -59,7 +61,7 @@ If you prefer not to persist the key, omit `--api-key-stdin` and ensure `MEMORYR
|
|
|
59
61
|
```bash
|
|
60
62
|
cd /path/to/repository
|
|
61
63
|
printf '%s' "$MEMORYROUTER_API_KEY" | \
|
|
62
|
-
npx -y memoryrouter-codex@1.
|
|
64
|
+
npx -y memoryrouter-codex@1.1.0 init --scope project --auth oauth --api-key-stdin
|
|
63
65
|
codex mcp login memoryrouter
|
|
64
66
|
```
|
|
65
67
|
|
|
@@ -69,7 +71,7 @@ This writes managed blocks/files under the repository's `AGENTS.md` and `.codex/
|
|
|
69
71
|
|
|
70
72
|
```bash
|
|
71
73
|
printf '%s' "$MEMORYROUTER_API_KEY" | \
|
|
72
|
-
npx -y memoryrouter-codex@1.
|
|
74
|
+
npx -y memoryrouter-codex@1.1.0 init --scope user --auth api-key --api-key-stdin
|
|
73
75
|
```
|
|
74
76
|
|
|
75
77
|
This uses a local stdio MCP server and the MemoryRouter API. It does not put the memory key in Codex config. The fallback tools are:
|
|
@@ -126,6 +128,10 @@ Automatic memories use an envelope containing project handle, stable memory hand
|
|
|
126
128
|
|
|
127
129
|
Set `retrievalScope` to `"vault"` in the private config only if you intentionally want unscoped automatic recall.
|
|
128
130
|
|
|
131
|
+
## Historical import boundary
|
|
132
|
+
|
|
133
|
+
The Codex package captures future work after installation. It does not ship a deterministic importer for ChatGPT exports or prior Codex conversations. A local Codex workflow can inspect an extracted export and curate selected facts, but that is experimental guided curation, not checkpointed/resumable/reconciled archive ingestion. Remote MCP OAuth connection by itself never imports history.
|
|
134
|
+
|
|
129
135
|
## Handles, deduplication, retries, and forgetting
|
|
130
136
|
|
|
131
137
|
- Handles (`mrh_...`) are content-derived from project, kind, and normalized content.
|
|
@@ -136,13 +142,24 @@ Set `retrievalScope` to `"vault"` in the private config only if you intentionall
|
|
|
136
142
|
- Whole-vault deletion is available only when `memory_forget` receives the exact phrase `CLEAR MY ENTIRE MEMORYROUTER VAULT`. It is irreversible and should require Codex approval.
|
|
137
143
|
- Use the MemoryRouter dashboard for server-side audit/management where available.
|
|
138
144
|
|
|
145
|
+
## Reflections and consolidation
|
|
146
|
+
|
|
147
|
+
MemoryRouter's Reflection Hierarchy consolidates raw memories into higher-tier reflections. This package exposes it in two lanes:
|
|
148
|
+
|
|
149
|
+
- **CLI:** `memoryrouter-codex reflect [--tier 1|2] [--batches N|all] [--max-tokens N] [--dry-run]`. The loop checks out a batch of unconsolidated memories under a 15 minute server lease, runs Codex itself headlessly (`codex exec`, you pay your own inference) with server-authored instructions, and commits the resulting reflection entries. It keeps no local state: if anything dies mid-loop, the lease expires server-side and the memories return to the pool, so re-running always resumes from the oldest unconsolidated memory. `--dry-run` prints the checkout and prompt and deliberately lets the lease expire (this bills one checkout).
|
|
150
|
+
- **Local stdio MCP:** `memory_consolidate` (checkout, returns memory texts plus the server's reflection instructions) and `memory_commit_reflections` (submit entries for the batch, idempotent on replay). The model never sees memory row IDs; it works with bare texts and one opaque `cb_` batch id.
|
|
151
|
+
|
|
152
|
+
`memory_search` accepts optional `tiers` (subset of `[1, 2, 3]`: raw, reflections, high-level reflections) and `importance` (integer 1 to 10 minimum threshold; only reflections carry ratings). `memory_status` reports the current reflection debt. When the server reports that consolidation is available, hook-injected context appends a one-line hint at most once per session per 24 hours.
|
|
153
|
+
|
|
154
|
+
Reflection prompts are always server-authored. This package never writes its own consolidation instructions and refuses to reflect if the server response carries none. See https://docs.memoryrouter.ai/reflections for endpoint semantics, lease rules, and billing.
|
|
155
|
+
|
|
139
156
|
## Context budget and failure behavior
|
|
140
157
|
|
|
141
158
|
- Default injected context cap: 12,000 characters, assembled at memory boundaries.
|
|
142
159
|
- Hook `additionalContextLimit`: approximately 3,500 tokens, so Codex can spill unexpectedly large context safely instead of consuming the whole model window.
|
|
143
160
|
- Candidate search default: 12 before strict project filtering.
|
|
144
161
|
- All hook entrypoints exit zero on missing credentials, malformed input, API errors, and timeouts. Memory never blocks Codex work.
|
|
145
|
-
- Hook status is recorded without secrets in `~/.memoryrouter/codex/health.json`. Search/store/forget outcomes (metadata only, never memory content or keys) append to a rotating 5 MiB local `~/.memoryrouter/codex/audit.jsonl`. `memory_status` exposes both locations. This is a host-local integration audit, not a server-wide team access log. Session
|
|
162
|
+
- Hook status is recorded without secrets in `~/.memoryrouter/codex/health.json`. Search/store/forget outcomes (metadata only, never memory content or keys) append to a rotating 5 MiB local `~/.memoryrouter/codex/audit.jsonl`. `memory_status` exposes both locations. This is a host-local integration audit, not a server-wide team access log. Session state and any legacy pre-1.1 trace files older than 30 days are pruned at session start. Version 1.1 creates no tool trace files.
|
|
146
163
|
- Set `MEMORYROUTER_DEBUG=1` for hook errors on stderr. Debug is off by default.
|
|
147
164
|
|
|
148
165
|
## Codex native Memories coexistence
|
|
@@ -172,7 +189,7 @@ Live verification with official `@openai/codex` **0.146.0** established:
|
|
|
172
189
|
5. Codex generates an authorization URL with PKCE S256, `memories:read memories:write`, the exact RFC 8707 resource, and a loopback callback.
|
|
173
190
|
6. The browser consent/token step was deliberately not completed by the package test because it is account-specific. Therefore `doctor` verifies discovery compatibility but never reports an OAuth session as logged in without user consent. Use `codex mcp login memoryrouter` and `/mcp verbose` to complete/verify it.
|
|
174
191
|
|
|
175
|
-
The
|
|
192
|
+
The production server supports modern stateless MCP `2026-07-28` and still accepts `2025-11-25`, `2025-06-18`, and `2025-03-26` for handshake-era clients. Codex 0.146 initializes the compatibility path successfully. Do not infer Codex support from protocol negotiation alone; verify OAuth login, `/mcp verbose`, and the actual tool inventory.
|
|
176
193
|
|
|
177
194
|
## Doctor
|
|
178
195
|
|
|
@@ -189,15 +206,15 @@ Doctor checks Codex version, managed config, runtime version, hook count, `AGENT
|
|
|
189
206
|
Upgrade idempotently by re-running the pinned/new package:
|
|
190
207
|
|
|
191
208
|
```bash
|
|
192
|
-
npx -y memoryrouter-codex@1.
|
|
209
|
+
npx -y memoryrouter-codex@1.1.0 init --scope user
|
|
193
210
|
```
|
|
194
211
|
|
|
195
212
|
Uninstall:
|
|
196
213
|
|
|
197
214
|
```bash
|
|
198
|
-
npx -y memoryrouter-codex@1.
|
|
215
|
+
npx -y memoryrouter-codex@1.1.0 uninstall --scope user
|
|
199
216
|
# or
|
|
200
|
-
npx -y memoryrouter-codex@1.
|
|
217
|
+
npx -y memoryrouter-codex@1.1.0 uninstall --scope project --project-dir /repo
|
|
201
218
|
```
|
|
202
219
|
|
|
203
220
|
Uninstall deliberately retains `~/.memoryrouter/codex/` because another project installation may use the credentials and local suppression state. Delete that private directory manually only when no integration uses it.
|
|
@@ -205,8 +222,8 @@ Uninstall deliberately retains `~/.memoryrouter/codex/` because another project
|
|
|
205
222
|
## Honest limitations
|
|
206
223
|
|
|
207
224
|
- Hooks are the deterministic automatic path. Remote MCP alone remains model-invoked, not guaranteed every turn.
|
|
208
|
-
- The transcript file path supplied to hooks is documented as unstable, so this integration does **not** parse Codex transcript JSONL.
|
|
209
|
-
-
|
|
225
|
+
- The transcript file path supplied to hooks is documented as unstable, so this integration does **not** parse Codex transcript JSONL. Version 1.1 captures only the stable user prompt and `last_assistant_message`.
|
|
226
|
+
- Tool-call records are intentionally excluded. MemoryRouter does not store tool names, tool IDs, tool inputs, tool results, commands, patches, or other `PostToolUse` payloads from automatic Codex capture. Important outcomes must appear in the final assistant response to be retained.
|
|
210
227
|
- Some specialized tool paths can opt out of hooks. Codex documents hooks as a useful extension surface, not a complete enforcement boundary.
|
|
211
228
|
- SessionEnd can happen after a 30-minute idle period and has a three-second maximum. It is a best-effort checkpoint; normal `Stop` capture is the primary write path.
|
|
212
229
|
- Project scoping is strict for this package's automatic retrieval/local MCP. The existing hosted remote MCP server has no first-class project argument, so use local tools/hooks when cross-project isolation is required.
|
package/RESEARCH.md
CHANGED
|
@@ -17,7 +17,7 @@ Verified 2026-07-31. This is engineering evidence, not public marketing copy.
|
|
|
17
17
|
## Platform findings used in the architecture
|
|
18
18
|
|
|
19
19
|
- Current Codex supports command hooks for `SessionStart`, `SessionEnd`, `PreToolUse`, `PostToolUse`, `PreCompact`, `PostCompact`, `UserPromptSubmit`, `SubagentStart`, `SubagentStop`, and `Stop`.
|
|
20
|
-
- Hook transcript paths are explicitly documented as unstable.
|
|
20
|
+
- Hook transcript paths are explicitly documented as unstable. Version 1.0 used stable event fields plus an append-only local `PostToolUse` trace. Version 1.1 removes that trace and its hook entirely: completed-turn storage is composed only from the `UserPromptSubmit` prompt and Stop's stable `last_assistant_message`.
|
|
21
21
|
- Hooks are enabled by default and project hooks require trust. `features.codex_hooks` is only a deprecated alias; the current canonical key is `features.hooks`.
|
|
22
22
|
- Remote MCP OAuth no longer needs `experimental_use_rmcp_client` in current Codex. That key is absent from the current configuration reference.
|
|
23
23
|
- The local CLI, desktop Codex host, and IDE extension share Codex config. ChatGPT web uses hosted plugin tools and does not inherit local config.
|
|
@@ -54,4 +54,8 @@ npm run typecheck
|
|
|
54
54
|
npm pack --dry-run
|
|
55
55
|
```
|
|
56
56
|
|
|
57
|
-
The integration suite covers config migration/backup, foreign-config preservation, corrupt config fail-closed behavior, idempotent upgrades, uninstall, project/worktree identity, project-vs-vault filtering, native-memory config coexistence, OAuth/local fallback configuration, read retries, write deduplication, hook fail-open behavior,
|
|
57
|
+
The integration suite covers config migration/backup, foreign-config preservation, corrupt config fail-closed behavior, idempotent upgrades, uninstall, project/worktree identity, project-vs-vault filtering, native-memory config coexistence, OAuth/local fallback configuration, read retries, write deduplication, hook fail-open behavior, prompt/final-only turn and checkpoint capture, explicit exclusion of tool payloads, context budgets, local suppression, and MCP tool annotations.
|
|
58
|
+
|
|
59
|
+
## Version 1.1 capture decision
|
|
60
|
+
|
|
61
|
+
`PostToolUse` is not registered by the installer in 1.1. The runtime no longer defines or reads trace files, and health records no longer count tool calls. Normal Stop capture requires `last_assistant_message`; PreCompact and SessionEnd may preserve the cached user prompt when no final response is available. `SubagentStop` remains separate and stores only its stable final outcome plus non-payload agent identifiers.
|
package/bin/cli.cjs
CHANGED
|
@@ -23,7 +23,7 @@ function parseArgs(argv) {
|
|
|
23
23
|
}
|
|
24
24
|
|
|
25
25
|
function usage() {
|
|
26
|
-
return `memoryrouter-codex ${INTEGRATION_VERSION}\n\nUsage:\n memoryrouter-codex init [--scope user|project] [--auth oauth|api-key]\n [--project-dir PATH] [--api-key-stdin]\n [--no-hooks] [--no-agents]\n memoryrouter-codex doctor [--scope user|project] [--project-dir PATH] [--offline]\n memoryrouter-codex uninstall [--scope user|project] [--project-dir PATH]\n memoryrouter-codex mcp-server\n\nAuth modes:\n oauth Current remote MCP at ${'https://mcp.memoryrouter.ai/mcp'} (default).\n Run \`codex mcp login memoryrouter\` after init. Hooks additionally\n require MEMORYROUTER_API_KEY or a key saved with --api-key-stdin.\n api-key Local stdio MCP fallback plus automatic hooks. The key is read only\n from the environment/private config, never from project files.\n\nSecure key setup:\n printf '%s' "$MEMORYROUTER_API_KEY" | memoryrouter-codex init --api-key-stdin\n`;
|
|
26
|
+
return `memoryrouter-codex ${INTEGRATION_VERSION}\n\nUsage:\n memoryrouter-codex init [--scope user|project] [--auth oauth|api-key]\n [--project-dir PATH] [--api-key-stdin]\n [--no-hooks] [--no-agents]\n memoryrouter-codex doctor [--scope user|project] [--project-dir PATH] [--offline]\n memoryrouter-codex uninstall [--scope user|project] [--project-dir PATH]\n memoryrouter-codex reflect [--tier 1|2] [--batches N|all] [--max-tokens N] [--dry-run]\n memoryrouter-codex mcp-server\n\nReflect:\n Consolidates unconsolidated memories into reflections using Codex itself as\n the model (codex exec, you pay your own inference). Server-authored\n instructions; resumable; no local state. --dry-run prints the checkout and\n lets the lease expire without committing.\n\nAuth modes:\n oauth Current remote MCP at ${'https://mcp.memoryrouter.ai/mcp'} (default).\n Run \`codex mcp login memoryrouter\` after init. Hooks additionally\n require MEMORYROUTER_API_KEY or a key saved with --api-key-stdin.\n api-key Local stdio MCP fallback plus automatic hooks. The key is read only\n from the environment/private config, never from project files.\n\nSecure key setup:\n printf '%s' "$MEMORYROUTER_API_KEY" | memoryrouter-codex init --api-key-stdin\n`;
|
|
27
27
|
}
|
|
28
28
|
|
|
29
29
|
function optionsFromArgs(args) {
|
|
@@ -66,6 +66,17 @@ async function main() {
|
|
|
66
66
|
await require('../runtime/mcp-stdio.cjs').main();
|
|
67
67
|
return;
|
|
68
68
|
}
|
|
69
|
+
if (command === 'reflect') {
|
|
70
|
+
const { runReflect } = require('../runtime/reflect.cjs');
|
|
71
|
+
const result = await runReflect({
|
|
72
|
+
tier: args.tier,
|
|
73
|
+
batches: args.batches,
|
|
74
|
+
maxTokens: args.maxTokens,
|
|
75
|
+
dryRun: Boolean(args.dryRun),
|
|
76
|
+
});
|
|
77
|
+
process.exitCode = result.ok ? 0 : 1;
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
69
80
|
const options = optionsFromArgs(args);
|
|
70
81
|
if (command === 'init') {
|
|
71
82
|
const result = init(options);
|
package/lib/hooks.cjs
CHANGED
|
@@ -52,7 +52,6 @@ function managedGroups(runtimeDir, scope) {
|
|
|
52
52
|
return {
|
|
53
53
|
SessionStart: [{ matcher: 'startup|resume|clear|compact', hooks: [handler('session-start', 8, 'Recalling MemoryRouter context', 3500)] }],
|
|
54
54
|
UserPromptSubmit: [{ hooks: [handler('inject', 8, 'Searching project memory', 3500)] }],
|
|
55
|
-
PostToolUse: [{ hooks: [handler('trace', 3)] }],
|
|
56
55
|
Stop: [{ hooks: [handler('capture', 18, 'Saving durable project memory')] }],
|
|
57
56
|
SubagentStop: [{ hooks: [handler('capture-subagent', 15, 'Saving durable subagent outcome')] }],
|
|
58
57
|
PreCompact: [{ matcher: 'manual|auto', hooks: [handler('flush', 18, 'Saving memory before compaction')] }],
|
package/lib/installer.cjs
CHANGED
|
@@ -11,7 +11,7 @@ const {
|
|
|
11
11
|
installToml, uninstallToml, installAgents, uninstallAgents, readText, replaceBlock,
|
|
12
12
|
hasUnmanagedMemoryRouterTable, TOML_START, TOML_END, AGENTS_START, AGENTS_END,
|
|
13
13
|
} = require('./managed.cjs');
|
|
14
|
-
const { installHooks, uninstallHooks, isOwnedHook, parseHooks } = require('./hooks.cjs');
|
|
14
|
+
const { installHooks, uninstallHooks, isOwnedHook, managedGroups, parseHooks } = require('./hooks.cjs');
|
|
15
15
|
|
|
16
16
|
function resolveLayout(options = {}) {
|
|
17
17
|
const env = options.env || process.env;
|
|
@@ -176,7 +176,8 @@ async function doctor(options = {}) {
|
|
|
176
176
|
let hooks = null;
|
|
177
177
|
try { hooks = hooksText ? JSON.parse(hooksText) : null; } catch { /* handled */ }
|
|
178
178
|
const owned = hooks ? Object.values(hooks.hooks || {}).flat().flatMap(group => group.hooks || []).filter(isOwnedHook) : [];
|
|
179
|
-
|
|
179
|
+
const expectedOwned = Object.values(managedGroups(layout.runtimeDir, layout.scope)).flat().flatMap(group => group.hooks || []).length;
|
|
180
|
+
addCheck(checks, 'hooks', owned.length === expectedOwned ? 'pass' : 'error', owned.length ? `${owned.length} of ${expectedOwned} managed lifecycle hooks found.` : 'Managed lifecycle hooks are missing or hooks.json is invalid.');
|
|
180
181
|
addCheck(checks, 'agents', agentsText.includes(AGENTS_START) ? 'pass' : 'warn', agentsText.includes(AGENTS_START) ? 'Managed AGENTS.md guidance is present.' : 'Managed AGENTS.md guidance is missing.');
|
|
181
182
|
|
|
182
183
|
const cfg = loadConfig(options.env);
|
package/package.json
CHANGED
package/runtime/api.cjs
CHANGED
|
@@ -47,10 +47,13 @@ async function request(method, endpoint, options = {}) {
|
|
|
47
47
|
}
|
|
48
48
|
|
|
49
49
|
function search(key, query, limit, options = {}) {
|
|
50
|
+
const body = { query, limit };
|
|
51
|
+
if (options.tiers !== undefined) body.tiers = options.tiers;
|
|
52
|
+
if (options.importance !== undefined) body.importance = options.importance;
|
|
50
53
|
return request('POST', '/v1/memory/search', {
|
|
51
54
|
...options,
|
|
52
55
|
key,
|
|
53
|
-
body
|
|
56
|
+
body,
|
|
54
57
|
headers: options.sessionId ? { 'X-Session-ID': options.sessionId } : {},
|
|
55
58
|
retries: options.retries ?? 2,
|
|
56
59
|
});
|
|
@@ -77,4 +80,84 @@ function clearVault(key, options = {}) {
|
|
|
77
80
|
return request('DELETE', '/v1/memory', { ...options, key, retries: 0 });
|
|
78
81
|
}
|
|
79
82
|
|
|
80
|
-
|
|
83
|
+
// Reflection Hierarchy wrappers. Checkout and commit run with retries: 0 because
|
|
84
|
+
// both create or consume leases; blind transport retries are handled by the
|
|
85
|
+
// reflect loop, not this layer.
|
|
86
|
+
function reflectCheckout(key, tier = 1, maxTokens, options = {}) {
|
|
87
|
+
const body = { tier };
|
|
88
|
+
// Default budget is 50000 tokens. No connector-side cap: the server's own
|
|
89
|
+
// checkout limits are the only bound.
|
|
90
|
+
body.max_tokens = maxTokens === undefined ? 50000 : maxTokens;
|
|
91
|
+
return request('POST', '/v1/memory/consolidate/checkout', {
|
|
92
|
+
...options,
|
|
93
|
+
key,
|
|
94
|
+
body,
|
|
95
|
+
timeoutMs: options.timeoutMs || 20000,
|
|
96
|
+
retries: 0,
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function reflectCommit(key, batchId, entries, options = {}) {
|
|
101
|
+
return request('POST', '/v1/memory/consolidate/commit', {
|
|
102
|
+
...options,
|
|
103
|
+
key,
|
|
104
|
+
body: { batch_id: batchId, entries },
|
|
105
|
+
timeoutMs: options.timeoutMs || 30000,
|
|
106
|
+
retries: 0,
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function inspect(key, memoryId, options = {}) {
|
|
111
|
+
return request('POST', '/v1/memory/inspect', {
|
|
112
|
+
...options,
|
|
113
|
+
key,
|
|
114
|
+
body: { memory_id: memoryId },
|
|
115
|
+
timeoutMs: options.timeoutMs || 15000,
|
|
116
|
+
retries: options.retries ?? 1,
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function deleteIds(key, ids, options = {}) {
|
|
121
|
+
return request('POST', '/v1/memory/delete-ids', {
|
|
122
|
+
...options,
|
|
123
|
+
key,
|
|
124
|
+
body: { ids },
|
|
125
|
+
timeoutMs: options.timeoutMs || 20000,
|
|
126
|
+
retries: 0,
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function dateSearch(key, params = {}, options = {}) {
|
|
131
|
+
return request('POST', '/v1/memory/date-search', {
|
|
132
|
+
...options,
|
|
133
|
+
key,
|
|
134
|
+
body: params,
|
|
135
|
+
timeoutMs: options.timeoutMs || 15000,
|
|
136
|
+
retries: options.retries ?? 1,
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Sanctioned debt read: an empty-window date-search returns the reflection
|
|
141
|
+
// debt block with zero results and bills zero retrieval tokens.
|
|
142
|
+
function debtProbe(key, options = {}) {
|
|
143
|
+
return dateSearch(key, {
|
|
144
|
+
from: new Date(Date.now() - 1000).toISOString(),
|
|
145
|
+
max_tokens: 1000,
|
|
146
|
+
}, options);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
module.exports = {
|
|
150
|
+
sleep,
|
|
151
|
+
request,
|
|
152
|
+
search,
|
|
153
|
+
ingest,
|
|
154
|
+
stats,
|
|
155
|
+
warmup,
|
|
156
|
+
clearVault,
|
|
157
|
+
reflectCheckout,
|
|
158
|
+
reflectCommit,
|
|
159
|
+
inspect,
|
|
160
|
+
deleteIds,
|
|
161
|
+
dateSearch,
|
|
162
|
+
debtProbe,
|
|
163
|
+
};
|
package/runtime/config.cjs
CHANGED
|
@@ -74,10 +74,6 @@ function sessionStatePath(sessionId, env = process.env) {
|
|
|
74
74
|
return path.join(stateDir(env), `${safeId(sessionId)}.json`);
|
|
75
75
|
}
|
|
76
76
|
|
|
77
|
-
function tracePath(sessionId, turnId, env = process.env) {
|
|
78
|
-
return path.join(stateDir(env), `${safeId(sessionId)}-${safeId(turnId)}.trace.jsonl`);
|
|
79
|
-
}
|
|
80
|
-
|
|
81
77
|
function loadSessionState(sessionId, env = process.env) {
|
|
82
78
|
try {
|
|
83
79
|
const value = JSON.parse(fs.readFileSync(sessionStatePath(sessionId, env), 'utf8'));
|
|
@@ -154,7 +150,6 @@ module.exports = {
|
|
|
154
150
|
safeId,
|
|
155
151
|
stateDir,
|
|
156
152
|
sessionStatePath,
|
|
157
|
-
tracePath,
|
|
158
153
|
loadSessionState,
|
|
159
154
|
saveSessionState,
|
|
160
155
|
pruneState,
|
package/runtime/constants.cjs
CHANGED
|
@@ -1,13 +1,11 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
const INTEGRATION_VERSION = '1.
|
|
3
|
+
const INTEGRATION_VERSION = '1.1.0';
|
|
4
4
|
const RUNTIME_SCHEMA_VERSION = 1;
|
|
5
5
|
const API_BASE = 'https://api.memoryrouter.ai';
|
|
6
6
|
const MCP_ENDPOINT = 'https://mcp.memoryrouter.ai/mcp';
|
|
7
7
|
const DEFAULT_CONTEXT_CHARS = 12000;
|
|
8
8
|
const DEFAULT_SEARCH_RESULTS = 12;
|
|
9
|
-
const MAX_TRACE_RECORD_CHARS = 8000;
|
|
10
|
-
const MAX_TRACE_TOTAL_CHARS = 60000;
|
|
11
9
|
const ENVELOPE_HEADER = '[MemoryRouter Codex Memory v1]';
|
|
12
10
|
|
|
13
11
|
module.exports = {
|
|
@@ -17,7 +15,5 @@ module.exports = {
|
|
|
17
15
|
MCP_ENDPOINT,
|
|
18
16
|
DEFAULT_CONTEXT_CHARS,
|
|
19
17
|
DEFAULT_SEARCH_RESULTS,
|
|
20
|
-
MAX_TRACE_RECORD_CHARS,
|
|
21
|
-
MAX_TRACE_TOTAL_CHARS,
|
|
22
18
|
ENVELOPE_HEADER,
|
|
23
19
|
};
|
package/runtime/hook.cjs
CHANGED
|
@@ -1,11 +1,9 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
'use strict';
|
|
3
3
|
|
|
4
|
-
const
|
|
5
|
-
const { loadConfig, getApiKey, loadSessionState, saveSessionState, tracePath, pruneState, recordHealth } = require('./config.cjs');
|
|
4
|
+
const { loadConfig, getApiKey, loadSessionState, saveSessionState, pruneState, recordHealth } = require('./config.cjs');
|
|
6
5
|
const { resolveProject } = require('./project.cjs');
|
|
7
6
|
const { searchScoped, storeScoped, memoryHandle } = require('./memory.cjs');
|
|
8
|
-
const { MAX_TRACE_RECORD_CHARS, MAX_TRACE_TOTAL_CHARS } = require('./constants.cjs');
|
|
9
7
|
|
|
10
8
|
function debug(...values) {
|
|
11
9
|
if (process.env.MEMORYROUTER_DEBUG) console.error('[memoryrouter-codex]', ...values);
|
|
@@ -22,7 +20,7 @@ function readStdin(timeoutMs = 1500) {
|
|
|
22
20
|
});
|
|
23
21
|
}
|
|
24
22
|
|
|
25
|
-
function truncate(value, limit =
|
|
23
|
+
function truncate(value, limit = 8000) {
|
|
26
24
|
let text;
|
|
27
25
|
try { text = typeof value === 'string' ? value : JSON.stringify(value); } catch { text = String(value); }
|
|
28
26
|
if (text.length <= limit) return text;
|
|
@@ -38,61 +36,41 @@ function emitContext(eventName, context) {
|
|
|
38
36
|
return true;
|
|
39
37
|
}
|
|
40
38
|
|
|
41
|
-
|
|
42
|
-
if (!input.session_id || !input.turn_id) return;
|
|
43
|
-
const record = {
|
|
44
|
-
at: new Date().toISOString(),
|
|
45
|
-
turn_id: input.turn_id,
|
|
46
|
-
tool_name: input.tool_name,
|
|
47
|
-
tool_use_id: input.tool_use_id,
|
|
48
|
-
input: truncate(input.tool_input),
|
|
49
|
-
response: truncate(input.tool_response),
|
|
50
|
-
};
|
|
51
|
-
const file = tracePath(input.session_id, input.turn_id, env);
|
|
52
|
-
fs.appendFileSync(file, `${JSON.stringify(record)}\n`, { encoding: 'utf8', mode: 0o600, flag: 'a' });
|
|
53
|
-
}
|
|
39
|
+
const NUDGE_SUPPRESS_MS = 24 * 60 * 60 * 1000;
|
|
54
40
|
|
|
55
|
-
function
|
|
56
|
-
const
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
const records = [];
|
|
60
|
-
let total = 0;
|
|
61
|
-
for (const line of text.split('\n')) {
|
|
62
|
-
if (!line.trim()) continue;
|
|
63
|
-
try {
|
|
64
|
-
const record = JSON.parse(line);
|
|
65
|
-
const size = JSON.stringify(record).length;
|
|
66
|
-
if (total + size > MAX_TRACE_TOTAL_CHARS) break;
|
|
67
|
-
records.push(record);
|
|
68
|
-
total += size;
|
|
69
|
-
} catch { /* tolerate partial concurrent append */ }
|
|
70
|
-
}
|
|
71
|
-
return records;
|
|
41
|
+
function estimateDebtTokens(reflection) {
|
|
42
|
+
const raw = reflection?.unconsolidated?.raw?.est_tokens || 0;
|
|
43
|
+
const tier2 = reflection?.unconsolidated?.tier2?.est_tokens || 0;
|
|
44
|
+
return raw + tier2;
|
|
72
45
|
}
|
|
73
46
|
|
|
74
|
-
|
|
75
|
-
|
|
47
|
+
// Debt nudge rules (reflection-connectors-spec.md, section d):
|
|
48
|
+
// only when a context is being emitted anyway, at most once per session state
|
|
49
|
+
// per 24h window, and counted inside the existing context char cap so it can
|
|
50
|
+
// never push memory content out.
|
|
51
|
+
function maybeAppendNudge(context, reflection, sessionId, hookContext) {
|
|
52
|
+
if (!context || !reflection || !reflection.suggestion || !sessionId) return context;
|
|
53
|
+
const state = loadSessionState(sessionId, hookContext.env);
|
|
54
|
+
if (state.lastNudgeAt && Date.now() - Date.parse(state.lastNudgeAt) < NUDGE_SUPPRESS_MS) return context;
|
|
55
|
+
const tokens = estimateDebtTokens(reflection);
|
|
56
|
+
const nudge = `\n\n[MemoryRouter] Consolidation available: ${Math.round(tokens / 1000)}k unconsolidated tokens. Run: memoryrouter-codex reflect`;
|
|
57
|
+
const maxChars = Number(hookContext.config?.contextChars || 12000);
|
|
58
|
+
if (context.length + nudge.length > maxChars) return context;
|
|
59
|
+
state.lastNudgeAt = new Date().toISOString();
|
|
60
|
+
saveSessionState(sessionId, state, hookContext.env);
|
|
61
|
+
return context + nudge;
|
|
76
62
|
}
|
|
77
63
|
|
|
78
|
-
function turnContent(input, state
|
|
64
|
+
function turnContent(input, state) {
|
|
79
65
|
const prompt = state.prompts?.[input.turn_id] || state.lastPrompt || '';
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
`- ${record.tool_name || 'tool'} (${record.tool_use_id || 'unknown'})`,
|
|
89
|
-
` input: ${record.input}`,
|
|
90
|
-
` result: ${record.response}`,
|
|
91
|
-
].join('\n'));
|
|
92
|
-
}
|
|
93
|
-
}
|
|
94
|
-
if (input.last_assistant_message) parts.push(`Final assistant response:\n${truncate(input.last_assistant_message, 60000)}`);
|
|
95
|
-
return parts.filter(Boolean).join('\n\n');
|
|
66
|
+
return [
|
|
67
|
+
prompt ? `User request:
|
|
68
|
+
${truncate(prompt, 40000)}` : null,
|
|
69
|
+
input.last_assistant_message
|
|
70
|
+
? `Final assistant response:
|
|
71
|
+
${truncate(input.last_assistant_message, 60000)}`
|
|
72
|
+
: null,
|
|
73
|
+
].filter(Boolean).join('\n\n');
|
|
96
74
|
}
|
|
97
75
|
|
|
98
76
|
async function handleInject(input, context) {
|
|
@@ -115,7 +93,7 @@ async function handleInject(input, context) {
|
|
|
115
93
|
memories: result.memories?.length || 0,
|
|
116
94
|
status: result.status,
|
|
117
95
|
}, context.env);
|
|
118
|
-
emitContext('UserPromptSubmit', result.context);
|
|
96
|
+
emitContext('UserPromptSubmit', maybeAppendNudge(result.context, result.reflection, input.session_id, context));
|
|
119
97
|
}
|
|
120
98
|
|
|
121
99
|
async function handleSessionStart(input, context) {
|
|
@@ -130,7 +108,7 @@ async function handleSessionStart(input, context) {
|
|
|
130
108
|
project: context.project.handle,
|
|
131
109
|
memories: result.memories?.length || 0,
|
|
132
110
|
}, context.env);
|
|
133
|
-
emitContext('SessionStart', result.context);
|
|
111
|
+
emitContext('SessionStart', maybeAppendNudge(result.context, result.reflection, input.session_id, context));
|
|
134
112
|
}
|
|
135
113
|
|
|
136
114
|
async function captureTurn(input, context, partial = false) {
|
|
@@ -142,9 +120,10 @@ async function captureTurn(input, context, partial = false) {
|
|
|
142
120
|
if (!partial && state.capturedTurns?.[turnId]) {
|
|
143
121
|
return { ok: true, skipped: 'deduplicated', handle: state.capturedTurns[turnId] };
|
|
144
122
|
}
|
|
145
|
-
const
|
|
146
|
-
if (!
|
|
147
|
-
|
|
123
|
+
const prompt = state.prompts?.[turnId] || state.lastPrompt || '';
|
|
124
|
+
if (!partial && !input.last_assistant_message) return { ok: true, skipped: 'empty' };
|
|
125
|
+
if (partial && !prompt && !input.last_assistant_message) return { ok: true, skipped: 'empty' };
|
|
126
|
+
const content = turnContent({ ...input, turn_id: turnId }, state);
|
|
148
127
|
const kind = partial ? 'checkpoint' : 'turn';
|
|
149
128
|
const handle = memoryHandle(context.project.handle, kind, content);
|
|
150
129
|
if ((state.capturedHandles || []).includes(handle)) return { ok: true, skipped: 'deduplicated', handle };
|
|
@@ -166,14 +145,14 @@ async function captureTurn(input, context, partial = false) {
|
|
|
166
145
|
if (!partial && state.prompts) delete state.prompts[turnId];
|
|
167
146
|
state.lastCaptureAt = new Date().toISOString();
|
|
168
147
|
saveSessionState(sessionId, state, context.env);
|
|
169
|
-
if (!partial) removeTrace(sessionId, turnId, context.env);
|
|
170
148
|
}
|
|
171
149
|
recordHealth(partial ? 'flush' : 'capture', {
|
|
172
150
|
ok: result.ok,
|
|
173
151
|
status: result.status,
|
|
174
152
|
project: context.project.handle,
|
|
175
153
|
handle,
|
|
176
|
-
|
|
154
|
+
hasPrompt: Boolean(prompt),
|
|
155
|
+
hasFinalResponse: Boolean(input.last_assistant_message),
|
|
177
156
|
}, context.env);
|
|
178
157
|
return result;
|
|
179
158
|
}
|
|
@@ -222,7 +201,6 @@ async function run(kind, input, options = {}) {
|
|
|
222
201
|
switch (kind) {
|
|
223
202
|
case 'inject': return handleInject(input, context);
|
|
224
203
|
case 'session-start': return handleSessionStart(input, context);
|
|
225
|
-
case 'trace': appendTrace(input, env); return;
|
|
226
204
|
case 'capture': return captureTurn(input, context, false);
|
|
227
205
|
case 'capture-subagent': return captureSubagent(input, context);
|
|
228
206
|
case 'flush': return captureTurn(input, context, true);
|
|
@@ -232,7 +210,7 @@ async function run(kind, input, options = {}) {
|
|
|
232
210
|
|
|
233
211
|
async function main() {
|
|
234
212
|
const kind = process.argv[2];
|
|
235
|
-
const wallClock = setTimeout(() => process.exit(0), kind === '
|
|
213
|
+
const wallClock = setTimeout(() => process.exit(0), kind === 'inject' ? 7500 : 18000);
|
|
236
214
|
wallClock.unref();
|
|
237
215
|
let input = {};
|
|
238
216
|
try { input = JSON.parse((await readStdin()) || '{}'); } catch { debug('invalid hook JSON'); }
|
|
@@ -247,9 +225,8 @@ module.exports = {
|
|
|
247
225
|
readStdin,
|
|
248
226
|
truncate,
|
|
249
227
|
emitContext,
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
removeTrace,
|
|
228
|
+
maybeAppendNudge,
|
|
229
|
+
estimateDebtTokens,
|
|
253
230
|
turnContent,
|
|
254
231
|
handleInject,
|
|
255
232
|
handleSessionStart,
|
package/runtime/mcp-stdio.cjs
CHANGED
|
@@ -7,6 +7,7 @@ const { loadConfig, getApiKey, configPath, auditPath, recordAudit, healthPath }
|
|
|
7
7
|
const { resolveProject } = require('./project.cjs');
|
|
8
8
|
const { searchScoped, storeScoped, forgetLocally } = require('./memory.cjs');
|
|
9
9
|
const api = require('./api.cjs');
|
|
10
|
+
const { contractFrom, validateEntries } = require('./reflect.cjs');
|
|
10
11
|
const { INTEGRATION_VERSION } = require('./constants.cjs');
|
|
11
12
|
|
|
12
13
|
const SERVER_INSTRUCTIONS = 'MemoryRouter provides durable cross-machine, cross-tool, team-shareable memory. Search project memory before answering questions about prior decisions, preferences, incidents, or unfinished work. Store concise durable decisions and preferences after they are established. Automatic Codex hooks may already capture completed turns, so do not duplicate them. Use returned memory handles when referring to a record. Codex native Memories remain the local personal recall layer; MemoryRouter is the shared and auditable layer.';
|
|
@@ -20,6 +21,8 @@ const TOOLS = [
|
|
|
20
21
|
properties: {
|
|
21
22
|
query: { type: 'string', description: 'Semantic search query.' },
|
|
22
23
|
limit: { type: 'integer', minimum: 1, maximum: 50, description: 'Candidate results before project filtering.' },
|
|
24
|
+
tiers: { type: 'array', items: { type: 'integer', enum: [1, 2, 3] }, minItems: 1, uniqueItems: true, description: 'Restrict results to these memory tiers. 1 raw memories, 2 reflections, 3 high-level reflections. Omit to search all tiers blended.' },
|
|
25
|
+
importance: { type: 'integer', minimum: 1, maximum: 10, description: 'Only return memories rated at least this important (1 to 10). Only reflections carry ratings, so this filters to tier 2 and 3 content.' },
|
|
23
26
|
},
|
|
24
27
|
required: ['query'],
|
|
25
28
|
},
|
|
@@ -57,6 +60,46 @@ const TOOLS = [
|
|
|
57
60
|
},
|
|
58
61
|
annotations: { title: 'Forget MemoryRouter memory', readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
|
|
59
62
|
},
|
|
63
|
+
{
|
|
64
|
+
name: 'memory_consolidate',
|
|
65
|
+
description: 'Use this when the user asks to consolidate, reflect on, or organize their memories, or when memory_status or a search result reports that consolidation is available. Checks out a batch of unconsolidated memories from the connected vault under a 15 minute lease and returns their texts plus server instructions for writing reflection entries. Follow the returned instructions to write the entries, then call memory_commit_reflections with the batch_id. Nothing is modified until commit.',
|
|
66
|
+
inputSchema: {
|
|
67
|
+
type: 'object',
|
|
68
|
+
properties: {
|
|
69
|
+
tier: { type: 'integer', enum: [1, 2], default: 1, description: '1 consolidates raw memories into reflections; 2 consolidates reflections into high-level reflections.' },
|
|
70
|
+
max_tokens: { type: 'integer', minimum: 1000, default: 50000, description: 'Token budget for the checked-out batch. Defaults to 50000. No connector-side cap.' },
|
|
71
|
+
},
|
|
72
|
+
additionalProperties: false,
|
|
73
|
+
},
|
|
74
|
+
annotations: { title: 'Consolidate memories (checkout)', readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
name: 'memory_commit_reflections',
|
|
78
|
+
description: 'Use this to complete a consolidation started with memory_consolidate. Submits the reflection entries you wrote for the checked-out batch. Each entry is standalone prose with an importance rating from 1 to 10. The server stores the reflections, links them to their source memories, and marks the sources consolidated. Safe to retry: committing the same batch twice returns the original result.',
|
|
79
|
+
inputSchema: {
|
|
80
|
+
type: 'object',
|
|
81
|
+
properties: {
|
|
82
|
+
batch_id: { type: 'string', minLength: 1 },
|
|
83
|
+
entries: {
|
|
84
|
+
type: 'array',
|
|
85
|
+
minItems: 1,
|
|
86
|
+
maxItems: 100,
|
|
87
|
+
items: {
|
|
88
|
+
type: 'object',
|
|
89
|
+
properties: {
|
|
90
|
+
text: { type: 'string', minLength: 1, maxLength: 8000 },
|
|
91
|
+
importance: { type: 'integer', minimum: 1, maximum: 10 },
|
|
92
|
+
},
|
|
93
|
+
required: ['text', 'importance'],
|
|
94
|
+
additionalProperties: false,
|
|
95
|
+
},
|
|
96
|
+
},
|
|
97
|
+
},
|
|
98
|
+
required: ['batch_id', 'entries'],
|
|
99
|
+
additionalProperties: false,
|
|
100
|
+
},
|
|
101
|
+
annotations: { title: 'Commit reflections', readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
102
|
+
},
|
|
60
103
|
];
|
|
61
104
|
|
|
62
105
|
function textResult(text, isError = false) {
|
|
@@ -73,10 +116,28 @@ async function callTool(name, args = {}, options = {}) {
|
|
|
73
116
|
|
|
74
117
|
if (name === 'memory_search') {
|
|
75
118
|
if (!args.query) return textResult('query is required', true);
|
|
76
|
-
|
|
119
|
+
if (args.tiers !== undefined) {
|
|
120
|
+
if (!Array.isArray(args.tiers) || args.tiers.length < 1 || args.tiers.some(tier => ![1, 2, 3].includes(tier))) {
|
|
121
|
+
return textResult('tiers must be a non-empty array drawn from [1, 2, 3]', true);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
if (args.importance !== undefined && (!Number.isInteger(args.importance) || args.importance < 1 || args.importance > 10)) {
|
|
125
|
+
return textResult('importance must be an integer 1 to 10', true);
|
|
126
|
+
}
|
|
127
|
+
const result = await searchScoped(args.query, {
|
|
128
|
+
env, config, key, cwd, project, limit: args.limit,
|
|
129
|
+
...(args.tiers !== undefined ? { tiers: args.tiers } : {}),
|
|
130
|
+
...(args.importance !== undefined ? { importance: args.importance } : {}),
|
|
131
|
+
});
|
|
77
132
|
if (!result.ok) return textResult(`Memory search failed${result.status ? ` (HTTP ${result.status})` : ''}.`, true);
|
|
78
133
|
if (!result.context) return textResult(`No memories matched current project ${project.handle}.`);
|
|
79
|
-
|
|
134
|
+
let text = result.context;
|
|
135
|
+
const debt = result.reflection;
|
|
136
|
+
if (debt && debt.suggestion) {
|
|
137
|
+
const tokens = (debt.unconsolidated?.raw?.est_tokens || 0) + (debt.unconsolidated?.tier2?.est_tokens || 0);
|
|
138
|
+
text += `\n\nNote: ${Math.round(tokens / 1000)}k unconsolidated memory tokens are ready for consolidation. Suggest consolidating when convenient.`;
|
|
139
|
+
}
|
|
140
|
+
return textResult(text);
|
|
80
141
|
}
|
|
81
142
|
if (name === 'memory_store') {
|
|
82
143
|
if (!args.content) return textResult('content is required', true);
|
|
@@ -89,10 +150,14 @@ async function callTool(name, args = {}, options = {}) {
|
|
|
89
150
|
: `Stored for project ${project.handle}. Memory handle: ${result.handle}`);
|
|
90
151
|
}
|
|
91
152
|
if (name === 'memory_status') {
|
|
92
|
-
const response = await
|
|
153
|
+
const [response, probe] = await Promise.all([
|
|
154
|
+
api.stats(key, { apiBase: config.apiBase, timeoutMs: 8000, retries: 1 }),
|
|
155
|
+
api.debtProbe(key, { apiBase: config.apiBase, timeoutMs: 8000 }).catch(() => null),
|
|
156
|
+
]);
|
|
93
157
|
let health = null;
|
|
94
158
|
try { health = JSON.parse(fs.readFileSync(healthPath(env), 'utf8')); } catch { /* optional */ }
|
|
95
|
-
|
|
159
|
+
const debt = probe && probe.ok ? probe.data?.reflection || null : null;
|
|
160
|
+
const status = {
|
|
96
161
|
connected: response.ok,
|
|
97
162
|
status: response.status,
|
|
98
163
|
project: { handle: project.handle, identitySource: project.source, crossMachineStable: project.portable },
|
|
@@ -101,8 +166,65 @@ async function callTool(name, args = {}, options = {}) {
|
|
|
101
166
|
localAuditFile: auditPath(env),
|
|
102
167
|
retrievalScope: config.retrievalScope,
|
|
103
168
|
vault: response.ok ? response.data : null,
|
|
169
|
+
reflection: debt,
|
|
104
170
|
recentHooks: health,
|
|
105
|
-
}
|
|
171
|
+
};
|
|
172
|
+
let text = JSON.stringify(status, null, 2);
|
|
173
|
+
if (debt && debt.suggestion) {
|
|
174
|
+
const tokens = (debt.unconsolidated?.raw?.est_tokens || 0) + (debt.unconsolidated?.tier2?.est_tokens || 0);
|
|
175
|
+
text += `\n\nConsolidation is available: ${Math.round(tokens / 1000)}k unconsolidated memory tokens. The user can ask to consolidate memories.`;
|
|
176
|
+
}
|
|
177
|
+
return textResult(text);
|
|
178
|
+
}
|
|
179
|
+
if (name === 'memory_consolidate') {
|
|
180
|
+
const tier = args.tier === undefined ? 1 : args.tier;
|
|
181
|
+
if (tier !== 1 && tier !== 2) return textResult('tier must be 1 or 2', true);
|
|
182
|
+
if (args.max_tokens !== undefined && (!Number.isInteger(args.max_tokens) || args.max_tokens < 1000)) {
|
|
183
|
+
return textResult('max_tokens must be an integer of at least 1000', true);
|
|
184
|
+
}
|
|
185
|
+
const response = await api.reflectCheckout(key, tier, args.max_tokens, { apiBase: config.apiBase });
|
|
186
|
+
if (response.status === 403) return textResult('This memory key is read-only; consolidation requires a writable key.', true);
|
|
187
|
+
if (!response.ok) return textResult(`Consolidation checkout failed (HTTP ${response.status}).`, true);
|
|
188
|
+
const data = response.data || {};
|
|
189
|
+
if (!data.batch_id || !data.count) return textResult('Nothing to consolidate. The unconsolidated pool is empty.');
|
|
190
|
+
const contract = contractFrom(data);
|
|
191
|
+
if (!contract) return textResult('Checkout response carried no reflection contract. Refusing to consolidate: this connector never authors reflection prompts.', true);
|
|
192
|
+
// Invariant: the model never sees memory row IDs. source_ids is dropped
|
|
193
|
+
// here and never rendered; commit needs only the opaque batch_id.
|
|
194
|
+
const dated = (Array.isArray(data.memories) ? data.memories : [])
|
|
195
|
+
.map(memory => `[${memory.date || 'unknown date'}]\n${memory.text}`)
|
|
196
|
+
.join('\n\n');
|
|
197
|
+
recordAudit('consolidate-checkout', { ok: true, tier, count: data.count, project: project.handle }, env);
|
|
198
|
+
return textResult([
|
|
199
|
+
contract.instructions,
|
|
200
|
+
'',
|
|
201
|
+
`Batch of ${data.count} memories (${data.est_tokens} estimated tokens), covering ${data.covers?.from || 'unknown'} to ${data.covers?.to || 'unknown'}.`,
|
|
202
|
+
'',
|
|
203
|
+
dated,
|
|
204
|
+
'',
|
|
205
|
+
`When your entries are ready, call memory_commit_reflections with batch_id ${data.batch_id} before ${data.lease_expires_at}.`,
|
|
206
|
+
].join('\n'));
|
|
207
|
+
}
|
|
208
|
+
if (name === 'memory_commit_reflections') {
|
|
209
|
+
if (!args.batch_id || typeof args.batch_id !== 'string') return textResult('batch_id is required', true);
|
|
210
|
+
const validated = validateEntries({ entries: args.entries });
|
|
211
|
+
if (!validated.ok) return textResult(`Invalid entries: ${validated.reason}`, true);
|
|
212
|
+
const response = await api.reflectCommit(key, args.batch_id, validated.entries, { apiBase: config.apiBase });
|
|
213
|
+
if (response.status === 409) {
|
|
214
|
+
return textResult('The consolidation lease expired before commit. Ask the user whether to start over with memory_consolidate; the memories were returned to the pool and nothing was lost.', true);
|
|
215
|
+
}
|
|
216
|
+
if (response.status === 403) return textResult('This memory key is read-only; commit requires a writable key.', true);
|
|
217
|
+
if (!response.ok) return textResult(`Commit failed (HTTP ${response.status}).`, true);
|
|
218
|
+
const data = response.data || {};
|
|
219
|
+
// Invariant: created reflection UUIDs are dropped; the model never sees row IDs.
|
|
220
|
+
const created = Array.isArray(data.created) ? data.created.length : 0;
|
|
221
|
+
recordAudit('consolidate-commit', { ok: true, alreadyCommitted: Boolean(data.already_committed), created, sourcesConsolidated: data.sources_consolidated || 0, project: project.handle }, env);
|
|
222
|
+
const summary = data.already_committed
|
|
223
|
+
? 'This batch was already committed; the original result stands.'
|
|
224
|
+
: `${created} reflections written; ${data.sources_consolidated || 0} memories consolidated.`;
|
|
225
|
+
const debt = data.reflection;
|
|
226
|
+
const remaining = debt && debt.suggestion ? ` Remaining: ${debt.suggestion}.` : '';
|
|
227
|
+
return textResult(`${summary}${remaining}`);
|
|
106
228
|
}
|
|
107
229
|
if (name === 'memory_forget') {
|
|
108
230
|
if (args.clear_vault_confirmation === 'CLEAR MY ENTIRE MEMORYROUTER VAULT') {
|
package/runtime/memory.cjs
CHANGED
|
@@ -62,13 +62,18 @@ function parseEnvelope(content) {
|
|
|
62
62
|
return { project, handle, kind, body };
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
-
function filterMemories(memories, projectHandle, config) {
|
|
65
|
+
function filterMemories(memories, projectHandle, config, options = {}) {
|
|
66
66
|
const scope = config.retrievalScope || 'project';
|
|
67
67
|
const forgotten = new Set(config.forgottenHandles || []);
|
|
68
|
+
// Reflections (tier 2 and 3) are vault-level distilled prose without project
|
|
69
|
+
// envelopes. When the caller explicitly asked for reflection tiers, let those
|
|
70
|
+
// rows through the project filter instead of silently dropping every result.
|
|
71
|
+
const requestedReflectionTiers = Array.isArray(options.tiers) && options.tiers.some(tier => tier >= 2);
|
|
68
72
|
return (Array.isArray(memories) ? memories : []).filter(memory => {
|
|
69
73
|
const parsed = parseEnvelope(memory.content);
|
|
70
74
|
if (parsed && forgotten.has(parsed.handle)) return false;
|
|
71
75
|
if (scope === 'vault') return true;
|
|
76
|
+
if (requestedReflectionTiers && memory.tier != null && memory.tier >= 2) return true;
|
|
72
77
|
return parsed?.project === projectHandle;
|
|
73
78
|
});
|
|
74
79
|
}
|
|
@@ -85,7 +90,9 @@ function formatContext(memories, projectHandle, maxChars) {
|
|
|
85
90
|
const body = parsed ? parsed.body : String(memory.content || '');
|
|
86
91
|
const handle = parsed?.handle || memory.id || 'unhandled';
|
|
87
92
|
const timestamp = memory.timestampHuman || (memory.timestamp ? new Date(memory.timestamp).toISOString() : 'unknown time');
|
|
88
|
-
const
|
|
93
|
+
const tierTag = memory.tier != null && memory.tier !== 1 ? ` | tier ${memory.tier}` : '';
|
|
94
|
+
const importanceTag = memory.importance != null ? ` | importance ${memory.importance}` : '';
|
|
95
|
+
const chunk = `\n\n[${handle} | ${timestamp}${tierTag}${importanceTag}]\n${body.slice(0, 5000)}`;
|
|
89
96
|
if (output.length + chunk.length > maxChars) break;
|
|
90
97
|
output += chunk;
|
|
91
98
|
}
|
|
@@ -110,18 +117,21 @@ async function searchScoped(query, options = {}) {
|
|
|
110
117
|
sessionId: options.sessionId,
|
|
111
118
|
timeoutMs: options.timeoutMs || 5500,
|
|
112
119
|
retries: options.retries,
|
|
120
|
+
...(options.tiers !== undefined ? { tiers: options.tiers } : {}),
|
|
121
|
+
...(options.importance !== undefined ? { importance: options.importance } : {}),
|
|
113
122
|
},
|
|
114
123
|
);
|
|
115
124
|
if (!response.ok) {
|
|
116
125
|
recordAudit('search', { ok: false, status: response.status, project: project.handle }, options.env);
|
|
117
126
|
return { ok: false, status: response.status, memories: [], context: '' };
|
|
118
127
|
}
|
|
119
|
-
const memories = filterMemories(response.data?.memories, project.handle, config);
|
|
128
|
+
const memories = filterMemories(response.data?.memories, project.handle, config, { tiers: options.tiers });
|
|
120
129
|
recordAudit('search', { ok: true, project: project.handle, candidates: response.data?.memories?.length || 0, returned: memories.length }, options.env);
|
|
121
130
|
return {
|
|
122
131
|
ok: true,
|
|
123
132
|
project,
|
|
124
133
|
memories,
|
|
134
|
+
reflection: response.data?.reflection || null,
|
|
125
135
|
context: formatContext(memories, project.handle, Number(config.contextChars || 12000)),
|
|
126
136
|
};
|
|
127
137
|
}
|
|
@@ -188,7 +198,7 @@ function forgetLocally(handle, options = {}) {
|
|
|
188
198
|
ok: true,
|
|
189
199
|
handle,
|
|
190
200
|
deleted: false,
|
|
191
|
-
message: 'Suppressed from MemoryRouter Codex retrieval on this machine. The server record was not deleted
|
|
201
|
+
message: 'Suppressed from MemoryRouter Codex retrieval on this machine. The server record was not deleted; this Codex surface performs local suppression only. Per-memory server deletion is available from other MemoryRouter surfaces.',
|
|
192
202
|
};
|
|
193
203
|
}
|
|
194
204
|
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Reflection Hierarchy consolidation loop for the codex CLI lane.
|
|
4
|
+
//
|
|
5
|
+
// Locked invariants (reflection-connectors-spec.md, section d):
|
|
6
|
+
// 1. The server is the single source of reflection prompts. This module never
|
|
7
|
+
// authors reflection guidance; the model prompt is built exclusively from
|
|
8
|
+
// reflection_contract.instructions plus the dated memory texts. The strict
|
|
9
|
+
// JSON envelope instruction is connector mechanics, which is permitted.
|
|
10
|
+
// 2. No memory IDs ever reach the model. source_ids from checkout are never
|
|
11
|
+
// included in the prompt; batch_id stays with the connector.
|
|
12
|
+
// 3. No local state. Lease expiry on the server is the recovery mechanism;
|
|
13
|
+
// re-running continues from the oldest unconsolidated memory.
|
|
14
|
+
|
|
15
|
+
const fs = require('fs');
|
|
16
|
+
const os = require('os');
|
|
17
|
+
const path = require('path');
|
|
18
|
+
const crypto = require('crypto');
|
|
19
|
+
const { spawnSync } = require('child_process');
|
|
20
|
+
const api = require('./api.cjs');
|
|
21
|
+
const { loadConfig, getApiKey, recordAudit } = require('./config.cjs');
|
|
22
|
+
|
|
23
|
+
const MAX_ENTRY_CHARS = 8000;
|
|
24
|
+
const MAX_ENTRIES = 100;
|
|
25
|
+
const JSON_ENVELOPE_INSTRUCTION = [
|
|
26
|
+
'Output STRICT JSON only, no prose before or after, exactly this shape:',
|
|
27
|
+
'{"entries": [{"text": "...", "importance": N}]}',
|
|
28
|
+
'Each entry object has exactly two fields: text (string) and importance (integer 1 to 10).',
|
|
29
|
+
].join('\n');
|
|
30
|
+
const RETRY_SUFFIX = '\n\nYour previous output was not valid JSON in the required shape. Respond again with ONLY the JSON object, nothing else.';
|
|
31
|
+
|
|
32
|
+
function contractFrom(data) {
|
|
33
|
+
if (data && data.reflection_contract && typeof data.reflection_contract.instructions === 'string') {
|
|
34
|
+
return { instructions: data.reflection_contract.instructions, version: data.reflection_contract.version ?? null };
|
|
35
|
+
}
|
|
36
|
+
if (data && data.tier_definitions && typeof data.tier_definitions.instruction === 'string') {
|
|
37
|
+
return { instructions: data.tier_definitions.instruction, version: null };
|
|
38
|
+
}
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function buildPrompt(instructions, memories) {
|
|
43
|
+
const texts = (Array.isArray(memories) ? memories : [])
|
|
44
|
+
.map(memory => `[${memory.date || 'unknown date'}]\n${memory.text}`)
|
|
45
|
+
.join('\n\n');
|
|
46
|
+
return `${instructions}\n\nMemories:\n\n${texts}\n\n${JSON_ENVELOPE_INSTRUCTION}`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function extractJson(text) {
|
|
50
|
+
const raw = String(text || '');
|
|
51
|
+
try { return JSON.parse(raw); } catch { /* try fenced or embedded */ }
|
|
52
|
+
const fenced = raw.match(/```(?:json)?\s*([\s\S]*?)```/);
|
|
53
|
+
if (fenced) {
|
|
54
|
+
try { return JSON.parse(fenced[1]); } catch { /* fall through */ }
|
|
55
|
+
}
|
|
56
|
+
const start = raw.indexOf('{');
|
|
57
|
+
const end = raw.lastIndexOf('}');
|
|
58
|
+
if (start >= 0 && end > start) {
|
|
59
|
+
try { return JSON.parse(raw.slice(start, end + 1)); } catch { /* invalid */ }
|
|
60
|
+
}
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function validateEntries(parsed) {
|
|
65
|
+
if (!parsed || !Array.isArray(parsed.entries)) return { ok: false, reason: 'entries array missing' };
|
|
66
|
+
if (parsed.entries.length < 1) return { ok: false, reason: 'entries array is empty' };
|
|
67
|
+
if (parsed.entries.length > MAX_ENTRIES) return { ok: false, reason: `more than ${MAX_ENTRIES} entries` };
|
|
68
|
+
const entries = [];
|
|
69
|
+
for (const entry of parsed.entries) {
|
|
70
|
+
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) return { ok: false, reason: 'entry is not an object' };
|
|
71
|
+
const keys = Object.keys(entry);
|
|
72
|
+
const extras = keys.filter(k => k !== 'text' && k !== 'importance');
|
|
73
|
+
if (extras.length) return { ok: false, reason: `entry has forbidden fields: ${extras.join(', ')}` };
|
|
74
|
+
if (typeof entry.text !== 'string' || entry.text.length < 1 || entry.text.length > MAX_ENTRY_CHARS) {
|
|
75
|
+
return { ok: false, reason: `entry text must be a string of 1 to ${MAX_ENTRY_CHARS} chars` };
|
|
76
|
+
}
|
|
77
|
+
if (!Number.isInteger(entry.importance) || entry.importance < 1 || entry.importance > 10) {
|
|
78
|
+
return { ok: false, reason: 'entry importance must be an integer 1 to 10' };
|
|
79
|
+
}
|
|
80
|
+
entries.push({ text: entry.text, importance: entry.importance });
|
|
81
|
+
}
|
|
82
|
+
return { ok: true, entries };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function invokeCodex(prompt, options = {}) {
|
|
86
|
+
const outFile = path.join(os.tmpdir(), `mrcodex-reflect-${process.pid}-${crypto.randomBytes(4).toString('hex')}.txt`);
|
|
87
|
+
try {
|
|
88
|
+
const result = spawnSync('codex', ['exec', '--skip-git-repo-check', '--output-last-message', outFile, prompt], {
|
|
89
|
+
encoding: 'utf8',
|
|
90
|
+
timeout: options.timeoutMs || 10 * 60 * 1000,
|
|
91
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
92
|
+
});
|
|
93
|
+
if (result.error) return { ok: false, reason: result.error.message };
|
|
94
|
+
let output = '';
|
|
95
|
+
try { output = fs.readFileSync(outFile, 'utf8'); } catch { output = String(result.stdout || ''); }
|
|
96
|
+
if (!output.trim()) return { ok: false, reason: `codex exec produced no output (exit ${result.status})` };
|
|
97
|
+
return { ok: true, output };
|
|
98
|
+
} finally {
|
|
99
|
+
try { fs.rmSync(outFile, { force: true }); } catch { /* best effort */ }
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async function runReflect(options = {}) {
|
|
104
|
+
const env = options.env || process.env;
|
|
105
|
+
const config = options.config || loadConfig(env);
|
|
106
|
+
const key = options.key || getApiKey(env, config);
|
|
107
|
+
const log = options.log || (line => process.stdout.write(`${line}\n`));
|
|
108
|
+
const invokeModel = options.invokeModel || invokeCodex;
|
|
109
|
+
if (!key) {
|
|
110
|
+
log('MemoryRouter API key is not configured. Run `memoryrouter-codex init` or set MEMORYROUTER_API_KEY.');
|
|
111
|
+
return { ok: false, reason: 'no-api-key' };
|
|
112
|
+
}
|
|
113
|
+
const tier = Number(options.tier || 1);
|
|
114
|
+
if (tier !== 1 && tier !== 2) {
|
|
115
|
+
log('tier must be 1 or 2');
|
|
116
|
+
return { ok: false, reason: 'bad-tier' };
|
|
117
|
+
}
|
|
118
|
+
const batchesAll = options.batches === 'all';
|
|
119
|
+
const maxBatches = batchesAll ? Infinity : Math.max(1, Number(options.batches || 1));
|
|
120
|
+
const maxTokens = options.maxTokens === undefined ? undefined : Number(options.maxTokens);
|
|
121
|
+
const apiOptions = { apiBase: config.apiBase };
|
|
122
|
+
|
|
123
|
+
let completed = 0;
|
|
124
|
+
let totalCreated = 0;
|
|
125
|
+
let totalConsolidated = 0;
|
|
126
|
+
let leaseWarned = false;
|
|
127
|
+
let leaseRetries = 0;
|
|
128
|
+
const maxLeaseRetries = 3;
|
|
129
|
+
|
|
130
|
+
while (completed < maxBatches) {
|
|
131
|
+
const checkout = await api.reflectCheckout(key, tier, maxTokens, apiOptions);
|
|
132
|
+
if (!checkout.ok) {
|
|
133
|
+
log(`Checkout failed (HTTP ${checkout.status}).`);
|
|
134
|
+
recordAudit('reflect-checkout', { ok: false, status: checkout.status, tier }, env);
|
|
135
|
+
return { ok: false, reason: 'checkout-failed', status: checkout.status, completed };
|
|
136
|
+
}
|
|
137
|
+
const data = checkout.data || {};
|
|
138
|
+
if (!data.batch_id || !data.count) {
|
|
139
|
+
log(completed ? 'Pool is empty. Nothing more to consolidate.' : 'Nothing to consolidate.');
|
|
140
|
+
return { ok: true, completed, created: totalCreated, consolidated: totalConsolidated, empty: true };
|
|
141
|
+
}
|
|
142
|
+
recordAudit('reflect-checkout', { ok: true, tier, count: data.count, estTokens: data.est_tokens }, env);
|
|
143
|
+
|
|
144
|
+
const contract = contractFrom(data);
|
|
145
|
+
if (!contract) {
|
|
146
|
+
log('Checkout response carried no reflection contract. Refusing to reflect: this connector never authors its own reflection prompt. Update memoryrouter-codex or contact support.');
|
|
147
|
+
return { ok: false, reason: 'no-contract', completed };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const prompt = buildPrompt(contract.instructions, data.memories);
|
|
151
|
+
|
|
152
|
+
if (options.dryRun) {
|
|
153
|
+
log(`Dry run. Batch of ${data.count} memories (${data.est_tokens} est tokens), lease expires ${data.lease_expires_at}.`);
|
|
154
|
+
log('The lease will be allowed to expire; nothing will be committed.');
|
|
155
|
+
log('\n----- reflection contract instructions -----\n');
|
|
156
|
+
log(contract.instructions);
|
|
157
|
+
log('\n----- full model prompt -----\n');
|
|
158
|
+
log(prompt);
|
|
159
|
+
return { ok: true, dryRun: true, completed };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
log(`Batch ${completed + 1}: ${data.count} memories, ${data.est_tokens} est tokens, lease expires ${data.lease_expires_at}.`);
|
|
163
|
+
|
|
164
|
+
let entries = null;
|
|
165
|
+
let attemptPrompt = prompt;
|
|
166
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
167
|
+
const invocation = invokeModel(attemptPrompt, options);
|
|
168
|
+
if (!invocation.ok) {
|
|
169
|
+
log(`Model invocation failed: ${invocation.reason}`);
|
|
170
|
+
break;
|
|
171
|
+
}
|
|
172
|
+
const validated = validateEntries(extractJson(invocation.output));
|
|
173
|
+
if (validated.ok) { entries = validated.entries; break; }
|
|
174
|
+
log(`Model output invalid (${validated.reason}).${attempt === 0 ? ' Retrying once.' : ''}`);
|
|
175
|
+
attemptPrompt = prompt + RETRY_SUFFIX;
|
|
176
|
+
}
|
|
177
|
+
if (!entries) {
|
|
178
|
+
log('Giving up on this batch. The lease will expire on its own and the memories return to the pool. No entries were invented.');
|
|
179
|
+
recordAudit('reflect-abandon', { tier, batchAbandoned: true }, env);
|
|
180
|
+
return { ok: false, reason: 'model-output-invalid', completed };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (data.lease_expires_at && Date.now() >= Date.parse(data.lease_expires_at)) {
|
|
184
|
+
log('The model step outlasted the lease; skipping commit and re-checking out a fresh batch.');
|
|
185
|
+
if (!leaseWarned) {
|
|
186
|
+
log('Hint: pass a smaller --max-tokens so each batch fits inside the 15 minute lease.');
|
|
187
|
+
leaseWarned = true;
|
|
188
|
+
}
|
|
189
|
+
leaseRetries += 1;
|
|
190
|
+
if (leaseRetries >= maxLeaseRetries) {
|
|
191
|
+
log('Repeated lease expiries; stopping. Re-run with a smaller --max-tokens.');
|
|
192
|
+
return { ok: false, reason: 'lease-expired-repeatedly', completed };
|
|
193
|
+
}
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const commit = await api.reflectCommit(key, data.batch_id, entries, apiOptions);
|
|
198
|
+
if (commit.status === 409) {
|
|
199
|
+
log('Lease expired before commit. The memories returned to the pool; re-checking out.');
|
|
200
|
+
if (!leaseWarned) {
|
|
201
|
+
log('Hint: pass a smaller --max-tokens so each batch fits inside the 15 minute lease.');
|
|
202
|
+
leaseWarned = true;
|
|
203
|
+
}
|
|
204
|
+
leaseRetries += 1;
|
|
205
|
+
if (leaseRetries >= maxLeaseRetries) {
|
|
206
|
+
log('Repeated lease expiries; stopping. Re-run with a smaller --max-tokens.');
|
|
207
|
+
return { ok: false, reason: 'lease-expired-repeatedly', completed };
|
|
208
|
+
}
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
leaseRetries = 0;
|
|
212
|
+
if (!commit.ok) {
|
|
213
|
+
log(`Commit failed (HTTP ${commit.status}).`);
|
|
214
|
+
recordAudit('reflect-commit', { ok: false, status: commit.status, tier }, env);
|
|
215
|
+
return { ok: false, reason: 'commit-failed', status: commit.status, completed };
|
|
216
|
+
}
|
|
217
|
+
const committed = commit.data || {};
|
|
218
|
+
const createdCount = Array.isArray(committed.created) ? committed.created.length : 0;
|
|
219
|
+
totalCreated += createdCount;
|
|
220
|
+
totalConsolidated += committed.sources_consolidated || 0;
|
|
221
|
+
completed += 1;
|
|
222
|
+
recordAudit('reflect-commit', {
|
|
223
|
+
ok: true, tier, alreadyCommitted: Boolean(committed.already_committed), created: createdCount, sourcesConsolidated: committed.sources_consolidated || 0,
|
|
224
|
+
}, env);
|
|
225
|
+
if (committed.already_committed) {
|
|
226
|
+
log('Batch was already committed; treated as success.');
|
|
227
|
+
} else {
|
|
228
|
+
log(`Committed ${createdCount} reflections covering ${committed.sources_consolidated || 0} memories.`);
|
|
229
|
+
}
|
|
230
|
+
const debt = committed.reflection;
|
|
231
|
+
if (debt && debt.suggestion) log(`Remaining: ${debt.suggestion}.`);
|
|
232
|
+
else if (debt && !debt.suggestion && batchesAll) {
|
|
233
|
+
log('Debt is below threshold; stopping.');
|
|
234
|
+
break;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
log(`Done. ${completed} batch${completed === 1 ? '' : 'es'} committed, ${totalCreated} reflections created, ${totalConsolidated} memories consolidated.`);
|
|
239
|
+
return { ok: true, completed, created: totalCreated, consolidated: totalConsolidated };
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
module.exports = {
|
|
243
|
+
JSON_ENVELOPE_INSTRUCTION,
|
|
244
|
+
contractFrom,
|
|
245
|
+
buildPrompt,
|
|
246
|
+
extractJson,
|
|
247
|
+
validateEntries,
|
|
248
|
+
invokeCodex,
|
|
249
|
+
runReflect,
|
|
250
|
+
};
|