renor 0.1.0 → 0.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 +39 -2
- package/bin/renor.mjs +11 -1
- package/lib/args.mjs +14 -4
- package/lib/clients.mjs +45 -11
- package/lib/constants.mjs +1 -1
- package/lib/mcp-client.mjs +66 -0
- package/lib/mcp.mjs +60 -0
- package/lib/review-rules-cli.mjs +144 -0
- package/lib/review-rules-source.mjs +83 -0
- package/lib/review-rules.mjs +390 -0
- package/lib/setup.mjs +89 -4
- package/lib/sharing-gate.mjs +42 -0
- package/lib/sharing.mjs +114 -0
- package/lib/sync.mjs +249 -0
- package/lib/system.mjs +12 -4
- package/lib/verify.mjs +153 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -6,7 +6,9 @@ Connects the AI apps on your computer to [Renor](https://renor.cloud), so they s
|
|
|
6
6
|
npx renor setup
|
|
7
7
|
```
|
|
8
8
|
|
|
9
|
-
It finds the apps you have, adds Renor to each, signs you in, and installs the Renor skill, which tells your AI when to save and when to recall.
|
|
9
|
+
It finds the apps you have, adds Renor to each, signs you in, and installs the Renor skill, which tells your AI when to save and when to recall. Then it checks that Renor’s gateway answers, and lists anything left for you to do, app by app; anything that fails prints what happened and the exact fix.
|
|
10
|
+
|
|
11
|
+
To prove the whole path, run `npx renor setup --verify` once Claude Code is signed in to Renor: it runs `claude -p` in an empty folder, with only Renor’s tools allowed and Claude Code’s own tools (Bash, Edit, Write, Read and the rest) denied, to save one test memory, recall it, and then retire that exact note again with `retire_memory` (by the id of its own save, so nothing else is touched) and check it no longer comes back. That uses a little of its model quota. If the check ends before that is confirmed, setup runs a second pass that finds the note by its exact random code and retires it; if even that cannot be confirmed, the run fails and prints the code. The other apps are not launched by setup, because their one-shot modes cannot be limited to Renor’s tools; setup tells you what to say to them instead.
|
|
10
12
|
|
|
11
13
|
| App | How Renor is added | Sign-in |
|
|
12
14
|
|---|---|---|
|
|
@@ -14,6 +16,7 @@ It finds the apps you have, adds Renor to each, signs you in, and installs the R
|
|
|
14
16
|
| Codex | `codex mcp add renor --url …` | opens your browser right away |
|
|
15
17
|
| Cursor | adds `renor` to `~/.cursor/mcp.json` (a backup of the old file is kept) | `cursor-agent mcp login renor`, or in Cursor's settings |
|
|
16
18
|
| OpenCode | `opencode mcp add renor --url … --global` | `opencode mcp auth renor` |
|
|
19
|
+
| Gemini CLI | adds `renor` (`httpUrl`) to `~/.gemini/settings.json` (a backup is kept) | in Gemini CLI: `/mcp auth renor` |
|
|
17
20
|
| Pi (0.99 or later) | `pi mcp add renor --url … --exposure direct` | `pi mcp login renor` |
|
|
18
21
|
| VS Code | `code --add-mcp …` | when Copilot first uses Renor |
|
|
19
22
|
|
|
@@ -25,6 +28,10 @@ Every app gets the same address, `https://mcp.renor.cloud/mcp`. No key is stored
|
|
|
25
28
|
- If an app already has a server named `renor` that points somewhere else, it is left as it is unless you pass `--force`.
|
|
26
29
|
- It sends nothing anywhere itself. The only network use is the apps' own sign-ins and `npx skills` installing the skill.
|
|
27
30
|
|
|
31
|
+
## The one question
|
|
32
|
+
|
|
33
|
+
At the end, at a terminal, it asks once whether to share anonymous usage signals with Renor (numbers only: never memory text, queries, names or paths). The answer is **No** unless you type `y`, and it is asked once: your answer is saved in `~/.config/renor/sharing.json` (or `$RENOR_SHARING_DIR`), the file the open-source engine reads, so `renor-engine telemetry status` shows it and `renor-engine telemetry off` changes it. `--yes`, `--dry-run`, a machine with no terminal, `DO_NOT_TRACK=1` and `RENOR_SHARING=off` never ask. This command sends nothing either way; a hosted workspace has its own switch in Settings on https://app.renor.cloud.
|
|
34
|
+
|
|
28
35
|
## Options
|
|
29
36
|
|
|
30
37
|
```
|
|
@@ -32,10 +39,40 @@ Every app gets the same address, `https://mcp.renor.cloud/mcp`. No key is stored
|
|
|
32
39
|
-y, --yes Do not ask before adding
|
|
33
40
|
--no-login Add Renor but leave the sign-ins for later
|
|
34
41
|
--no-skill Do not install the skill
|
|
42
|
+
--verify Also have Claude Code save and recall one test memory
|
|
35
43
|
--force Replace a renor server that points somewhere else
|
|
36
|
-
--only <apps> Only some apps: claude-code, codex, cursor, opencode, pi, vscode
|
|
44
|
+
--only <apps> Only some apps: claude-code, codex, cursor, opencode, gemini, pi, vscode
|
|
37
45
|
```
|
|
38
46
|
|
|
47
|
+
## Your rules in every review
|
|
48
|
+
|
|
49
|
+
```sh
|
|
50
|
+
gh pr diff 123 | RENOR_API_KEY=… npx renor review-rules
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
It prints the standing rules and preferences you saved in Renor that bear on the diff, to put at the top of a review prompt: each one quoted exactly as saved, with the day it was saved and its memory id, most relevant first, about 600 tokens at most (`--max-tokens`). The block ends by asking the reviewer to cite a rule's id when a finding rests on it. A value you later replaced is never quoted.
|
|
54
|
+
|
|
55
|
+
```
|
|
56
|
+
--diff <file|-> The diff (default: standard input)
|
|
57
|
+
--export [file] Write the rules into one marked section of REVIEW.md (or AGENTS.md); nothing else in the file changes
|
|
58
|
+
--propose <dir> Read past reviews (Markdown) and propose rules from findings that recur; saves nothing
|
|
59
|
+
--url <url> A local engine: http://127.0.0.1:3471/mcp (default: RENOR_URL, else the hosted address)
|
|
60
|
+
--rules-file <json> Rules from a file instead of Renor
|
|
61
|
+
--json JSON instead of text
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Every call it makes to Renor is a read (`memory_map`, `get_context`, `retrieve_memory`, `read_memory`).
|
|
65
|
+
|
|
39
66
|
ChatGPT and Claude in the browser are set up from your dashboard at https://app.renor.cloud.
|
|
40
67
|
|
|
41
68
|
Needs Node.js 18 or later.
|
|
69
|
+
|
|
70
|
+
## Sync your agents' memory files
|
|
71
|
+
|
|
72
|
+
```sh
|
|
73
|
+
npx renor sync add ~/.claude/CLAUDE.md ./AGENTS.md # choose what Renor may read (files or folders of .md)
|
|
74
|
+
RENOR_TOKEN=… npx renor sync # import what is new or changed; --dry-run to preview
|
|
75
|
+
npx renor sync remove <path> # stop syncing it
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Opt-in: only the paths you add are read, only `.md` files. Each note lands under `Imported/synced/` with a first line naming its source path; a changed file is imported again (alternating between `Imported/synced/` and `Imported/synced-2/`) and the earlier copy is retired only once the new one is in, so a failed run never costs you the old note. Links inside a chosen folder are skipped; a file you choose by name may be a link. A file Renor would leave out (it looks like a secret, or is empty) is reported as failed and its earlier copy is kept. A file you delete locally is listed as `gone`; Renor keeps its note until you forget it in the dashboard. `RENOR_TOKEN` is a connect token from the dashboard and is never stored. It is sent only to https (or `http://127.0.0.1` / `[::1]` for a local engine) when `RENOR_MCP_URL` is set; redirects are refused.
|
package/bin/renor.mjs
CHANGED
|
@@ -2,11 +2,14 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* renor — connects the AI apps on this computer to Renor, the memory they share.
|
|
4
4
|
* Usage: npx renor setup [--yes] [--dry-run] [--no-login] [--no-skill] [--force] [--only claude-code,codex]
|
|
5
|
+
* npx renor review-rules [--diff <file>] [--export [file]] [--propose <dir>]
|
|
5
6
|
*/
|
|
6
7
|
import { readFileSync } from 'node:fs'
|
|
7
8
|
import { HELP, parse } from '../lib/args.mjs'
|
|
8
9
|
import { CLIENTS } from '../lib/clients.mjs'
|
|
10
|
+
import { reviewRules } from '../lib/review-rules-cli.mjs'
|
|
9
11
|
import { setup } from '../lib/setup.mjs'
|
|
12
|
+
import { sync } from '../lib/sync.mjs'
|
|
10
13
|
|
|
11
14
|
function version() {
|
|
12
15
|
try {
|
|
@@ -17,6 +20,8 @@ function version() {
|
|
|
17
20
|
}
|
|
18
21
|
|
|
19
22
|
async function main() {
|
|
23
|
+
// review-rules has options of its own, so it reads its own arguments.
|
|
24
|
+
if (process.argv[2] === 'review-rules') return reviewRules(process.argv.slice(3))
|
|
20
25
|
const options = parse(process.argv.slice(2))
|
|
21
26
|
if (options.version) {
|
|
22
27
|
process.stdout.write(version() + '\n')
|
|
@@ -26,6 +31,7 @@ async function main() {
|
|
|
26
31
|
process.stdout.write(HELP + '\n')
|
|
27
32
|
return 0
|
|
28
33
|
}
|
|
34
|
+
if (options.command === 'sync') return sync(process.argv.slice(3))
|
|
29
35
|
if (options.command !== 'setup') {
|
|
30
36
|
process.stderr.write(`Unknown command: ${options.command}\n\n${HELP}\n`)
|
|
31
37
|
return 2
|
|
@@ -34,6 +40,10 @@ async function main() {
|
|
|
34
40
|
process.stderr.write(`Unknown option: ${options.unknown.join(' ')}\n\n${HELP}\n`)
|
|
35
41
|
return 2
|
|
36
42
|
}
|
|
43
|
+
if (options.only && options.only.length === 0) {
|
|
44
|
+
process.stderr.write(`--only needs a list of apps: ${CLIENTS.map((client) => client.id).join(', ')}\n`)
|
|
45
|
+
return 2
|
|
46
|
+
}
|
|
37
47
|
const unknownApps = (options.only || []).filter((id) => !CLIENTS.some((client) => client.id === id))
|
|
38
48
|
if (unknownApps.length > 0) {
|
|
39
49
|
process.stderr.write(`Unknown app: ${unknownApps.join(', ')}. Known: ${CLIENTS.map((client) => client.id).join(', ')}\n`)
|
|
@@ -51,7 +61,7 @@ if (major < 18) {
|
|
|
51
61
|
main().then(
|
|
52
62
|
(code) => process.exit(code),
|
|
53
63
|
(error) => {
|
|
54
|
-
process.stderr.write(`renor setup failed: ${error instanceof Error ? error.message : String(error)}\n`)
|
|
64
|
+
process.stderr.write(`renor ${process.argv[2] === 'review-rules' ? 'review-rules' : 'setup'} failed: ${error instanceof Error ? error.message : String(error)}\n`)
|
|
55
65
|
process.exit(1)
|
|
56
66
|
},
|
|
57
67
|
)
|
package/lib/args.mjs
CHANGED
|
@@ -4,13 +4,17 @@ import { CLIENTS } from './clients.mjs'
|
|
|
4
4
|
export const HELP = `renor — connect your AI apps to Renor, the memory they share.
|
|
5
5
|
|
|
6
6
|
Usage
|
|
7
|
-
npx renor setup Find your AI apps, add Renor to each, sign in, install the skill
|
|
7
|
+
npx renor setup Find your AI apps, add Renor to each, sign in, install the skill, check that Renor answers
|
|
8
8
|
npx renor setup --dry-run Say what would happen and change nothing
|
|
9
|
+
npx renor review-rules Your standing rules from Renor, for a code review (--help for more)
|
|
10
|
+
npx renor sync add <path> Choose memory files (CLAUDE.md, AGENTS.md, a notes folder) to keep in Renor
|
|
11
|
+
npx renor sync Send what changed in them (needs RENOR_TOKEN; --dry-run to preview)
|
|
9
12
|
|
|
10
13
|
Options
|
|
11
14
|
-y, --yes Do not ask before adding
|
|
12
15
|
--no-login Add Renor but leave the sign-ins for later
|
|
13
16
|
--no-skill Do not install the Renor skill
|
|
17
|
+
--verify Also have Claude Code save and recall one test memory (saves a test note, then retires it; uses a little model quota)
|
|
14
18
|
--force Replace a renor server that points somewhere else
|
|
15
19
|
--only <apps> Only these apps: ${CLIENTS.map((client) => client.id).join(', ')}
|
|
16
20
|
-h, --help Show this help
|
|
@@ -18,17 +22,23 @@ Options
|
|
|
18
22
|
|
|
19
23
|
ChatGPT and Claude in the browser are set up from https://app.renor.cloud.`
|
|
20
24
|
|
|
25
|
+
/** `--only`'s value as app ids; a missing value is an empty list, which the command refuses. */
|
|
26
|
+
function appList(value) {
|
|
27
|
+
return (value || '').split(',').map((id) => id.trim()).filter(Boolean)
|
|
28
|
+
}
|
|
29
|
+
|
|
21
30
|
export function parse(argv) {
|
|
22
|
-
const options = { command: null, yes: false, dryRun: false, login: true, skill: true, force: false, only: null, help: false, version: false, unknown: [] }
|
|
31
|
+
const options = { command: null, yes: false, dryRun: false, login: true, skill: true, verify: false, force: false, only: null, help: false, version: false, unknown: [] }
|
|
23
32
|
for (let i = 0; i < argv.length; i++) {
|
|
24
33
|
const arg = argv[i]
|
|
25
34
|
if (arg === '-y' || arg === '--yes') options.yes = true
|
|
26
35
|
else if (arg === '--dry-run') options.dryRun = true
|
|
27
36
|
else if (arg === '--no-login') options.login = false
|
|
28
37
|
else if (arg === '--no-skill') options.skill = false
|
|
38
|
+
else if (arg === '--verify') options.verify = true
|
|
29
39
|
else if (arg === '--force') options.force = true
|
|
30
|
-
else if (arg === '--only') options.only = (argv[
|
|
31
|
-
else if (arg.startsWith('--only=')) options.only = arg.slice(7)
|
|
40
|
+
else if (arg === '--only') options.only = appList(argv[i + 1]?.startsWith('-') ? '' : argv[++i])
|
|
41
|
+
else if (arg.startsWith('--only=')) options.only = appList(arg.slice(7))
|
|
32
42
|
else if (arg === '-h' || arg === '--help') options.help = true
|
|
33
43
|
else if (arg === '-v' || arg === '--version') options.version = true
|
|
34
44
|
else if (!arg.startsWith('-') && !options.command) options.command = arg
|
package/lib/clients.mjs
CHANGED
|
@@ -18,7 +18,7 @@ const failed = (detail) => ({ ok: false, detail, signedIn: false })
|
|
|
18
18
|
/** The URL a config entry points at, from the shapes the apps use (`url`, or `serverUrl` in older files). */
|
|
19
19
|
function urlOf(entry) {
|
|
20
20
|
if (!entry || typeof entry !== 'object') return null
|
|
21
|
-
return typeof entry.url === 'string' ? entry.url : typeof entry.serverUrl === 'string' ? entry.serverUrl : null
|
|
21
|
+
return typeof entry.url === 'string' ? entry.url : typeof entry.httpUrl === 'string' ? entry.httpUrl : typeof entry.serverUrl === 'string' ? entry.serverUrl : null
|
|
22
22
|
}
|
|
23
23
|
|
|
24
24
|
/** A JSON file we may rewrite: it parsed, and it held no comments that a rewrite would drop. */
|
|
@@ -34,6 +34,11 @@ function vscodeUserDir(env) {
|
|
|
34
34
|
return join(env.XDG_CONFIG_HOME || join(home(env), '.config'), 'Code', 'User')
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
+
/** Claude Code keeps its user config in CLAUDE_CONFIG_DIR when that is set, in the home folder otherwise. */
|
|
38
|
+
function claudeConfig(env) {
|
|
39
|
+
return join(env.CLAUDE_CONFIG_DIR || home(env), '.claude.json')
|
|
40
|
+
}
|
|
41
|
+
|
|
37
42
|
function opencodeDir(env) {
|
|
38
43
|
return join(env.XDG_CONFIG_HOME || join(home(env), '.config'), 'opencode')
|
|
39
44
|
}
|
|
@@ -54,6 +59,9 @@ function codexEntry(text) {
|
|
|
54
59
|
return { url: null }
|
|
55
60
|
}
|
|
56
61
|
|
|
62
|
+
/** Claude Code's own tools, denied during the check: allowing mcp__renor alone would still leave them to the person's own permission rules. */
|
|
63
|
+
const CLAUDE_BUILT_IN_TOOLS = 'Bash,Edit,MultiEdit,Write,NotebookEdit,Read,Glob,Grep,WebFetch,WebSearch,Task,TodoWrite'
|
|
64
|
+
|
|
57
65
|
export const CLIENTS = [
|
|
58
66
|
{
|
|
59
67
|
id: 'claude-code',
|
|
@@ -63,7 +71,7 @@ export const CLIENTS = [
|
|
|
63
71
|
return bin ? { found: true, version: versionOf('claude', ctx.env) } : { found: false }
|
|
64
72
|
},
|
|
65
73
|
current: (ctx) => {
|
|
66
|
-
const read = readJsonc(
|
|
74
|
+
const read = readJsonc(claudeConfig(ctx.env))
|
|
67
75
|
const entry = read.value?.mcpServers?.[SERVER_NAME]
|
|
68
76
|
return { readable: read.ok, configured: Boolean(entry), url: urlOf(entry) }
|
|
69
77
|
},
|
|
@@ -72,6 +80,8 @@ export const CLIENTS = [
|
|
|
72
80
|
const out = capture('claude', ['mcp', 'add', '--transport', 'http', '--scope', 'user', SERVER_NAME, GATEWAY_URL], { env: ctx.env })
|
|
73
81
|
return out.ok ? added() : failed((out.stderr || out.stdout || out.error || '').trim())
|
|
74
82
|
},
|
|
83
|
+
// The one app whose one-shot mode can be limited to Renor's tools (the others would approve shell and file edits).
|
|
84
|
+
check: (prompt) => ['claude', ['-p', prompt, '--allowedTools', `mcp__${SERVER_NAME}`, '--disallowedTools', CLAUDE_BUILT_IN_TOOLS]],
|
|
75
85
|
login: null,
|
|
76
86
|
signIn: 'Restart Claude Code, type /mcp, choose renor, then Authenticate.',
|
|
77
87
|
byHand: `claude mcp add --transport http --scope user ${SERVER_NAME} ${GATEWAY_URL}`,
|
|
@@ -149,14 +159,16 @@ export const CLIENTS = [
|
|
|
149
159
|
return existsSync(opencodeDir(ctx.env)) ? { found: true, version: null } : { found: false }
|
|
150
160
|
},
|
|
151
161
|
current: (ctx) => {
|
|
152
|
-
|
|
162
|
+
// OpenCode reads both files, so Renor in either one counts.
|
|
163
|
+
let readable = true
|
|
164
|
+
for (const name of ['opencode.jsonc', 'opencode.json']) {
|
|
153
165
|
const read = readJsonc(join(opencodeDir(ctx.env), name))
|
|
154
|
-
|
|
166
|
+
readable &&= read.ok
|
|
155
167
|
// OpenCode 1 keeps servers under `mcp`, OpenCode 2 under `mcp.servers`; it reads both.
|
|
156
168
|
const entry = read.value?.mcp?.servers?.[SERVER_NAME] ?? read.value?.mcp?.[SERVER_NAME]
|
|
157
|
-
return { readable: read.ok, configured:
|
|
169
|
+
if (entry) return { readable: read.ok, configured: true, url: urlOf(entry) }
|
|
158
170
|
}
|
|
159
|
-
return { readable
|
|
171
|
+
return { readable, configured: false, url: null }
|
|
160
172
|
},
|
|
161
173
|
add: (ctx, { replace }) => {
|
|
162
174
|
// OpenCode's own command keeps the file's comments and format; an older OpenCode without it gets a careful merge.
|
|
@@ -179,6 +191,32 @@ export const CLIENTS = [
|
|
|
179
191
|
signIn: 'Run opencode mcp auth renor, then restart OpenCode.',
|
|
180
192
|
byHand: `opencode mcp add ${SERVER_NAME} --url ${GATEWAY_URL} --global`,
|
|
181
193
|
},
|
|
194
|
+
{
|
|
195
|
+
id: 'gemini',
|
|
196
|
+
label: 'Gemini CLI',
|
|
197
|
+
detect: (ctx) => {
|
|
198
|
+
if (which('gemini', ctx.env)) return { found: true, version: versionOf('gemini', ctx.env) }
|
|
199
|
+
return existsSync(join(home(ctx.env), '.gemini')) ? { found: true, version: null } : { found: false }
|
|
200
|
+
},
|
|
201
|
+
current: (ctx) => {
|
|
202
|
+
const read = readJsonc(join(home(ctx.env), '.gemini', 'settings.json'))
|
|
203
|
+
const entry = read.value?.mcpServers?.[SERVER_NAME]
|
|
204
|
+
return { readable: read.ok, configured: Boolean(entry), url: urlOf(entry) }
|
|
205
|
+
},
|
|
206
|
+
// Gemini CLI reads `httpUrl` for a streamable-HTTP server (`url` would make it try SSE).
|
|
207
|
+
add: (ctx) => {
|
|
208
|
+
const path = join(home(ctx.env), '.gemini', 'settings.json')
|
|
209
|
+
const read = readJsonc(path)
|
|
210
|
+
if (!plainJson(read)) return failed(`${path} has comments or is not valid JSON, so it was left as it is.`)
|
|
211
|
+
const value = read.value && typeof read.value === 'object' ? read.value : {}
|
|
212
|
+
value.mcpServers = { ...(value.mcpServers || {}), [SERVER_NAME]: { httpUrl: GATEWAY_URL } }
|
|
213
|
+
const backup = writeJsonWithBackup(path, value)
|
|
214
|
+
return added(backup ? `The previous file is saved as ${backup}.` : null)
|
|
215
|
+
},
|
|
216
|
+
login: null,
|
|
217
|
+
signIn: 'Start Gemini CLI, type /mcp auth renor, and approve Renor in your browser.',
|
|
218
|
+
byHand: `Add { "mcpServers": { "${SERVER_NAME}": { "httpUrl": "${GATEWAY_URL}" } } } to ~/.gemini/settings.json`,
|
|
219
|
+
},
|
|
182
220
|
{
|
|
183
221
|
id: 'pi',
|
|
184
222
|
label: 'Pi',
|
|
@@ -230,8 +268,4 @@ export const CLIENTS = [
|
|
|
230
268
|
},
|
|
231
269
|
]
|
|
232
270
|
|
|
233
|
-
export
|
|
234
|
-
return CLIENTS.find((client) => client.id === id) ?? null
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
export { codexEntry, plainJson }
|
|
271
|
+
export { codexEntry }
|
package/lib/constants.mjs
CHANGED
|
@@ -16,7 +16,7 @@ export const SKILL_INSTALL_ARGS = [
|
|
|
16
16
|
'-y',
|
|
17
17
|
`skills@${SKILLS_CLI_VERSION}`,
|
|
18
18
|
'add',
|
|
19
|
-
`https://github.com/
|
|
19
|
+
`https://github.com/renorai/renor-skills/tree/${RENOR_SKILLS_COMMIT}`,
|
|
20
20
|
'--agent',
|
|
21
21
|
'*',
|
|
22
22
|
'-y',
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A small MCP client over Streamable HTTP, enough to call Renor's read tools: initialize, then tools/call. Plain fetch, no
|
|
3
|
+
* dependency. A reply may come as JSON or as an event stream (text/event-stream); both are read. The session id the server
|
|
4
|
+
* hands out on initialize (Mcp-Session-Id) is sent back on every later request.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const PROTOCOL_VERSION = '2025-06-18'
|
|
8
|
+
|
|
9
|
+
/** The JSON-RPC message answering request `id`, from a JSON body or from the `data:` lines of an event stream. */
|
|
10
|
+
function messageFor(body, contentType, id) {
|
|
11
|
+
const messages = []
|
|
12
|
+
if (contentType.includes('text/event-stream')) {
|
|
13
|
+
for (const event of body.split(/\r?\n\r?\n/)) {
|
|
14
|
+
const data = event.split(/\r?\n/).filter((line) => line.startsWith('data:')).map((line) => line.slice(5).replace(/^ /, '')).join('\n')
|
|
15
|
+
if (data.trim()) messages.push(JSON.parse(data))
|
|
16
|
+
}
|
|
17
|
+
} else if (body.trim()) {
|
|
18
|
+
const parsed = JSON.parse(body)
|
|
19
|
+
messages.push(...(Array.isArray(parsed) ? parsed : [parsed]))
|
|
20
|
+
}
|
|
21
|
+
return messages.find((message) => message && message.id === id)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* `connect({ url, token, fetchImpl })` → `{ callTool(name, args) }`. `callTool` returns the tool's text content parsed as
|
|
26
|
+
* JSON (Renor's read tools answer one JSON text block), and throws with the tool's own message when it answers isError.
|
|
27
|
+
*/
|
|
28
|
+
export async function connect({ url, token, fetchImpl = globalThis.fetch, clientName = 'renor-cli' }) {
|
|
29
|
+
let session = null
|
|
30
|
+
let nextId = 1
|
|
31
|
+
|
|
32
|
+
async function post(payload) {
|
|
33
|
+
const headers = { 'content-type': 'application/json', accept: 'application/json, text/event-stream', 'mcp-protocol-version': PROTOCOL_VERSION }
|
|
34
|
+
if (token) headers.authorization = `Bearer ${token}`
|
|
35
|
+
if (session) headers['mcp-session-id'] = session
|
|
36
|
+
const res = await fetchImpl(url, { method: 'POST', headers, body: JSON.stringify(payload) })
|
|
37
|
+
const sid = res.headers.get('mcp-session-id')
|
|
38
|
+
if (sid) session = sid
|
|
39
|
+
const body = await res.text()
|
|
40
|
+
if (res.status === 401) throw new Error(`Renor refused the key (401 from ${url}). Set RENOR_API_KEY to an API key from https://app.renor.cloud.`)
|
|
41
|
+
if (!res.ok && res.status !== 202) throw new Error(`Renor answered ${res.status} from ${url}: ${body.slice(0, 200)}`)
|
|
42
|
+
return { body, contentType: res.headers.get('content-type') || '' }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function request(method, params) {
|
|
46
|
+
const id = nextId++
|
|
47
|
+
const { body, contentType } = await post({ jsonrpc: '2.0', id, method, params })
|
|
48
|
+
const message = messageFor(body, contentType, id)
|
|
49
|
+
if (!message) throw new Error(`Renor sent no answer to ${method}`)
|
|
50
|
+
if (message.error) throw new Error(`Renor ${method} failed: ${message.error.message}`)
|
|
51
|
+
return message.result
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
await request('initialize', { protocolVersion: PROTOCOL_VERSION, capabilities: {}, clientInfo: { name: clientName, version: '1' } })
|
|
55
|
+
await post({ jsonrpc: '2.0', method: 'notifications/initialized' })
|
|
56
|
+
|
|
57
|
+
return {
|
|
58
|
+
async callTool(name, args = {}) {
|
|
59
|
+
const result = await request('tools/call', { name, arguments: args })
|
|
60
|
+
const text = (result.content || []).filter((part) => part.type === 'text').map((part) => part.text)[0] ?? ''
|
|
61
|
+
if (result.isError) throw new Error(`${name}: ${text.slice(0, 300)}`)
|
|
62
|
+
if (result.structuredContent) return result.structuredContent
|
|
63
|
+
return JSON.parse(text)
|
|
64
|
+
},
|
|
65
|
+
}
|
|
66
|
+
}
|
package/lib/mcp.mjs
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/** A minimal MCP client over HTTP: one session, tools/call only. The same handshake the dashboard import uses. */
|
|
2
|
+
|
|
3
|
+
const LOOPBACK = /^(127(\.\d{1,3}){3}|\[::1\])$/
|
|
4
|
+
|
|
5
|
+
/** The token goes only to https, or http on a literal loopback address (a name like localhost can be pointed elsewhere). */
|
|
6
|
+
export function checkUrl(value) {
|
|
7
|
+
let url
|
|
8
|
+
try { url = new URL(value) } catch { throw new Error(`RENOR_MCP_URL is not a URL: ${value}`) }
|
|
9
|
+
if (url.username || url.password) throw new Error('RENOR_MCP_URL must not carry a user name or password.')
|
|
10
|
+
if (url.protocol !== 'https:' && !(url.protocol === 'http:' && LOOPBACK.test(url.hostname))) {
|
|
11
|
+
throw new Error('RENOR_MCP_URL must be https (http only for 127.0.0.1 or [::1]); the token is not sent anywhere else.')
|
|
12
|
+
}
|
|
13
|
+
return url.href
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Opens a session at `url` with a connect token and returns `{ call, close }`. `call` resolves to the tool's JSON or throws. */
|
|
17
|
+
export async function openSession(target, token, fetchImpl = fetch) {
|
|
18
|
+
const url = checkUrl(target)
|
|
19
|
+
const headers = { authorization: `Bearer ${token}`, 'content-type': 'application/json', accept: 'application/json, text/event-stream' }
|
|
20
|
+
const post = (body) => fetchImpl(url, { method: 'POST', headers, body: JSON.stringify(body), redirect: 'error' })
|
|
21
|
+
const init = await post({
|
|
22
|
+
jsonrpc: '2.0', id: 1, method: 'initialize',
|
|
23
|
+
params: { protocolVersion: '2025-03-26', capabilities: {}, clientInfo: { name: 'renor-sync', version: '1' } },
|
|
24
|
+
})
|
|
25
|
+
if (init.status === 401) throw new Error('Renor did not accept the token (RENOR_TOKEN). Make a new connect token in the dashboard.')
|
|
26
|
+
if (!init.ok) throw new Error(`Renor answered ${init.status} to the first request.`)
|
|
27
|
+
await reply(init, 1)
|
|
28
|
+
const session = init.headers.get('mcp-session-id')
|
|
29
|
+
if (session) headers['mcp-session-id'] = session
|
|
30
|
+
await (await post({ jsonrpc: '2.0', method: 'notifications/initialized' })).text()
|
|
31
|
+
let id = 1
|
|
32
|
+
return {
|
|
33
|
+
async call(tool, args) {
|
|
34
|
+
const n = ++id
|
|
35
|
+
const res = await post({ jsonrpc: '2.0', id: n, method: 'tools/call', params: { name: tool, arguments: args } })
|
|
36
|
+
if (!res.ok) throw new Error(`${tool}: Renor answered ${res.status}.`)
|
|
37
|
+
const message = await reply(res, n)
|
|
38
|
+
if (message.error) throw new Error(`${tool}: ${message.error.message}`)
|
|
39
|
+
const text = message.result?.content?.find((part) => part?.type === 'text')?.text ?? ''
|
|
40
|
+
if (message.result?.isError) throw new Error(`${tool}: ${text.slice(0, 300) || 'refused'}`)
|
|
41
|
+
return JSON.parse(text)
|
|
42
|
+
},
|
|
43
|
+
async close() {
|
|
44
|
+
if (session) await fetchImpl(url, { method: 'DELETE', headers, redirect: 'error' }).then((r) => r.text(), () => {})
|
|
45
|
+
},
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function reply(res, id) {
|
|
50
|
+
const text = await res.text()
|
|
51
|
+
const events = (res.headers.get('content-type') ?? '').includes('text/event-stream')
|
|
52
|
+
? text.split(/\r?\n/).filter((line) => line.startsWith('data:')).map((line) => line.slice(5))
|
|
53
|
+
: [text]
|
|
54
|
+
for (const event of events) {
|
|
55
|
+
try {
|
|
56
|
+
for (const message of [JSON.parse(event)].flat()) if (message?.id === id) return message
|
|
57
|
+
} catch { /* not JSON */ }
|
|
58
|
+
}
|
|
59
|
+
throw new Error('Renor sent no usable reply.')
|
|
60
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `renor review-rules`: the owner's standing rules for a review prompt (default), a marked section of a guidelines file
|
|
3
|
+
* (--export), or proposed rules from past reviews (--propose, which never reaches Renor).
|
|
4
|
+
*/
|
|
5
|
+
import { existsSync, readFileSync, readdirSync, writeFileSync } from 'node:fs'
|
|
6
|
+
import { join } from 'node:path'
|
|
7
|
+
import { GATEWAY_URL } from './constants.mjs'
|
|
8
|
+
import { connect } from './mcp-client.mjs'
|
|
9
|
+
import { fetchRules } from './review-rules-source.mjs'
|
|
10
|
+
import {
|
|
11
|
+
DEFAULT_EXPORT_MAX_TOKENS, DEFAULT_MAX_TOKENS, buildReviewRulesBlock, formatProposals, proposeRules, renderRulesSection, updateMarkedSection,
|
|
12
|
+
} from './review-rules.mjs'
|
|
13
|
+
|
|
14
|
+
export const REVIEW_RULES_HELP = `renor review-rules — the owner's standing rules from Renor, for a code review.
|
|
15
|
+
|
|
16
|
+
Usage
|
|
17
|
+
gh pr diff 123 | npx renor review-rules Print the rules that bear on the diff, to put at the top of a review prompt
|
|
18
|
+
npx renor review-rules --diff change.patch The same, from a file
|
|
19
|
+
npx renor review-rules --export [REVIEW.md] Write the rules into one marked section of REVIEW.md (or AGENTS.md)
|
|
20
|
+
npx renor review-rules --propose reviews/ Propose rules from recurring findings in past reviews (Markdown); saves nothing
|
|
21
|
+
|
|
22
|
+
Options
|
|
23
|
+
--diff <file|-> The diff to match rules against (default: standard input when it is not a terminal)
|
|
24
|
+
--max-tokens <n> Cap the block at about n tokens (default ${DEFAULT_MAX_TOKENS}; ${DEFAULT_EXPORT_MAX_TOKENS} for --export)
|
|
25
|
+
--url <url> Renor's MCP address (default RENOR_URL, else ${GATEWAY_URL}); a local engine is http://127.0.0.1:3471/mcp
|
|
26
|
+
--rules-file <json> Read the rules from a JSON file (an array of rules) instead of Renor
|
|
27
|
+
--min-reviews <n> --propose: how many reviews a finding must recur in (default 2)
|
|
28
|
+
--json Print JSON (the block, or the proposals) instead of text
|
|
29
|
+
|
|
30
|
+
The key is RENOR_API_KEY (an API key from https://app.renor.cloud). Every call to Renor is a read.`
|
|
31
|
+
|
|
32
|
+
export function parseReviewRules(argv) {
|
|
33
|
+
const options = { diff: null, maxTokens: null, url: null, rulesFile: null, export: null, propose: null, minReviews: 2, json: false, help: false, unknown: [] }
|
|
34
|
+
for (let i = 0; i < argv.length; i++) {
|
|
35
|
+
const arg = argv[i]
|
|
36
|
+
const value = () => {
|
|
37
|
+
const eq = arg.indexOf('=')
|
|
38
|
+
return eq > 0 ? arg.slice(eq + 1) : argv[++i]
|
|
39
|
+
}
|
|
40
|
+
const name = arg.includes('=') ? arg.slice(0, arg.indexOf('=')) : arg
|
|
41
|
+
if (name === '--diff') options.diff = value()
|
|
42
|
+
else if (name === '--max-tokens') options.maxTokens = Number(value())
|
|
43
|
+
else if (name === '--url') options.url = value()
|
|
44
|
+
else if (name === '--rules-file') options.rulesFile = value()
|
|
45
|
+
else if (name === '--min-reviews') options.minReviews = Number(value())
|
|
46
|
+
else if (name === '--propose') options.propose = value()
|
|
47
|
+
else if (name === '--export') {
|
|
48
|
+
if (arg.includes('=')) options.export = value()
|
|
49
|
+
else options.export = argv[i + 1] && !argv[i + 1].startsWith('-') ? argv[++i] : ''
|
|
50
|
+
} else if (arg === '--json') options.json = true
|
|
51
|
+
else if (arg === '-h' || arg === '--help') options.help = true
|
|
52
|
+
else options.unknown.push(arg)
|
|
53
|
+
}
|
|
54
|
+
return options
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function readStdin() {
|
|
58
|
+
try {
|
|
59
|
+
return readFileSync(0, 'utf8')
|
|
60
|
+
} catch {
|
|
61
|
+
return ''
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The rules: from --rules-file, or from Renor. */
|
|
66
|
+
async function loadRules(options, { env, diff, err, fetchImpl }) {
|
|
67
|
+
if (options.rulesFile) return JSON.parse(readFileSync(options.rulesFile, 'utf8'))
|
|
68
|
+
const url = options.url || env.RENOR_URL || GATEWAY_URL
|
|
69
|
+
const token = env.RENOR_API_KEY || ''
|
|
70
|
+
if (!token && url === GATEWAY_URL) throw new Error('Set RENOR_API_KEY to an API key from https://app.renor.cloud (or pass --url for a local engine, or --rules-file).')
|
|
71
|
+
const client = await connect({ url, token, fetchImpl })
|
|
72
|
+
return fetchRules(client, { diff, warn: (message) => err.write(`renor review-rules: ${message}\n`) })
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Runs `renor review-rules` with `argv` (the words after the command). Returns the exit code. `io` is for tests:
|
|
77
|
+
* { env, out, err, stdin: () => string, stdinIsTTY, cwd, fetchImpl }.
|
|
78
|
+
*/
|
|
79
|
+
export async function reviewRules(argv, io = {}) {
|
|
80
|
+
const env = io.env || process.env
|
|
81
|
+
const out = io.out || process.stdout
|
|
82
|
+
const err = io.err || process.stderr
|
|
83
|
+
const cwd = io.cwd || process.cwd()
|
|
84
|
+
const options = parseReviewRules(argv)
|
|
85
|
+
if (options.help) {
|
|
86
|
+
out.write(REVIEW_RULES_HELP + '\n')
|
|
87
|
+
return 0
|
|
88
|
+
}
|
|
89
|
+
if (options.unknown.length > 0) {
|
|
90
|
+
err.write(`Unknown option: ${options.unknown.join(' ')}\n\n${REVIEW_RULES_HELP}\n`)
|
|
91
|
+
return 2
|
|
92
|
+
}
|
|
93
|
+
if (options.maxTokens !== null && !(Number.isInteger(options.maxTokens) && options.maxTokens > 0)) {
|
|
94
|
+
err.write('--max-tokens takes a whole number above 0\n')
|
|
95
|
+
return 2
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (options.propose !== null) {
|
|
99
|
+
// Reads the folder only: no connection to Renor is opened in this mode, so it cannot save anything there.
|
|
100
|
+
const dir = options.propose || '.'
|
|
101
|
+
const reviews = readdirSync(dir).filter((name) => name.toLowerCase().endsWith('.md')).sort()
|
|
102
|
+
.map((name) => ({ name, text: readFileSync(join(dir, name), 'utf8') }))
|
|
103
|
+
const proposals = proposeRules(reviews, { minReviews: Number.isInteger(options.minReviews) && options.minReviews > 0 ? options.minReviews : 2 })
|
|
104
|
+
out.write(options.json ? JSON.stringify(proposals, null, 2) + '\n' : formatProposals(proposals, reviews.length))
|
|
105
|
+
return 0
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
let diff = ''
|
|
109
|
+
if (options.diff === '-') diff = (io.stdin || readStdin)()
|
|
110
|
+
else if (options.diff) diff = readFileSync(options.diff, 'utf8')
|
|
111
|
+
else if (options.export === null && !(io.stdinIsTTY ?? process.stdin.isTTY)) diff = (io.stdin || readStdin)()
|
|
112
|
+
if (options.export === null && diff.trim() === '') {
|
|
113
|
+
err.write('No diff: pipe one in (gh pr diff 123 | npx renor review-rules) or pass --diff <file>.\n')
|
|
114
|
+
return 2
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const rules = await loadRules(options, { env, diff, err, fetchImpl: io.fetchImpl })
|
|
118
|
+
|
|
119
|
+
if (options.export !== null) {
|
|
120
|
+
const target = options.export || (existsSync(join(cwd, 'REVIEW.md')) || !existsSync(join(cwd, 'AGENTS.md')) ? 'REVIEW.md' : 'AGENTS.md')
|
|
121
|
+
const path = join(cwd, target)
|
|
122
|
+
const before = existsSync(path) ? readFileSync(path, 'utf8') : ''
|
|
123
|
+
const { section, included, omitted } = renderRulesSection({ rules, diff, maxTokens: options.maxTokens ?? DEFAULT_EXPORT_MAX_TOKENS })
|
|
124
|
+
const after = updateMarkedSection(before, section)
|
|
125
|
+
if (after === before) {
|
|
126
|
+
out.write(`${target}: unchanged (${included.length} rule(s)).\n`)
|
|
127
|
+
return 0
|
|
128
|
+
}
|
|
129
|
+
writeFileSync(path, after)
|
|
130
|
+
out.write(`${target}: wrote ${included.length} rule(s) into the Renor section${omitted.length ? `; ${omitted.length} more did not fit --max-tokens` : ''}. Nothing outside the section changed.\n`)
|
|
131
|
+
return 0
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const { block, included, omitted } = buildReviewRulesBlock({ rules, diff, maxTokens: options.maxTokens ?? DEFAULT_MAX_TOKENS })
|
|
135
|
+
if (options.json) {
|
|
136
|
+
out.write(JSON.stringify({ block, included: included.map((rule) => rule.id), omitted: omitted.map((rule) => rule.id) }, null, 2) + '\n')
|
|
137
|
+
return 0
|
|
138
|
+
}
|
|
139
|
+
out.write(block)
|
|
140
|
+
err.write(block
|
|
141
|
+
? `renor review-rules: ${included.length} rule(s) quoted${omitted.length ? `, ${omitted.length} left out by --max-tokens` : ''}.\n`
|
|
142
|
+
: 'renor review-rules: no current rule bears on this diff; nothing printed.\n')
|
|
143
|
+
return 0
|
|
144
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where `renor review-rules` gets the owner's rules: Renor's own read tools, over MCP, hosted or local. No engine tool was
|
|
3
|
+
* added for this; the existing ones are enough:
|
|
4
|
+
*
|
|
5
|
+
* memory_map (no query) the newest facts, history included: the subjects that hold a rule-like predicate
|
|
6
|
+
* get_context (the diff) current facts the diff's words reach, beyond the newest ones: more such subjects
|
|
7
|
+
* retrieve_memory (subject) each subject's facts, every one marked current or superseded, with its event id and date,
|
|
8
|
+
* its value whole (get_context may cut a value, and gives no id)
|
|
9
|
+
* memory_map (rule words) plain-words memories that hold "always", "never", "prefer", "rule", ...
|
|
10
|
+
* read_memory (id) each such memory's full text, and whether a later correction replaced it
|
|
11
|
+
*
|
|
12
|
+
* Only `current: true` facts and memories nobody replaced are kept, so a superseded value never reaches the block. Every
|
|
13
|
+
* call is a read: nothing here saves, retires or changes anything in Renor.
|
|
14
|
+
*/
|
|
15
|
+
import { diffFiles, isRulePredicate, looksLikeRule, terms } from './review-rules.mjs'
|
|
16
|
+
|
|
17
|
+
const RULE_WORDS = 'rule always never must prefer avoid policy decision decided convention guideline'
|
|
18
|
+
|
|
19
|
+
/** The question get_context is asked: the changed files and the diff's most frequent words, inside its 1000-character limit. */
|
|
20
|
+
export function contextQuery(diff) {
|
|
21
|
+
const counts = new Map()
|
|
22
|
+
for (const line of String(diff || '').split('\n')) {
|
|
23
|
+
if (!/^[+-]/.test(line) || /^(\+\+\+|---) /.test(line)) continue
|
|
24
|
+
for (const t of terms(line.slice(1))) counts.set(t, (counts.get(t) || 0) + 1)
|
|
25
|
+
}
|
|
26
|
+
const top = [...counts].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).slice(0, 30).map(([t]) => t)
|
|
27
|
+
return `Standing rules, preferences and decisions for a code review of changes to ${diffFiles(diff).slice(0, 20).join(', ')}. Topics: ${top.join(' ')}`.slice(0, 1000)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const day = (iso) => (typeof iso === 'string' ? iso.slice(0, 10) : undefined)
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* fetchRules(client, { diff?, maxSubjects = 40, maxMemories = 40, warn? }) → Rule[]
|
|
34
|
+
*
|
|
35
|
+
* `client.callTool(name, args)` answers a Renor tool's JSON (mcp-client.mjs). A tool that fails is skipped with a warning
|
|
36
|
+
* (`warn(message)`), so one refused read leaves the rest of the rules; nothing is guessed in its place.
|
|
37
|
+
*/
|
|
38
|
+
export async function fetchRules(client, { diff = '', maxSubjects = 40, maxMemories = 40, warn = () => {} } = {}) {
|
|
39
|
+
const call = async (name, args) => {
|
|
40
|
+
try {
|
|
41
|
+
return await client.callTool(name, args)
|
|
42
|
+
} catch (error) {
|
|
43
|
+
warn(`${name} failed: ${error instanceof Error ? error.message : String(error)}`)
|
|
44
|
+
return null
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const subjects = []
|
|
49
|
+
const addSubject = (subject) => {
|
|
50
|
+
if (typeof subject === 'string' && subject.trim() !== '' && !subjects.includes(subject)) subjects.push(subject)
|
|
51
|
+
}
|
|
52
|
+
const map = await call('memory_map', { limit: 200 })
|
|
53
|
+
for (const fact of map?.facts || []) if (isRulePredicate(fact.predicate)) addSubject(fact.subject)
|
|
54
|
+
if (String(diff).trim() !== '') {
|
|
55
|
+
const context = await call('get_context', { query: contextQuery(diff), max_tokens: 4000 })
|
|
56
|
+
for (const fact of context?.facts || []) if (isRulePredicate(fact.predicate) && fact.untrusted !== true) addSubject(fact.subject)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const rules = []
|
|
60
|
+
const seen = new Set()
|
|
61
|
+
for (const subject of subjects.slice(0, maxSubjects)) {
|
|
62
|
+
const read = await call('retrieve_memory', { subject })
|
|
63
|
+
for (const fact of read?.facts || []) {
|
|
64
|
+
if (fact.current !== true || fact.validTo || fact.untrusted === true || !isRulePredicate(fact.predicate)) continue
|
|
65
|
+
if (!fact.eventId || seen.has(fact.eventId)) continue
|
|
66
|
+
seen.add(fact.eventId)
|
|
67
|
+
rules.push({ id: fact.eventId, date: day(fact.validFrom), text: fact.object, kind: 'fact', subject, predicate: fact.predicate, scope: fact.scope, current: true })
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const found = await call('memory_map', { query: RULE_WORDS, limit: 200 })
|
|
72
|
+
const candidates = (found?.memories || []).filter((memory) => memory.untrusted !== true && memory.origin !== 'import')
|
|
73
|
+
for (const memory of candidates.slice(0, maxMemories)) {
|
|
74
|
+
if (seen.has(memory.id)) continue
|
|
75
|
+
const read = await call('read_memory', { id: memory.id })
|
|
76
|
+
if (!read || read.ok === false || read.replaced || read.textTruncated || typeof read.text !== 'string') continue
|
|
77
|
+
const detail = read.memory || memory
|
|
78
|
+
if (detail.untrusted === true || detail.origin === 'import' || !looksLikeRule(read.text)) continue
|
|
79
|
+
seen.add(memory.id)
|
|
80
|
+
rules.push({ id: memory.id, date: day(detail.session_date || detail.at), text: read.text, kind: 'memory', current: true })
|
|
81
|
+
}
|
|
82
|
+
return rules
|
|
83
|
+
}
|