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/lib/setup.mjs
ADDED
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `renor setup`: find the AI apps on this machine, add Renor to each without touching anything else in their config,
|
|
3
|
+
* sign in where the app can do it from the terminal, install the skill, then say plainly what is left, app by app.
|
|
4
|
+
*/
|
|
5
|
+
import { CLIENTS } from './clients.mjs'
|
|
6
|
+
import { FIRST_MEMORY_RECALL, FIRST_MEMORY_SAVE, GATEWAY_URL, SKILL_INSTALL_ARGS } from './constants.mjs'
|
|
7
|
+
import { checkCode, cleanupClient, probeGateway, verifyClient } from './verify.mjs'
|
|
8
|
+
import { askAboutSharing, outcomeLine } from './sharing.mjs'
|
|
9
|
+
import { confirm, interactive, which } from './system.mjs'
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* @param {object} options
|
|
13
|
+
* @param {boolean} options.yes add without asking first
|
|
14
|
+
* @param {boolean} options.dryRun say what would happen, change nothing
|
|
15
|
+
* @param {boolean} options.login run the sign-ins the apps offer from the terminal
|
|
16
|
+
* @param {boolean} options.skill install the Renor skill
|
|
17
|
+
* @param {boolean} options.verify also have Claude Code save and recall one test memory (the gateway is always probed)
|
|
18
|
+
* @param {boolean} options.force replace a renor entry that points somewhere else
|
|
19
|
+
* @param {string[]|null} options.only limit to these app ids
|
|
20
|
+
*/
|
|
21
|
+
export async function setup(options, io = defaultIo()) {
|
|
22
|
+
const { env, out, isTTY } = io
|
|
23
|
+
const paint = painter(out, env)
|
|
24
|
+
const say = (line = '') => out.write(line + '\n')
|
|
25
|
+
const canSignIn = options.login && isTTY
|
|
26
|
+
|
|
27
|
+
say(paint.bold('Renor setup'))
|
|
28
|
+
say(`Adds Renor (${GATEWAY_URL}) to the AI apps on this computer. Each app keeps everything else in its config.`)
|
|
29
|
+
say()
|
|
30
|
+
|
|
31
|
+
const pool = options.only ? CLIENTS.filter((client) => options.only.includes(client.id)) : CLIENTS
|
|
32
|
+
const found = []
|
|
33
|
+
const missing = []
|
|
34
|
+
for (const client of pool) {
|
|
35
|
+
const detection = client.detect({ env })
|
|
36
|
+
if (detection.found) found.push({ client, detection })
|
|
37
|
+
else missing.push(client)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (found.length === 0) {
|
|
41
|
+
say('No supported app was found: Claude Code, Codex, Cursor, OpenCode, Gemini CLI, Pi or VS Code.')
|
|
42
|
+
say('For ChatGPT or Claude in the browser, open https://app.renor.cloud and follow the steps there.')
|
|
43
|
+
return { code: 0, results: [] }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
say(`Found: ${found.map(({ client, detection }) => client.label + (detection.version ? ` ${detection.version}` : '')).join(', ')}.`)
|
|
47
|
+
if (missing.length > 0) say(paint.dim(`Not found: ${missing.map((client) => client.label).join(', ')}.`))
|
|
48
|
+
say()
|
|
49
|
+
|
|
50
|
+
if (options.dryRun) {
|
|
51
|
+
for (const { client, detection } of found) {
|
|
52
|
+
const now = client.current({ env })
|
|
53
|
+
say(` ${client.label}: ${planFor(client, detection, now, options)}`)
|
|
54
|
+
}
|
|
55
|
+
say()
|
|
56
|
+
say('Dry run: nothing was changed.')
|
|
57
|
+
return { code: 0, results: [] }
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (!options.yes && isTTY) {
|
|
61
|
+
const signIns = canSignIn ? ' Your browser opens once for each app that signs in from here; approve Renor there.' : ''
|
|
62
|
+
const yes = await confirm(`Add Renor to ${found.length === 1 ? found[0].client.label : `these ${found.length} apps`}?${signIns} [Y/n] `, io)
|
|
63
|
+
if (!yes) {
|
|
64
|
+
say('Nothing was changed.')
|
|
65
|
+
return { code: 0, results: [] }
|
|
66
|
+
}
|
|
67
|
+
say()
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const results = []
|
|
71
|
+
for (const { client, detection } of found) {
|
|
72
|
+
try {
|
|
73
|
+
results.push(await setUpOne(client, detection, { ...options, canSignIn }, { env, say }))
|
|
74
|
+
} catch (error) {
|
|
75
|
+
// One app's config that cannot be written (a permission, a file where a folder should be) is that app's failure,
|
|
76
|
+
// not the end of the run: the other apps are still set up and the report still says what is left.
|
|
77
|
+
const detail = error instanceof Error ? error.message : String(error)
|
|
78
|
+
results.push({ id: client.id, label: client.label, state: 'failed', next: `${detail} Add Renor by hand: ${client.byHand}` })
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
let skill = null
|
|
83
|
+
if (options.skill) {
|
|
84
|
+
if (!which('npx', env)) {
|
|
85
|
+
skill = { ok: false, detail: 'npx was not found.' }
|
|
86
|
+
} else {
|
|
87
|
+
say(paint.dim('Installing the Renor skill, which tells your AI when to save and when to recall…'))
|
|
88
|
+
const status = interactive('npx', SKILL_INSTALL_ARGS, { env })
|
|
89
|
+
skill = status === 0 ? { ok: true } : { ok: false, detail: `The skill install exited with ${status}.` }
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
await verifyAll(found, results, { env, say, paint, io, options })
|
|
94
|
+
|
|
95
|
+
report(results, skill, { say, paint })
|
|
96
|
+
const sharing = await askAboutSharing(io, options)
|
|
97
|
+
const sharingLine = outcomeLine(sharing, env)
|
|
98
|
+
if (sharingLine) say(sharingLine)
|
|
99
|
+
const failures = results.filter((result) => result.state === 'failed' || result.verified === false).length + (skill && !skill.ok ? 1 : 0)
|
|
100
|
+
return { code: failures > 0 ? 1 : 0, results, skill }
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** The proof: the gateway answers; with --verify, Claude Code (the app that can be limited to Renor's tools) saves and recalls one test memory. */
|
|
104
|
+
async function verifyAll(found, results, { env, say, paint, io, options }) {
|
|
105
|
+
say()
|
|
106
|
+
const gateway = await probeGateway(io.fetch)
|
|
107
|
+
if (gateway.ok) say(`${paint.ok('✓')} Renor’s gateway answers.`)
|
|
108
|
+
else {
|
|
109
|
+
say(`${paint.warn('!')} ${gateway.detail}`)
|
|
110
|
+
say(` Fix: ${gateway.fix}`)
|
|
111
|
+
}
|
|
112
|
+
if (!options.verify) return gateway.ok
|
|
113
|
+
|
|
114
|
+
const hasCheck = (result) => Boolean(found.find(({ client }) => client.id === result.id).client.check)
|
|
115
|
+
for (const result of results) {
|
|
116
|
+
if ((result.state === 'already' || result.state === 'added') && !hasCheck(result)) result.unchecked = true
|
|
117
|
+
}
|
|
118
|
+
const checkable = results.filter((result) => (result.state === 'already' || (result.state === 'added' && result.signedIn)) && hasCheck(result))
|
|
119
|
+
if (checkable.length === 0) {
|
|
120
|
+
say(paint.dim('Nothing to check from here yet: Claude Code is the app setup can test, and it is not signed in to Renor. Sign in as listed below, then run npx renor setup --verify.'))
|
|
121
|
+
return gateway.ok
|
|
122
|
+
}
|
|
123
|
+
if (!gateway.ok) {
|
|
124
|
+
for (const result of checkable) result.verified = false
|
|
125
|
+
return false
|
|
126
|
+
}
|
|
127
|
+
say(paint.dim('Checking Claude Code: it saves one test memory to Renor and recalls it. This can take a minute and uses a little of its model quota.'))
|
|
128
|
+
for (const result of checkable) {
|
|
129
|
+
const { client } = found.find((item) => item.client.id === result.id)
|
|
130
|
+
const code = checkCode()
|
|
131
|
+
result.checkCode = code
|
|
132
|
+
const check = verifyClient(client, { env, code, run: io.run })
|
|
133
|
+
result.verified = check.ok
|
|
134
|
+
let retiredId = check.retiredId
|
|
135
|
+
result.noteRetired = check.retired
|
|
136
|
+
if (!check.retired) {
|
|
137
|
+
const cleanup = cleanupClient(client, { env, code, savedId: check.savedId, run: io.run })
|
|
138
|
+
result.noteRetired = cleanup.retired
|
|
139
|
+
retiredId = cleanup.retiredId ?? retiredId
|
|
140
|
+
}
|
|
141
|
+
if (check.wrongRetire) {
|
|
142
|
+
check.ok = false
|
|
143
|
+
check.detail = `${client.label} retired a different memory (id ${check.wrongRetire.retired}) than the one it saved (id ${check.wrongRetire.saved}).`
|
|
144
|
+
check.fix = `put it back with restore_memory and the id ${check.wrongRetire.retired} (ask any connected AI, or use the dashboard's memory list). Setup then retired the test note itself.`
|
|
145
|
+
} else if (result.noteRetired && check.recalledOk) {
|
|
146
|
+
// The recall worked and the cleanup pass confirmed the retire the app had not.
|
|
147
|
+
check.ok = true
|
|
148
|
+
} else if (!result.noteRetired) {
|
|
149
|
+
check.ok = false
|
|
150
|
+
check.detail = `${check.detail ?? 'The check finished'}; setup could not retire the test note Renor setup check ${code}.`
|
|
151
|
+
check.fix = `Run npx renor setup --verify again; if it persists, write to hello@renor.cloud with the code ${code}.`
|
|
152
|
+
}
|
|
153
|
+
result.noteId = retiredId
|
|
154
|
+
result.verified = check.ok
|
|
155
|
+
if (!check.ok) {
|
|
156
|
+
result.verifyDetail = check.detail
|
|
157
|
+
result.verifyFix = check.fix
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
if (checkable.every((result) => result.noteRetired)) say(`${paint.ok('✓')} The test note${checkable.length === 1 && checkable[0].noteId ? ` (id ${checkable[0].noteId})` : ''} was retired again: it no longer comes back in search.`)
|
|
161
|
+
return gateway.ok
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function planFor(client, detection, now, options) {
|
|
165
|
+
if (detection.blocked) return detection.blocked
|
|
166
|
+
if (now.configured && now.url === GATEWAY_URL && !options.force) return 'already has Renor; nothing to do.'
|
|
167
|
+
if (now.configured && !options.force) return `has a renor server at ${now.url ?? 'another address'}; it would be left alone (use --force to replace it).`
|
|
168
|
+
if (now.configured) return 'its renor server would be replaced with Renor’s address.'
|
|
169
|
+
return client.login ? 'Renor would be added, then signed in.' : 'Renor would be added.'
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
async function setUpOne(client, detection, options, { env, say }) {
|
|
173
|
+
const base = { id: client.id, label: client.label }
|
|
174
|
+
if (detection.blocked) return { ...base, state: 'blocked', next: detection.blocked }
|
|
175
|
+
|
|
176
|
+
const now = client.current({ env })
|
|
177
|
+
if (!now.readable) {
|
|
178
|
+
return { ...base, state: 'failed', next: `Its config file could not be read. Add Renor by hand: ${client.byHand}` }
|
|
179
|
+
}
|
|
180
|
+
if (now.configured && now.url === GATEWAY_URL && !options.force) {
|
|
181
|
+
return { ...base, state: 'already', next: null }
|
|
182
|
+
}
|
|
183
|
+
if (now.configured && !options.force) {
|
|
184
|
+
return {
|
|
185
|
+
...base,
|
|
186
|
+
state: 'different',
|
|
187
|
+
next: `It already has a renor server at ${now.url ?? 'another address'}, left as it is. Run renor setup --force to replace it.`,
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const ctx = { env, canSignIn: options.canSignIn, say }
|
|
192
|
+
const outcome = client.add(ctx, { replace: now.configured })
|
|
193
|
+
if (!outcome.ok) {
|
|
194
|
+
return { ...base, state: 'failed', next: `${outcome.detail ? outcome.detail + ' ' : ''}Add Renor by hand: ${client.byHand}` }
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
let signedIn = outcome.signedIn
|
|
198
|
+
const login = !signedIn && options.canSignIn && client.login ? (typeof client.login === 'function' ? client.login(ctx) : client.login) : null
|
|
199
|
+
if (login) {
|
|
200
|
+
say(`Signing in to Renor for ${client.label}. Your browser opens: sign in and approve.`)
|
|
201
|
+
signedIn = interactive(login[0], login[1], { env }) === 0
|
|
202
|
+
}
|
|
203
|
+
return {
|
|
204
|
+
...base,
|
|
205
|
+
state: 'added',
|
|
206
|
+
signedIn,
|
|
207
|
+
detail: outcome.detail,
|
|
208
|
+
next: signedIn ? null : client.signIn,
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function report(results, skill, { say, paint }) {
|
|
213
|
+
say()
|
|
214
|
+
say(paint.bold('Done.'))
|
|
215
|
+
const wired = results.filter((result) => result.state === 'already' || result.state === 'added').length
|
|
216
|
+
const verified = results.filter((result) => result.verified === true).length
|
|
217
|
+
say(`${wired} of ${results.length} wired${results.some((result) => result.verified !== undefined) ? `, ${verified} verified` : ''}.`)
|
|
218
|
+
const width = Math.max(...results.map((result) => result.label.length))
|
|
219
|
+
for (const result of results) {
|
|
220
|
+
const name = result.label.padEnd(width)
|
|
221
|
+
if (result.state === 'already') say(` ${paint.ok('✓')} ${name} already had Renor`)
|
|
222
|
+
else if (result.state === 'added' && result.signedIn) say(` ${paint.ok('✓')} ${name} added and signed in`)
|
|
223
|
+
else if (result.state === 'added') say(` ${paint.todo('•')} ${name} added. Left for you: ${result.next}`)
|
|
224
|
+
else say(` ${paint.warn('!')} ${name} ${result.next}`)
|
|
225
|
+
if (result.detail) say(` ${paint.dim(result.detail)}`)
|
|
226
|
+
if (result.verified === true) say(` ${paint.ok('✓')} verified: saved a test memory and recalled it`)
|
|
227
|
+
if (result.unchecked) say(` ${paint.dim(`not checked by setup: tell it "${FIRST_MEMORY_SAVE}", then ask "${FIRST_MEMORY_RECALL}" in a new chat.`)}`)
|
|
228
|
+
if (result.verified === false) {
|
|
229
|
+
say(` ${paint.warn('!')} not verified: ${result.verifyDetail ?? 'Renor’s gateway could not be reached.'}`)
|
|
230
|
+
if (result.verifyFix) say(` Fix: ${result.verifyFix}`)
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
if (skill) {
|
|
234
|
+
say(skill.ok ? ` ${paint.ok('✓')} ${'Skill'.padEnd(width)} installed` : ` ${paint.warn('!')} ${'Skill'.padEnd(width)} not installed: ${skill.detail}`)
|
|
235
|
+
}
|
|
236
|
+
say()
|
|
237
|
+
say(`Try it: tell one of them "${FIRST_MEMORY_SAVE}"`)
|
|
238
|
+
say(`Then, in a new chat or another app, ask "${FIRST_MEMORY_RECALL}"`)
|
|
239
|
+
say(paint.dim('ChatGPT and Claude in the browser: open https://app.renor.cloud for their steps.'))
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function painter(out, env) {
|
|
243
|
+
const on = Boolean(out.isTTY) && !env.NO_COLOR
|
|
244
|
+
const wrap = (open, close) => (text) => (on ? `\u001b[${open}m${text}\u001b[${close}m` : text)
|
|
245
|
+
return { bold: wrap(1, 22), dim: wrap(2, 22), ok: wrap(32, 39), todo: wrap(36, 39), warn: wrap(33, 39) }
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function defaultIo() {
|
|
249
|
+
return {
|
|
250
|
+
env: process.env,
|
|
251
|
+
out: process.stdout,
|
|
252
|
+
input: process.stdin,
|
|
253
|
+
output: process.stdout,
|
|
254
|
+
isTTY: Boolean(process.stdin.isTTY && process.stdout.isTTY),
|
|
255
|
+
}
|
|
256
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether `renor setup` may ask the sharing question (#778): only while the operator's gate is open. The switch lives on the
|
|
3
|
+
* server (app_settings 'learning_sharing', supabase/migrations/20261024120000_learning_sharing_gate.sql) and the gateway reports
|
|
4
|
+
* it, as one public bit, at GET /sharing/status ({"open": true|false}). Anything but an answer of exactly {"open": true} (no
|
|
5
|
+
* network, a timeout, an older gateway without the route, a malformed body) means closed: the question is not asked.
|
|
6
|
+
*
|
|
7
|
+
* Opening the switch only lets setup ASK. Whether a row is ever stored is decided again by the server from the authenticated
|
|
8
|
+
* identity and that user's recorded consent; client-supplied workspace ids never decide it.
|
|
9
|
+
*/
|
|
10
|
+
import { GATEWAY_URL } from './constants.mjs'
|
|
11
|
+
|
|
12
|
+
export const SHARING_STATUS_URL = new URL('/sharing/status', GATEWAY_URL).href
|
|
13
|
+
|
|
14
|
+
/** True only when the gateway answers {"open": true} within `timeoutMs`. */
|
|
15
|
+
export async function sharingPromptOpen({ fetchImpl = globalThis.fetch, url = SHARING_STATUS_URL, timeoutMs = 3000 } = {}) {
|
|
16
|
+
if (typeof fetchImpl !== 'function') return false
|
|
17
|
+
const controller = new AbortController()
|
|
18
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs)
|
|
19
|
+
try {
|
|
20
|
+
const response = await fetchImpl(url, {
|
|
21
|
+
headers: { accept: 'application/json' },
|
|
22
|
+
redirect: 'error',
|
|
23
|
+
signal: controller.signal,
|
|
24
|
+
})
|
|
25
|
+
if (!response.ok) return false
|
|
26
|
+
const body = await response.json()
|
|
27
|
+
return body !== null && typeof body === 'object' && body.open === true
|
|
28
|
+
} catch {
|
|
29
|
+
return false
|
|
30
|
+
} finally {
|
|
31
|
+
clearTimeout(timer)
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Runs `ask` (the question #778 asks) only while the gate is open; otherwise returns 'closed' without asking or writing anything.
|
|
37
|
+
* #778's askAboutSharing calls it as its first step after its own not-interactive / environment / answered checks.
|
|
38
|
+
*/
|
|
39
|
+
export async function whenSharingOpen(ask, options = {}) {
|
|
40
|
+
if (!(await sharingPromptOpen(options))) return 'closed'
|
|
41
|
+
return ask()
|
|
42
|
+
}
|
package/lib/sharing.mjs
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one question `renor setup` asks, once: do you want to share anonymous usage signals with Renor? The answer is No unless the
|
|
3
|
+
* person says yes, and nothing is sent by this command either way: it only writes the choice, in this install's sharing folder,
|
|
4
|
+
* which the open-source engine (engine/src/sharing/state.ts) reads as a limit on every workspace folder on this install: the
|
|
5
|
+
* stricter of the install's answer and a workspace's own record is in force, and an engine finding the install at No turns any
|
|
6
|
+
* workspace record that is still on to off (engine/src/sharing/loop.ts). A No therefore stops every engine on this install that
|
|
7
|
+
* reads this folder; a yes never turns a workspace on by itself.
|
|
8
|
+
*
|
|
9
|
+
* The question is asked only while the operator's gate is open (cli/lib/sharing-gate.mjs, #961): closed, it asks and writes nothing.
|
|
10
|
+
*
|
|
11
|
+
* A hosted workspace has its own switch (the dashboard's Settings), which an owner signed in there controls; this file does not
|
|
12
|
+
* touch it. The record has the level, the time, the id of the words the person saw and their confirmation of being an adult, as the
|
|
13
|
+
* engine writes them (a test in tests/ holds the two in step).
|
|
14
|
+
*/
|
|
15
|
+
import { chmodSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
|
|
16
|
+
import { homedir } from 'node:os'
|
|
17
|
+
import { join, resolve } from 'node:path'
|
|
18
|
+
import { createInterface } from 'node:readline'
|
|
19
|
+
import { whenSharingOpen } from './sharing-gate.mjs'
|
|
20
|
+
|
|
21
|
+
/** The id of the words below. Change the words, change the id. */
|
|
22
|
+
export const SETUP_WORDING_VERSION = 'setup-2026-10-21'
|
|
23
|
+
|
|
24
|
+
export const SHARING_QUESTION = `One question, asked once: help Renor learn from how its memory is used?
|
|
25
|
+
Share anonymous usage signals: numbers only (which memory was returned, its position and score, and whether your AI then opened or
|
|
26
|
+
cited it, times to the hour). Never memory text, queries, names or paths. Off is the default; you can change it any time with
|
|
27
|
+
\`renor-engine telemetry\`, and every field is listed in the engine's docs/TELEMETRY.md. Saying yes also confirms you are 18 or older.
|
|
28
|
+
This command sends nothing itself.
|
|
29
|
+
Share anonymous usage signals? [y/N] `
|
|
30
|
+
|
|
31
|
+
export function sharingDir(env = process.env) {
|
|
32
|
+
const set = (env.RENOR_SHARING_DIR || '').trim()
|
|
33
|
+
if (set !== '') return resolve(set)
|
|
34
|
+
const xdg = (env.XDG_CONFIG_HOME || '').trim()
|
|
35
|
+
return join(xdg !== '' ? resolve(xdg) : join(homedir(), '.config'), 'renor')
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** True when a person (or this command) already recorded an answer: the question is never asked twice. */
|
|
39
|
+
export function hasAnswer(env = process.env) {
|
|
40
|
+
try {
|
|
41
|
+
const record = JSON.parse(readFileSync(join(sharingDir(env), 'sharing.json'), 'utf8'))
|
|
42
|
+
return record && record.version === 1 && ['off', 'signals', 'data'].includes(record.level)
|
|
43
|
+
} catch {
|
|
44
|
+
return false
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** An environment that says no already answered: DO_NOT_TRACK=1 or RENOR_SHARING=off. */
|
|
49
|
+
export function environmentSaysNo(env = process.env) {
|
|
50
|
+
return (env.DO_NOT_TRACK || '').trim() === '1' || (env.RENOR_SHARING || '').trim().toLowerCase() === 'off'
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function recordAnswer(env, level, now = new Date()) {
|
|
54
|
+
const dir = sharingDir(env)
|
|
55
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 })
|
|
56
|
+
// mkdirSync's mode applies only to a folder it creates: one that already exists (made by hand, or by an older tool) is
|
|
57
|
+
// tightened here, so the folder holding the consent record is the owner's alone on every write.
|
|
58
|
+
chmodSync(dir, 0o700)
|
|
59
|
+
const record = {
|
|
60
|
+
version: 1,
|
|
61
|
+
level,
|
|
62
|
+
changedAt: now.toISOString(),
|
|
63
|
+
wording: SETUP_WORDING_VERSION,
|
|
64
|
+
adult: level !== 'off',
|
|
65
|
+
source: 'setup',
|
|
66
|
+
}
|
|
67
|
+
const temp = join(dir, `.sharing.json.${process.pid}.tmp`)
|
|
68
|
+
writeFileSync(temp, JSON.stringify(record, null, 2) + '\n', { mode: 0o600 })
|
|
69
|
+
renameSync(temp, join(dir, 'sharing.json'))
|
|
70
|
+
return record
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function ask(question, { input = process.stdin, output = process.stdout } = {}) {
|
|
74
|
+
return new Promise((resolveAnswer) => {
|
|
75
|
+
const rl = createInterface({ input, output })
|
|
76
|
+
rl.question(question, (answer) => {
|
|
77
|
+
rl.close()
|
|
78
|
+
resolveAnswer(/^\s*y(es)?\s*$/i.test(answer))
|
|
79
|
+
})
|
|
80
|
+
})
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* What setup may say once the answer is recorded: only what this command controls, which is one folder for one account. It
|
|
85
|
+
* cannot see a hosted workspace's choice (Settings > Learning) or an engine run under another account or with its own
|
|
86
|
+
* RENOR_SHARING_DIR, so it never claims that nothing is sent, and a yes never claims that sharing is on.
|
|
87
|
+
*/
|
|
88
|
+
export function outcomeLine(result, env = process.env) {
|
|
89
|
+
const where = `Recorded for this computer's account, in ${join(sharingDir(env), 'sharing.json')}`
|
|
90
|
+
const elsewhere = 'A hosted workspace keeps its own switch in Settings on https://app.renor.cloud, and an engine run under another account reads its own folder.'
|
|
91
|
+
if (result === 'asked-no') {
|
|
92
|
+
return `${where}: not sharing. An engine that reads this folder stops sharing at its next check. ${elsewhere}`
|
|
93
|
+
}
|
|
94
|
+
if (result === 'asked-yes') {
|
|
95
|
+
return `${where}: sharing allowed up to anonymous usage signals. This turns nothing on by itself: an engine shares only for a workspace whose own choice is on (renor-engine telemetry). Undo it with renor-engine telemetry off. ${elsewhere}`
|
|
96
|
+
}
|
|
97
|
+
return null
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Asks, in front of a person only, and records the answer. Returns what happened: 'asked-yes', 'asked-no', or the reason it did not
|
|
102
|
+
* ask ('not-interactive', 'answered', 'environment', or 'closed' while the operator's gate is closed).
|
|
103
|
+
*/
|
|
104
|
+
export async function askAboutSharing(io, { yes, dryRun }) {
|
|
105
|
+
const env = io.env
|
|
106
|
+
if (yes || dryRun || !io.isTTY) return 'not-interactive'
|
|
107
|
+
if (environmentSaysNo(env)) return 'environment'
|
|
108
|
+
if (hasAnswer(env)) return 'answered'
|
|
109
|
+
return whenSharingOpen(async () => {
|
|
110
|
+
const wants = await ask(SHARING_QUESTION, io)
|
|
111
|
+
recordAnswer(env, wants ? 'signals' : 'off')
|
|
112
|
+
return wants ? 'asked-yes' : 'asked-no'
|
|
113
|
+
}, { fetchImpl: io.fetch })
|
|
114
|
+
}
|
package/lib/sync.mjs
ADDED
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `renor sync`: keep the memory files you choose (CLAUDE.md, AGENTS.md, an agent's memory folder) in your Renor
|
|
3
|
+
* workspace, so Renor and the notes your agents already trust are one memory.
|
|
4
|
+
*
|
|
5
|
+
* renor sync add <file-or-folder>… choose what to sync (nothing is read or sent until you do)
|
|
6
|
+
* renor sync remove <path>… stop syncing it (what Renor already holds stays; forget it in the dashboard)
|
|
7
|
+
* renor sync [--dry-run] send what changed since the last run
|
|
8
|
+
*
|
|
9
|
+
* Each file is one import batch under Imported/synced/<path>, first line marking the source path. A changed file
|
|
10
|
+
* undoes its previous batch and imports the new text, so a note is never held twice. Only chosen paths are read,
|
|
11
|
+
* only .md files, and the token comes from RENOR_TOKEN (a connect token from the dashboard); nothing is stored.
|
|
12
|
+
*/
|
|
13
|
+
import { createHash } from 'node:crypto'
|
|
14
|
+
import { lstatSync, mkdirSync, readdirSync, readFileSync, renameSync, statSync, writeFileSync } from 'node:fs'
|
|
15
|
+
import { dirname, join, resolve, relative, sep } from 'node:path'
|
|
16
|
+
import { GATEWAY_URL } from './constants.mjs'
|
|
17
|
+
import { openSession } from './mcp.mjs'
|
|
18
|
+
import { home } from './system.mjs'
|
|
19
|
+
|
|
20
|
+
const LABEL = 'synced'
|
|
21
|
+
const FILE_BYTES = 64 * 1024
|
|
22
|
+
const EMPTY = { chosen: [], synced: {} }
|
|
23
|
+
|
|
24
|
+
export const statePath = (env) => join(home(env), '.renor', 'sync.json')
|
|
25
|
+
|
|
26
|
+
export function loadState(env) {
|
|
27
|
+
try {
|
|
28
|
+
const parsed = JSON.parse(readFileSync(statePath(env), 'utf8'))
|
|
29
|
+
return { chosen: parsed.chosen ?? [], synced: parsed.synced ?? {} }
|
|
30
|
+
} catch {
|
|
31
|
+
return { ...EMPTY, chosen: [], synced: {} }
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function saveState(env, state) {
|
|
36
|
+
const path = statePath(env)
|
|
37
|
+
mkdirSync(dirname(path), { recursive: true })
|
|
38
|
+
writeFileSync(path + '.tmp', JSON.stringify(state, null, 2))
|
|
39
|
+
renameSync(path + '.tmp', path)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Every .md file under the chosen paths. A path you chose may itself be a link (dotfiles); inside a chosen folder
|
|
44
|
+
* links are skipped, so a folder cannot lead the walk to files you did not choose. Dot-names and node_modules are skipped.
|
|
45
|
+
*/
|
|
46
|
+
export function expand(chosen) {
|
|
47
|
+
const files = []
|
|
48
|
+
const walk = (path, top) => {
|
|
49
|
+
let info
|
|
50
|
+
try { info = top ? statSync(path) : lstatSync(path) } catch { return }
|
|
51
|
+
if (info.isDirectory()) {
|
|
52
|
+
for (const name of readdirSync(path).sort()) if (!name.startsWith('.') && name !== 'node_modules') walk(join(path, name), false)
|
|
53
|
+
} else if (info.isFile() && path.endsWith('.md')) files.push(path)
|
|
54
|
+
}
|
|
55
|
+
for (const path of chosen) walk(path, true)
|
|
56
|
+
return [...new Set(files)]
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** The shown path (~ for home) and the destination path inside Imported/synced/. A leading dot becomes _ (the engine skips dot-names), so .claude and claude stay apart. */
|
|
60
|
+
export function describe(path, env) {
|
|
61
|
+
const base = home(env)
|
|
62
|
+
const rel = relative(base, path)
|
|
63
|
+
const inHome = !rel.startsWith('..') && rel !== ''
|
|
64
|
+
const shown = inHome ? '~/' + rel.split(sep).join('/') : path
|
|
65
|
+
const parts = (inHome ? rel.split(sep) : ['_', ...path.split(sep).filter(Boolean)]).map((p) => p.replace(/^\./, '_')).filter(Boolean)
|
|
66
|
+
return { shown, relativePath: parts.join('/') }
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const sha = (text) => createHash('sha256').update(text).digest('hex')
|
|
70
|
+
|
|
71
|
+
/** What to do for each chosen file, without calling Renor. */
|
|
72
|
+
export function plan(env, state = loadState(env)) {
|
|
73
|
+
const items = []
|
|
74
|
+
for (const path of expand(state.chosen)) {
|
|
75
|
+
const { shown, relativePath } = describe(path, env)
|
|
76
|
+
const markdown = `<!-- renor-sync source: ${shown} -->\n\n${readFileSync(path, 'utf8')}`
|
|
77
|
+
const previous = state.synced[path]
|
|
78
|
+
const hash = sha(markdown)
|
|
79
|
+
let action = previous?.hash === hash ? 'unchanged' : previous ? 'update' : 'new'
|
|
80
|
+
if (Buffer.byteLength(markdown) > FILE_BYTES) action = 'too-big'
|
|
81
|
+
items.push({ path, shown, relativePath, markdown, hash, action, previous })
|
|
82
|
+
}
|
|
83
|
+
return items
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** The folders (synced, synced-2, …) this file is not in now, holds no kept copy in, and has no copy waiting to be retired in, in order. */
|
|
87
|
+
function* freeLabels(previous) {
|
|
88
|
+
const waiting = [...(previous?.kept ?? []), ...(previous?.retire ?? [])].map((copy) => copy.label)
|
|
89
|
+
const used = new Set([previous?.label ?? (previous?.batchId ? LABEL : null), ...waiting])
|
|
90
|
+
for (let n = 1; n <= 50; n++) {
|
|
91
|
+
const label = n === 1 ? LABEL : `${LABEL}-${n}`
|
|
92
|
+
if (!used.has(label)) yield label
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Retire an earlier batch of this file. Renor keeps a note the owner edited there and says so (`preserved`): that copy
|
|
98
|
+
* stays, is listed as kept, and its folder is not used for this file again. Returns 'retired' or 'kept'; a call that throws stays queued.
|
|
99
|
+
*/
|
|
100
|
+
async function retireOld(session, state, entry, old, shown, say, env) {
|
|
101
|
+
let undone
|
|
102
|
+
try {
|
|
103
|
+
undone = await session.call('undo_import', { batchId: old.batchId })
|
|
104
|
+
} catch (error) {
|
|
105
|
+
// A batch whose apply never happened has nothing to retire.
|
|
106
|
+
if (!/No applied batch|batch_not_found/.test(error instanceof Error ? error.message : '')) throw error
|
|
107
|
+
undone = {}
|
|
108
|
+
}
|
|
109
|
+
entry.retire = entry.retire.filter((copy) => copy.batchId !== old.batchId)
|
|
110
|
+
if (entry.retire.length === 0) delete entry.retire
|
|
111
|
+
// Preserved because another batch of this file still asserts the same text is not an edit; the last undo retires it.
|
|
112
|
+
const reasons = undone.preservedReasons ?? {}
|
|
113
|
+
const edited = (undone.preserved ?? []).filter((path) => !String(reasons[path] ?? '').startsWith('kept: re-asserted'))
|
|
114
|
+
let outcome = 'retired'
|
|
115
|
+
if (edited.length) {
|
|
116
|
+
if (!entry.kept?.some((copy) => copy.label === old.label)) entry.kept = [...(entry.kept ?? []), old]
|
|
117
|
+
say(` kept ${shown}: Renor kept the earlier copy (${reasons[edited[0]] || 'it was changed there, or could not be removed'}); the new copy is in`)
|
|
118
|
+
outcome = 'kept'
|
|
119
|
+
}
|
|
120
|
+
saveState(env, state)
|
|
121
|
+
return outcome
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export async function sync(argv, io = { env: process.env, out: process.stdout, fetchImpl: fetch }) {
|
|
125
|
+
const { env, out } = io
|
|
126
|
+
const say = (line = '') => out.write(line + '\n')
|
|
127
|
+
const dryRun = argv.includes('--dry-run')
|
|
128
|
+
const words = argv.filter((arg) => !arg.startsWith('-'))
|
|
129
|
+
const [verb, ...rest] = words[0] === 'add' || words[0] === 'remove' || words[0] === 'list' ? words : ['run']
|
|
130
|
+
const state = loadState(env)
|
|
131
|
+
|
|
132
|
+
if (verb === 'add' || verb === 'remove') {
|
|
133
|
+
if (rest.length === 0) { say(`renor sync ${verb} needs at least one file or folder.`); return 2 }
|
|
134
|
+
const paths = rest.map((p) => resolve(p))
|
|
135
|
+
if (verb === 'add') {
|
|
136
|
+
const missing = paths.filter((p) => { try { statSync(p); return false } catch { return true } })
|
|
137
|
+
if (missing.length) { say(`Not found: ${missing.join(', ')}`); return 2 }
|
|
138
|
+
state.chosen = [...new Set([...state.chosen, ...paths])]
|
|
139
|
+
} else {
|
|
140
|
+
state.chosen = state.chosen.filter((p) => !paths.includes(p))
|
|
141
|
+
}
|
|
142
|
+
saveState(env, state)
|
|
143
|
+
const count = expand(state.chosen).length
|
|
144
|
+
say(`${verb === 'add' ? 'Chosen' : 'Left'}: ${paths.map((p) => describe(p, env).shown).join(', ')}. ${count} Markdown file${count === 1 ? '' : 's'} will sync. Run: renor sync`)
|
|
145
|
+
return 0
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (verb === 'list') {
|
|
149
|
+
for (const path of state.chosen) say(describe(path, env).shown)
|
|
150
|
+
if (state.chosen.length === 0) say('Nothing chosen. Add a file or folder: renor sync add ~/.claude/CLAUDE.md')
|
|
151
|
+
return 0
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (state.chosen.length === 0) {
|
|
155
|
+
say('Nothing chosen to sync. Pick the files you want Renor to hold, e.g.:')
|
|
156
|
+
say(' renor sync add ~/.claude/CLAUDE.md ./AGENTS.md')
|
|
157
|
+
return 0
|
|
158
|
+
}
|
|
159
|
+
const items = plan(env, state)
|
|
160
|
+
const pending = items.filter((item) => item.action === 'new' || item.action === 'update')
|
|
161
|
+
for (const item of items) say(` ${item.action.padEnd(9)} ${item.shown}${item.action === 'too-big' ? ' (over 64 KiB; split it)' : ''}`)
|
|
162
|
+
const here = new Set(items.map((item) => item.path))
|
|
163
|
+
for (const path of Object.keys(state.synced)) {
|
|
164
|
+
if (!here.has(path)) say(` gone ${describe(path, env).shown} (deleted or no longer chosen; Renor still holds it, forget it in the dashboard)`)
|
|
165
|
+
}
|
|
166
|
+
const leftovers = Object.values(state.synced).some((entry) => entry.retire?.length)
|
|
167
|
+
if (dryRun || (pending.length === 0 && !leftovers)) {
|
|
168
|
+
say(dryRun ? 'Dry run: nothing was sent.' : 'Everything is already in sync.')
|
|
169
|
+
return 0
|
|
170
|
+
}
|
|
171
|
+
const token = env.RENOR_TOKEN
|
|
172
|
+
if (!token) { say('Set RENOR_TOKEN to a connect token from the Renor dashboard, then run renor sync again.'); return 2 }
|
|
173
|
+
|
|
174
|
+
let session
|
|
175
|
+
try {
|
|
176
|
+
session = await openSession(env.RENOR_MCP_URL || GATEWAY_URL, token, io.fetchImpl)
|
|
177
|
+
} catch (error) {
|
|
178
|
+
say(error instanceof Error ? error.message : String(error))
|
|
179
|
+
return 2
|
|
180
|
+
}
|
|
181
|
+
let failed = 0
|
|
182
|
+
try {
|
|
183
|
+
// An old copy that could not be retired last time goes first.
|
|
184
|
+
for (const [path, entry] of Object.entries(state.synced)) {
|
|
185
|
+
for (const old of [...(entry.retire ?? [])]) {
|
|
186
|
+
const shown = describe(path, env).shown
|
|
187
|
+
try {
|
|
188
|
+
await retireOld(session, state, entry, old, shown, say, env)
|
|
189
|
+
} catch (error) {
|
|
190
|
+
failed++
|
|
191
|
+
say(` failed ${shown}: could not retire an earlier copy: ${error instanceof Error ? error.message : error}`)
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
for (const item of pending) {
|
|
196
|
+
try {
|
|
197
|
+
// The new copy goes to a folder the file does not use yet, so it can be written while the old one is still
|
|
198
|
+
// there; the old one is retired only after the new one exists, and a failure on the way leaves it as it was.
|
|
199
|
+
const files = [{ relativePath: item.relativePath, markdown: item.markdown }]
|
|
200
|
+
// A folder where a different note already sits at this path (an attempt whose reply was lost) would be a conflict: skip it.
|
|
201
|
+
let label
|
|
202
|
+
let check
|
|
203
|
+
for (const candidate of freeLabels(item.previous)) {
|
|
204
|
+
check = await session.call('preview_import', { label: candidate, files, stage: false })
|
|
205
|
+
if (check.secretGuard || check.skipped?.length || !check.conflicts?.length) { label = candidate; break }
|
|
206
|
+
}
|
|
207
|
+
if (!label) throw new Error('no free folder for this file in Renor')
|
|
208
|
+
if (check.secretGuard || check.skipped?.length) throw new Error('Renor would leave this file out (it looks like it holds a secret, or is empty); the earlier copy was kept')
|
|
209
|
+
const preview = await session.call('preview_import', { label, files })
|
|
210
|
+
// Written down before apply: if the reply to apply is lost the batch may still have been applied, and every
|
|
211
|
+
// batch this file has had must be retired later or its text stays in Renor beside the current one.
|
|
212
|
+
const tried = item.previous ?? (state.synced[item.path] = { lost: [] })
|
|
213
|
+
tried.lost = [...(tried.lost ?? []), { batchId: preview.batchId, label }]
|
|
214
|
+
saveState(env, state)
|
|
215
|
+
const applied = await session.call('apply_import', { batchId: preview.batchId })
|
|
216
|
+
if (applied.notesConflicting) throw new Error('a different note is already at that place in Renor')
|
|
217
|
+
// Identical text already there (an earlier run whose reply was lost) counts: the batch asserts it again.
|
|
218
|
+
if ((applied.notesWritten ?? 0) + (applied.notesUnchanged ?? 0) !== 1) throw new Error('Renor wrote nothing for this file')
|
|
219
|
+
const before = item.previous ?? tried
|
|
220
|
+
const retire = [
|
|
221
|
+
...(before.retire ?? []),
|
|
222
|
+
...(before.batchId ? [{ batchId: before.batchId, label: before.label ?? LABEL }] : []),
|
|
223
|
+
...(before.lost ?? []).filter((copy) => copy.batchId !== applied.batchId),
|
|
224
|
+
]
|
|
225
|
+
const entry = { hash: item.hash, batchId: applied.batchId, label, syncedAt: new Date().toISOString() }
|
|
226
|
+
if (retire.length) entry.retire = retire
|
|
227
|
+
if (before.kept?.length) entry.kept = before.kept
|
|
228
|
+
state.synced[item.path] = entry
|
|
229
|
+
saveState(env, state)
|
|
230
|
+
let kept = false
|
|
231
|
+
for (const old of retire) {
|
|
232
|
+
try {
|
|
233
|
+
kept = (await retireOld(session, state, entry, old, item.shown, say, env)) === 'kept' || kept
|
|
234
|
+
} catch (error) {
|
|
235
|
+
failed++
|
|
236
|
+
say(` failed ${item.shown}: the new copy is in, the earlier one could not be retired (it is retried next run): ${error instanceof Error ? error.message : error}`)
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
if (!kept) say(` synced ${item.shown}`)
|
|
240
|
+
} catch (error) {
|
|
241
|
+
failed++
|
|
242
|
+
say(` failed ${item.shown}: ${error instanceof Error ? error.message : error}`)
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
} finally {
|
|
246
|
+
await session.close()
|
|
247
|
+
}
|
|
248
|
+
return failed ? 1 : 0
|
|
249
|
+
}
|