renor 0.0.0-stage → 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/LICENSE +21 -0
- package/README.md +77 -2
- package/bin/renor.mjs +67 -0
- package/lib/args.mjs +48 -0
- package/lib/clients.mjs +271 -0
- package/lib/constants.mjs +27 -0
- 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 +256 -0
- package/lib/sharing-gate.mjs +42 -0
- package/lib/sharing.mjs +114 -0
- package/lib/sync.mjs +249 -0
- package/lib/system.mjs +202 -0
- package/lib/verify.mjs +153 -0
- package/package.json +35 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Renor
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,78 @@
|
|
|
1
|
-
#
|
|
1
|
+
# renor
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Connects the AI apps on your computer to [Renor](https://renor.cloud), so they share one memory: tell one of them something, and the others know it too.
|
|
4
|
+
|
|
5
|
+
```sh
|
|
6
|
+
npx renor setup
|
|
7
|
+
```
|
|
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. 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.
|
|
12
|
+
|
|
13
|
+
| App | How Renor is added | Sign-in |
|
|
14
|
+
|---|---|---|
|
|
15
|
+
| Claude Code | `claude mcp add --transport http --scope user renor …` | in Claude Code: `/mcp`, then Authenticate |
|
|
16
|
+
| Codex | `codex mcp add renor --url …` | opens your browser right away |
|
|
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 |
|
|
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` |
|
|
20
|
+
| Pi (0.99 or later) | `pi mcp add renor --url … --exposure direct` | `pi mcp login renor` |
|
|
21
|
+
| VS Code | `code --add-mcp …` | when Copilot first uses Renor |
|
|
22
|
+
|
|
23
|
+
Every app gets the same address, `https://mcp.renor.cloud/mcp`. No key is stored anywhere: you approve Renor in your browser, and each app keeps its own sign-in.
|
|
24
|
+
|
|
25
|
+
## What it does not do
|
|
26
|
+
|
|
27
|
+
- It never changes anything else in an app's config. A file with comments in it is left alone and you get the line to add by hand.
|
|
28
|
+
- If an app already has a server named `renor` that points somewhere else, it is left as it is unless you pass `--force`.
|
|
29
|
+
- It sends nothing anywhere itself. The only network use is the apps' own sign-ins and `npx skills` installing the skill.
|
|
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
|
+
|
|
35
|
+
## Options
|
|
36
|
+
|
|
37
|
+
```
|
|
38
|
+
--dry-run Say what would happen and change nothing
|
|
39
|
+
-y, --yes Do not ask before adding
|
|
40
|
+
--no-login Add Renor but leave the sign-ins for later
|
|
41
|
+
--no-skill Do not install the skill
|
|
42
|
+
--verify Also have Claude Code save and recall one test memory
|
|
43
|
+
--force Replace a renor server that points somewhere else
|
|
44
|
+
--only <apps> Only some apps: claude-code, codex, cursor, opencode, gemini, pi, vscode
|
|
45
|
+
```
|
|
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
|
+
|
|
66
|
+
ChatGPT and Claude in the browser are set up from your dashboard at https://app.renor.cloud.
|
|
67
|
+
|
|
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
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* renor — connects the AI apps on this computer to Renor, the memory they share.
|
|
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>]
|
|
6
|
+
*/
|
|
7
|
+
import { readFileSync } from 'node:fs'
|
|
8
|
+
import { HELP, parse } from '../lib/args.mjs'
|
|
9
|
+
import { CLIENTS } from '../lib/clients.mjs'
|
|
10
|
+
import { reviewRules } from '../lib/review-rules-cli.mjs'
|
|
11
|
+
import { setup } from '../lib/setup.mjs'
|
|
12
|
+
import { sync } from '../lib/sync.mjs'
|
|
13
|
+
|
|
14
|
+
function version() {
|
|
15
|
+
try {
|
|
16
|
+
return JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version
|
|
17
|
+
} catch {
|
|
18
|
+
return 'unknown'
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
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))
|
|
25
|
+
const options = parse(process.argv.slice(2))
|
|
26
|
+
if (options.version) {
|
|
27
|
+
process.stdout.write(version() + '\n')
|
|
28
|
+
return 0
|
|
29
|
+
}
|
|
30
|
+
if (options.help || !options.command) {
|
|
31
|
+
process.stdout.write(HELP + '\n')
|
|
32
|
+
return 0
|
|
33
|
+
}
|
|
34
|
+
if (options.command === 'sync') return sync(process.argv.slice(3))
|
|
35
|
+
if (options.command !== 'setup') {
|
|
36
|
+
process.stderr.write(`Unknown command: ${options.command}\n\n${HELP}\n`)
|
|
37
|
+
return 2
|
|
38
|
+
}
|
|
39
|
+
if (options.unknown.length > 0) {
|
|
40
|
+
process.stderr.write(`Unknown option: ${options.unknown.join(' ')}\n\n${HELP}\n`)
|
|
41
|
+
return 2
|
|
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
|
+
}
|
|
47
|
+
const unknownApps = (options.only || []).filter((id) => !CLIENTS.some((client) => client.id === id))
|
|
48
|
+
if (unknownApps.length > 0) {
|
|
49
|
+
process.stderr.write(`Unknown app: ${unknownApps.join(', ')}. Known: ${CLIENTS.map((client) => client.id).join(', ')}\n`)
|
|
50
|
+
return 2
|
|
51
|
+
}
|
|
52
|
+
const { code } = await setup(options)
|
|
53
|
+
return code
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const [major] = process.versions.node.split('.').map(Number)
|
|
57
|
+
if (major < 18) {
|
|
58
|
+
process.stderr.write(`renor needs Node.js 18 or later; this is ${process.versions.node}.\n`)
|
|
59
|
+
process.exit(1)
|
|
60
|
+
}
|
|
61
|
+
main().then(
|
|
62
|
+
(code) => process.exit(code),
|
|
63
|
+
(error) => {
|
|
64
|
+
process.stderr.write(`renor ${process.argv[2] === 'review-rules' ? 'review-rules' : 'setup'} failed: ${error instanceof Error ? error.message : String(error)}\n`)
|
|
65
|
+
process.exit(1)
|
|
66
|
+
},
|
|
67
|
+
)
|
package/lib/args.mjs
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/** The command line of `renor`: what it accepts, and its help text. */
|
|
2
|
+
import { CLIENTS } from './clients.mjs'
|
|
3
|
+
|
|
4
|
+
export const HELP = `renor — connect your AI apps to Renor, the memory they share.
|
|
5
|
+
|
|
6
|
+
Usage
|
|
7
|
+
npx renor setup Find your AI apps, add Renor to each, sign in, install the skill, check that Renor answers
|
|
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)
|
|
12
|
+
|
|
13
|
+
Options
|
|
14
|
+
-y, --yes Do not ask before adding
|
|
15
|
+
--no-login Add Renor but leave the sign-ins for later
|
|
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)
|
|
18
|
+
--force Replace a renor server that points somewhere else
|
|
19
|
+
--only <apps> Only these apps: ${CLIENTS.map((client) => client.id).join(', ')}
|
|
20
|
+
-h, --help Show this help
|
|
21
|
+
-v, --version Show the version
|
|
22
|
+
|
|
23
|
+
ChatGPT and Claude in the browser are set up from https://app.renor.cloud.`
|
|
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
|
+
|
|
30
|
+
export function parse(argv) {
|
|
31
|
+
const options = { command: null, yes: false, dryRun: false, login: true, skill: true, verify: false, force: false, only: null, help: false, version: false, unknown: [] }
|
|
32
|
+
for (let i = 0; i < argv.length; i++) {
|
|
33
|
+
const arg = argv[i]
|
|
34
|
+
if (arg === '-y' || arg === '--yes') options.yes = true
|
|
35
|
+
else if (arg === '--dry-run') options.dryRun = true
|
|
36
|
+
else if (arg === '--no-login') options.login = false
|
|
37
|
+
else if (arg === '--no-skill') options.skill = false
|
|
38
|
+
else if (arg === '--verify') options.verify = true
|
|
39
|
+
else if (arg === '--force') options.force = true
|
|
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))
|
|
42
|
+
else if (arg === '-h' || arg === '--help') options.help = true
|
|
43
|
+
else if (arg === '-v' || arg === '--version') options.version = true
|
|
44
|
+
else if (!arg.startsWith('-') && !options.command) options.command = arg
|
|
45
|
+
else options.unknown.push(arg)
|
|
46
|
+
}
|
|
47
|
+
return options
|
|
48
|
+
}
|
package/lib/clients.mjs
ADDED
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One adapter per AI app `renor setup` knows. Each says how to find the app, whether Renor is already in its config,
|
|
3
|
+
* how to add it (the app's own command where it has one, a careful file merge where it does not), and how to sign in.
|
|
4
|
+
*
|
|
5
|
+
* The commands are the ones the dashboard's setup prompt gives people (src/lib/landing-setup-prompt.ts). What each
|
|
6
|
+
* app does with them was checked against the real apps on 2026-10-19 in a throwaway home folder: Claude Code 2.1.303,
|
|
7
|
+
* Codex 0.163.0, OpenCode 2.0.32, Pi 1.3.0 and cursor-agent 2026.10.16.
|
|
8
|
+
*/
|
|
9
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync } from 'node:fs'
|
|
10
|
+
import { join, dirname } from 'node:path'
|
|
11
|
+
import { GATEWAY_URL, SERVER_NAME } from './constants.mjs'
|
|
12
|
+
import { atLeast, backupFile, capture, home, interactive, readJsonc, stripJsonComments, stripTrailingCommas, versionOf, which, writeJsonWithBackup } from './system.mjs'
|
|
13
|
+
|
|
14
|
+
/** What an adapter's add reports. */
|
|
15
|
+
const added = (detail = null, signedIn = false) => ({ ok: true, detail, signedIn })
|
|
16
|
+
const failed = (detail) => ({ ok: false, detail, signedIn: false })
|
|
17
|
+
|
|
18
|
+
/** The URL a config entry points at, from the shapes the apps use (`url`, or `serverUrl` in older files). */
|
|
19
|
+
function urlOf(entry) {
|
|
20
|
+
if (!entry || typeof entry !== 'object') return null
|
|
21
|
+
return typeof entry.url === 'string' ? entry.url : typeof entry.httpUrl === 'string' ? entry.httpUrl : typeof entry.serverUrl === 'string' ? entry.serverUrl : null
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** A JSON file we may rewrite: it parsed, and it held no comments that a rewrite would drop. */
|
|
25
|
+
function plainJson(read) {
|
|
26
|
+
if (!read.ok) return false
|
|
27
|
+
if (!read.exists || !read.text) return true
|
|
28
|
+
return stripJsonComments(read.text).trim() === stripTrailingCommas(read.text).trim()
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function vscodeUserDir(env) {
|
|
32
|
+
if (process.platform === 'darwin') return join(home(env), 'Library', 'Application Support', 'Code', 'User')
|
|
33
|
+
if (process.platform === 'win32') return join(env.APPDATA || join(home(env), 'AppData', 'Roaming'), 'Code', 'User')
|
|
34
|
+
return join(env.XDG_CONFIG_HOME || join(home(env), '.config'), 'Code', 'User')
|
|
35
|
+
}
|
|
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
|
+
|
|
42
|
+
function opencodeDir(env) {
|
|
43
|
+
return join(env.XDG_CONFIG_HOME || join(home(env), '.config'), 'opencode')
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function codexConfig(env) {
|
|
47
|
+
return join(env.CODEX_HOME || join(home(env), '.codex'), 'config.toml')
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Renor's table in Codex's config.toml, if any, and the url it holds. */
|
|
51
|
+
function codexEntry(text) {
|
|
52
|
+
const lines = text.split(/\r?\n/)
|
|
53
|
+
const start = lines.findIndex((line) => /^\s*\[\s*mcp_servers\s*\.\s*(?:renor|"renor"|'renor')\s*\]\s*(#.*)?$/.test(line))
|
|
54
|
+
if (start < 0) return null
|
|
55
|
+
for (let i = start + 1; i < lines.length && !/^\s*\[/.test(lines[i]); i++) {
|
|
56
|
+
const match = lines[i].match(/^\s*url\s*=\s*"([^"]*)"/)
|
|
57
|
+
if (match) return { url: match[1] }
|
|
58
|
+
}
|
|
59
|
+
return { url: null }
|
|
60
|
+
}
|
|
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
|
+
|
|
65
|
+
export const CLIENTS = [
|
|
66
|
+
{
|
|
67
|
+
id: 'claude-code',
|
|
68
|
+
label: 'Claude Code',
|
|
69
|
+
detect: (ctx) => {
|
|
70
|
+
const bin = which('claude', ctx.env)
|
|
71
|
+
return bin ? { found: true, version: versionOf('claude', ctx.env) } : { found: false }
|
|
72
|
+
},
|
|
73
|
+
current: (ctx) => {
|
|
74
|
+
const read = readJsonc(claudeConfig(ctx.env))
|
|
75
|
+
const entry = read.value?.mcpServers?.[SERVER_NAME]
|
|
76
|
+
return { readable: read.ok, configured: Boolean(entry), url: urlOf(entry) }
|
|
77
|
+
},
|
|
78
|
+
add: (ctx, { replace }) => {
|
|
79
|
+
if (replace) capture('claude', ['mcp', 'remove', SERVER_NAME, '-s', 'user'], { env: ctx.env })
|
|
80
|
+
const out = capture('claude', ['mcp', 'add', '--transport', 'http', '--scope', 'user', SERVER_NAME, GATEWAY_URL], { env: ctx.env })
|
|
81
|
+
return out.ok ? added() : failed((out.stderr || out.stdout || out.error || '').trim())
|
|
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]],
|
|
85
|
+
login: null,
|
|
86
|
+
signIn: 'Restart Claude Code, type /mcp, choose renor, then Authenticate.',
|
|
87
|
+
byHand: `claude mcp add --transport http --scope user ${SERVER_NAME} ${GATEWAY_URL}`,
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
id: 'codex',
|
|
91
|
+
label: 'Codex',
|
|
92
|
+
detect: (ctx) => (which('codex', ctx.env) ? { found: true, version: versionOf('codex', ctx.env) } : { found: false }),
|
|
93
|
+
current: (ctx) => {
|
|
94
|
+
const path = codexConfig(ctx.env)
|
|
95
|
+
if (!existsSync(path)) return { readable: true, configured: false, url: null }
|
|
96
|
+
try {
|
|
97
|
+
const entry = codexEntry(readFileSync(path, 'utf8'))
|
|
98
|
+
return { readable: true, configured: Boolean(entry), url: entry?.url ?? null }
|
|
99
|
+
} catch {
|
|
100
|
+
return { readable: false, configured: false, url: null }
|
|
101
|
+
}
|
|
102
|
+
},
|
|
103
|
+
// `codex mcp add` writes the config, then starts the browser sign-in and waits for it. Run in front of the person
|
|
104
|
+
// that is the whole setup; where nobody can approve a sign-in, the table is written directly instead, so nothing waits.
|
|
105
|
+
add: (ctx, { replace }) => {
|
|
106
|
+
if (replace) capture('codex', ['mcp', 'remove', SERVER_NAME], { env: ctx.env })
|
|
107
|
+
if (ctx.canSignIn) {
|
|
108
|
+
ctx.say(`Adding Renor to Codex. Codex opens your browser: sign in to Renor and approve.`)
|
|
109
|
+
const status = interactive('codex', ['mcp', 'add', SERVER_NAME, '--url', GATEWAY_URL], { env: ctx.env })
|
|
110
|
+
const nowThere = existsSync(codexConfig(ctx.env)) && codexEntry(readFileSync(codexConfig(ctx.env), 'utf8'))
|
|
111
|
+
if (status === 0) return added(null, true)
|
|
112
|
+
return nowThere ? added('The sign-in did not finish.') : failed(`codex mcp add exited with ${status}`)
|
|
113
|
+
}
|
|
114
|
+
const path = codexConfig(ctx.env)
|
|
115
|
+
mkdirSync(dirname(path), { recursive: true })
|
|
116
|
+
const before = existsSync(path) ? readFileSync(path, 'utf8') : ''
|
|
117
|
+
// A renor table that is still there (the remove above failed) would be written twice, which Codex cannot read.
|
|
118
|
+
if (codexEntry(before)) return failed(`${path} still has a [mcp_servers.${SERVER_NAME}] table that could not be removed, so it was left as it is.`)
|
|
119
|
+
const backup = before ? backupFile(path) : null
|
|
120
|
+
const table = `[mcp_servers.${SERVER_NAME}]\nurl = "${GATEWAY_URL}"\n`
|
|
121
|
+
appendFileSync(path, (before && !before.endsWith('\n') ? '\n' : '') + (before ? '\n' : '') + table)
|
|
122
|
+
return added(backup ? `The previous file is saved as ${backup}.` : null)
|
|
123
|
+
},
|
|
124
|
+
login: null,
|
|
125
|
+
signIn: 'Run codex mcp login renor, then restart Codex.',
|
|
126
|
+
byHand: `codex mcp add ${SERVER_NAME} --url ${GATEWAY_URL}`,
|
|
127
|
+
},
|
|
128
|
+
{
|
|
129
|
+
id: 'cursor',
|
|
130
|
+
label: 'Cursor',
|
|
131
|
+
detect: (ctx) => {
|
|
132
|
+
const bin = which('cursor-agent', ctx.env)
|
|
133
|
+
if (bin) return { found: true, version: versionOf('cursor-agent', ctx.env) }
|
|
134
|
+
return existsSync(join(home(ctx.env), '.cursor')) ? { found: true, version: null } : { found: false }
|
|
135
|
+
},
|
|
136
|
+
current: (ctx) => {
|
|
137
|
+
const read = readJsonc(join(home(ctx.env), '.cursor', 'mcp.json'))
|
|
138
|
+
const entry = read.value?.mcpServers?.[SERVER_NAME]
|
|
139
|
+
return { readable: read.ok, configured: Boolean(entry), url: urlOf(entry) }
|
|
140
|
+
},
|
|
141
|
+
add: (ctx) => {
|
|
142
|
+
const path = join(home(ctx.env), '.cursor', 'mcp.json')
|
|
143
|
+
const read = readJsonc(path)
|
|
144
|
+
if (!plainJson(read)) return failed(`${path} has comments or is not valid JSON, so it was left as it is.`)
|
|
145
|
+
const value = read.value && typeof read.value === 'object' ? read.value : {}
|
|
146
|
+
value.mcpServers = { ...(value.mcpServers || {}), [SERVER_NAME]: { url: GATEWAY_URL } }
|
|
147
|
+
const backup = writeJsonWithBackup(path, value)
|
|
148
|
+
return added(backup ? `The previous file is saved as ${backup}.` : null)
|
|
149
|
+
},
|
|
150
|
+
login: (ctx) => (which('cursor-agent', ctx.env) ? ['cursor-agent', ['mcp', 'login', SERVER_NAME]] : null),
|
|
151
|
+
signIn: 'Restart Cursor, open Settings › MCP, find renor and sign in.',
|
|
152
|
+
byHand: `Add { "mcpServers": { "${SERVER_NAME}": { "url": "${GATEWAY_URL}" } } } to ~/.cursor/mcp.json`,
|
|
153
|
+
},
|
|
154
|
+
{
|
|
155
|
+
id: 'opencode',
|
|
156
|
+
label: 'OpenCode',
|
|
157
|
+
detect: (ctx) => {
|
|
158
|
+
if (which('opencode', ctx.env)) return { found: true, version: versionOf('opencode', ctx.env) }
|
|
159
|
+
return existsSync(opencodeDir(ctx.env)) ? { found: true, version: null } : { found: false }
|
|
160
|
+
},
|
|
161
|
+
current: (ctx) => {
|
|
162
|
+
// OpenCode reads both files, so Renor in either one counts.
|
|
163
|
+
let readable = true
|
|
164
|
+
for (const name of ['opencode.jsonc', 'opencode.json']) {
|
|
165
|
+
const read = readJsonc(join(opencodeDir(ctx.env), name))
|
|
166
|
+
readable &&= read.ok
|
|
167
|
+
// OpenCode 1 keeps servers under `mcp`, OpenCode 2 under `mcp.servers`; it reads both.
|
|
168
|
+
const entry = read.value?.mcp?.servers?.[SERVER_NAME] ?? read.value?.mcp?.[SERVER_NAME]
|
|
169
|
+
if (entry) return { readable: read.ok, configured: true, url: urlOf(entry) }
|
|
170
|
+
}
|
|
171
|
+
return { readable, configured: false, url: null }
|
|
172
|
+
},
|
|
173
|
+
add: (ctx, { replace }) => {
|
|
174
|
+
// OpenCode's own command keeps the file's comments and format; an older OpenCode without it gets a careful merge.
|
|
175
|
+
if (!replace && which('opencode', ctx.env) && capture('opencode', ['mcp', 'add', '--help'], { env: ctx.env }).ok) {
|
|
176
|
+
const out = capture('opencode', ['mcp', 'add', SERVER_NAME, '--url', GATEWAY_URL, '--global'], { env: ctx.env })
|
|
177
|
+
if (out.ok) return added()
|
|
178
|
+
}
|
|
179
|
+
const path = join(opencodeDir(ctx.env), existsSync(join(opencodeDir(ctx.env), 'opencode.jsonc')) ? 'opencode.jsonc' : 'opencode.json')
|
|
180
|
+
const read = readJsonc(path)
|
|
181
|
+
if (!plainJson(read)) return failed(`${path} has comments or is not valid JSON, so it was left as it is.`)
|
|
182
|
+
const value = read.value && typeof read.value === 'object' ? read.value : {}
|
|
183
|
+
const mcp = { ...(value.mcp || {}) }
|
|
184
|
+
if (mcp.servers && typeof mcp.servers === 'object') mcp.servers = { ...mcp.servers, [SERVER_NAME]: { type: 'remote', url: GATEWAY_URL } }
|
|
185
|
+
else mcp[SERVER_NAME] = { type: 'remote', url: GATEWAY_URL }
|
|
186
|
+
value.mcp = mcp
|
|
187
|
+
const backup = writeJsonWithBackup(path, value)
|
|
188
|
+
return added(backup ? `The previous file is saved as ${backup}.` : null)
|
|
189
|
+
},
|
|
190
|
+
login: (ctx) => (which('opencode', ctx.env) ? ['opencode', ['mcp', 'auth', SERVER_NAME]] : null),
|
|
191
|
+
signIn: 'Run opencode mcp auth renor, then restart OpenCode.',
|
|
192
|
+
byHand: `opencode mcp add ${SERVER_NAME} --url ${GATEWAY_URL} --global`,
|
|
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
|
+
},
|
|
220
|
+
{
|
|
221
|
+
id: 'pi',
|
|
222
|
+
label: 'Pi',
|
|
223
|
+
detect: (ctx) => {
|
|
224
|
+
if (!which('pi', ctx.env)) return { found: false }
|
|
225
|
+
const version = versionOf('pi', ctx.env)
|
|
226
|
+
// `pi mcp` arrived in Pi 0.99.
|
|
227
|
+
return version && !atLeast(version, '0.99') ? { found: true, version, blocked: 'Update Pi first (pi update): Pi adds MCP servers from 0.99 on.' } : { found: true, version }
|
|
228
|
+
},
|
|
229
|
+
current: (ctx) => {
|
|
230
|
+
const read = readJsonc(join(home(ctx.env), '.pi', 'agent', 'mcp.json'))
|
|
231
|
+
const entry = read.value?.mcpServers?.[SERVER_NAME]
|
|
232
|
+
return { readable: read.ok, configured: Boolean(entry), url: urlOf(entry) }
|
|
233
|
+
},
|
|
234
|
+
// `pi mcp add` adds or replaces, so the same command serves a replacement.
|
|
235
|
+
add: (ctx) => {
|
|
236
|
+
const out = capture('pi', ['mcp', 'add', SERVER_NAME, '--url', GATEWAY_URL, '--exposure', 'direct'], { env: ctx.env })
|
|
237
|
+
return out.ok ? added() : failed((out.stderr || out.stdout || out.error || '').trim())
|
|
238
|
+
},
|
|
239
|
+
login: () => ['pi', ['mcp', 'login', SERVER_NAME]],
|
|
240
|
+
signIn: 'Run pi mcp login renor, then /reload in Pi.',
|
|
241
|
+
byHand: `pi mcp add ${SERVER_NAME} --url ${GATEWAY_URL} --exposure direct`,
|
|
242
|
+
},
|
|
243
|
+
{
|
|
244
|
+
id: 'vscode',
|
|
245
|
+
label: 'VS Code',
|
|
246
|
+
detect: (ctx) => (which('code', ctx.env) ? { found: true, version: versionOf('code', ctx.env) } : { found: false }),
|
|
247
|
+
current: (ctx) => {
|
|
248
|
+
const read = readJsonc(join(vscodeUserDir(ctx.env), 'mcp.json'))
|
|
249
|
+
const entry = read.value?.servers?.[SERVER_NAME]
|
|
250
|
+
return { readable: read.ok, configured: Boolean(entry), url: urlOf(entry) }
|
|
251
|
+
},
|
|
252
|
+
add: (ctx, { replace }) => {
|
|
253
|
+
if (replace) {
|
|
254
|
+
const path = join(vscodeUserDir(ctx.env), 'mcp.json')
|
|
255
|
+
const read = readJsonc(path)
|
|
256
|
+
if (!plainJson(read)) return failed(`${path} has comments or is not valid JSON, so it was left as it is.`)
|
|
257
|
+
const value = read.value || {}
|
|
258
|
+
value.servers = { ...(value.servers || {}), [SERVER_NAME]: { type: 'http', url: GATEWAY_URL } }
|
|
259
|
+
const backup = writeJsonWithBackup(path, value)
|
|
260
|
+
return added(backup ? `The previous file is saved as ${backup}.` : null)
|
|
261
|
+
}
|
|
262
|
+
const out = capture('code', ['--add-mcp', JSON.stringify({ name: SERVER_NAME, type: 'http', url: GATEWAY_URL })], { env: ctx.env })
|
|
263
|
+
return out.ok ? added() : failed((out.stderr || out.stdout || out.error || '').trim())
|
|
264
|
+
},
|
|
265
|
+
login: null,
|
|
266
|
+
signIn: 'Open VS Code. Copilot asks you to sign in to Renor the first time it uses it.',
|
|
267
|
+
byHand: `code --add-mcp '${JSON.stringify({ name: SERVER_NAME, type: 'http', url: GATEWAY_URL })}'`,
|
|
268
|
+
},
|
|
269
|
+
]
|
|
270
|
+
|
|
271
|
+
export { codexEntry }
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What `renor setup` adds and installs. The same values the dashboard's setup prompt gives
|
|
3
|
+
* (src/lib/landing-setup-prompt.ts); tests/cli-setup.test.ts in the platform suite fails if they drift apart.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/** Renor's one public MCP address. Sign-in is OAuth in the browser, so nothing here carries a key. */
|
|
7
|
+
export const GATEWAY_URL = 'https://mcp.renor.cloud/mcp'
|
|
8
|
+
|
|
9
|
+
/** The server's name in every client's config. */
|
|
10
|
+
export const SERVER_NAME = 'renor'
|
|
11
|
+
|
|
12
|
+
/** The public skill, pinned: the exact `skills` CLI version and the renor-skills commit it fetches. */
|
|
13
|
+
export const SKILLS_CLI_VERSION = '1.7.2'
|
|
14
|
+
export const RENOR_SKILLS_COMMIT = '018fddc51e6066c30c6919fdf27bbb1c8b64187a'
|
|
15
|
+
export const SKILL_INSTALL_ARGS = [
|
|
16
|
+
'-y',
|
|
17
|
+
`skills@${SKILLS_CLI_VERSION}`,
|
|
18
|
+
'add',
|
|
19
|
+
`https://github.com/renorai/renor-skills/tree/${RENOR_SKILLS_COMMIT}`,
|
|
20
|
+
'--agent',
|
|
21
|
+
'*',
|
|
22
|
+
'-y',
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
/** What to tell the person to try once everything is connected (FIRST_MEMORY_CHECK in the platform). */
|
|
26
|
+
export const FIRST_MEMORY_SAVE = 'Save to Renor: my favorite editor is Neovim.'
|
|
27
|
+
export const FIRST_MEMORY_RECALL = 'What is my favorite editor?'
|
|
@@ -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
|
+
}
|