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 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. At the end it lists anything left for you to do, app by app.
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[++i] || '').split(',').map((id) => id.trim()).filter(Boolean)
31
- else if (arg.startsWith('--only=')) options.only = arg.slice(7).split(',').map((id) => id.trim()).filter(Boolean)
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(join(home(ctx.env), '.claude.json'))
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
- for (const name of ['opencode.json', 'opencode.jsonc']) {
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
- if (!read.exists) continue
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: Boolean(entry), url: urlOf(entry) }
169
+ if (entry) return { readable: read.ok, configured: true, url: urlOf(entry) }
158
170
  }
159
- return { readable: true, configured: false, url: null }
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 function findClient(id) {
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/patrickpassosb/renor-skills/tree/${RENOR_SKILLS_COMMIT}`,
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
+ }