@100xprompt/brain 0.1.4 → 0.1.6
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 +41 -1
- package/package.json +1 -1
- package/src/adapters/amazon-q-ide/writer.ts +3 -1
- package/src/adapters/antigravity/reader.ts +25 -7
- package/src/adapters/claude-code/reader.ts +4 -0
- package/src/adapters/claude-desktop/index.ts +8 -0
- package/src/adapters/claude-desktop/reader.ts +140 -0
- package/src/adapters/claude-desktop/records.ts +191 -0
- package/src/adapters/claude-desktop/writer.ts +112 -0
- package/src/adapters/codex/reader.ts +19 -5
- package/src/adapters/codex/records.ts +17 -0
- package/src/adapters/codex/writer.ts +20 -7
- package/src/adapters/codex-desktop/index.ts +38 -0
- package/src/adapters/index.ts +4 -0
- package/src/cli.ts +79 -71
- package/src/dashboard/cli.ts +17 -2
- package/src/dashboard/report.ts +98 -5
- package/src/dashboard/sessions.ts +127 -0
- package/src/dashboard/state.ts +64 -5
- package/src/gateway/common.ts +8 -0
- package/src/gateway/connect.ts +33 -3
- package/src/gateway/connectors/antigravity.ts +205 -5
- package/src/gateway/connectors/claude-desktop.ts +169 -0
- package/src/gateway/connectors/index.ts +2 -0
- package/src/handoff.ts +18 -2
- package/src/hooks.ts +2 -2
- package/src/lock.ts +3 -0
- package/src/memory/mcp-install.ts +9 -0
- package/src/memory/sync.ts +25 -3
- package/src/moves.ts +402 -0
- package/src/service.ts +490 -0
- package/src/session/open.ts +82 -0
- package/src/session/ops.ts +111 -0
- package/src/settings/account.ts +65 -10
- package/src/settings/catalog.ts +20 -2
- package/src/settings/cli.ts +18 -3
- package/src/settings/env.ts +2 -0
- package/src/settings/pair.ts +3 -0
- package/src/settings/values.ts +28 -0
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
// Listing, finding, reading and moving sessions, for `brain session ...` and for brain's background service (a move asked
|
|
2
|
+
// from the dashboard, docs/sessions-contract.md). Nothing here prints or exits: callers say what happened.
|
|
3
|
+
import { resolve } from "node:path"
|
|
4
|
+
import type { SessionRef, Way, WriteResult } from "../adapters/types.ts"
|
|
5
|
+
import { toolLabel } from "../brief.ts"
|
|
6
|
+
import { prepare, sessionTokens } from "../memory/prepare.ts"
|
|
7
|
+
import type { Session } from "./types.ts"
|
|
8
|
+
|
|
9
|
+
// Adapters load only for what uses them: every tool's session-start hook runs cli.ts, and one adapter failing to load
|
|
10
|
+
// (or loading slowly) must not hold up or break a session in another tool.
|
|
11
|
+
let loaded: typeof import("../adapters/index.ts") | undefined
|
|
12
|
+
export const loadAdapters = async () => (loaded ??= await import("../adapters/index.ts"))
|
|
13
|
+
|
|
14
|
+
/** Every session brain can read under `home` (one tool's with `tool`), newest first, and the tools it could not list. */
|
|
15
|
+
export async function listSessions(home: string, tool?: string): Promise<{ refs: SessionRef[]; errors: string[] }> {
|
|
16
|
+
const { READERS } = await loadAdapters()
|
|
17
|
+
const readers = tool ? READERS.filter((r) => r.tool === tool) : READERS
|
|
18
|
+
const errors: string[] = []
|
|
19
|
+
const lists = await Promise.all(
|
|
20
|
+
readers.map(async (r) => {
|
|
21
|
+
try {
|
|
22
|
+
return await r.list(home)
|
|
23
|
+
} catch (e) {
|
|
24
|
+
errors.push(`${r.tool}: ${(e as Error).message}`)
|
|
25
|
+
return []
|
|
26
|
+
}
|
|
27
|
+
}),
|
|
28
|
+
)
|
|
29
|
+
return { refs: lists.flat().sort((a, b) => (b.updatedAt ?? "").localeCompare(a.updatedAt ?? "")), errors }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Whether a Claude Desktop session's transcripts (the current one or an earlier one) include Claude Code transcript `id`. */
|
|
33
|
+
export const desktopHolds = (r: SessionRef, id: string) =>
|
|
34
|
+
r.extra?.cliSessionId === id || (Array.isArray(r.extra?.priorCliSessionIds) && (r.extra.priorCliSessionIds as unknown[]).includes(id))
|
|
35
|
+
|
|
36
|
+
/** A session by `<tool>:<id>` (as `brain session list` prints it) or a unique start of its id. */
|
|
37
|
+
export async function findSession(home: string, given: string): Promise<{ ref: SessionRef } | { error: string }> {
|
|
38
|
+
// An id copied from an older, shortened list ends in "…": what comes before it is still a unique start.
|
|
39
|
+
const id = given.replace(/…$/, "")
|
|
40
|
+
const { readerFor } = await loadAdapters()
|
|
41
|
+
const colon = id.indexOf(":")
|
|
42
|
+
const tool = colon > 0 && readerFor(id.slice(0, colon)) ? id.slice(0, colon) : undefined
|
|
43
|
+
const want = tool ? id.slice(colon + 1) : id
|
|
44
|
+
if (!want) return { error: `no session "${id}"; see \`brain session list\`` }
|
|
45
|
+
const { refs } = await listSessions(home, tool)
|
|
46
|
+
const exact = refs.filter((r) => r.sourceId === want)
|
|
47
|
+
const matches = exact.length ? exact : refs.filter((r) => r.sourceId.startsWith(want))
|
|
48
|
+
if (!matches.length && (!tool || tool === "claude-code")) {
|
|
49
|
+
// A Claude Code transcript Claude Desktop keeps a record of (its current one or an earlier one) is listed as its Claude
|
|
50
|
+
// Desktop session (an id from before).
|
|
51
|
+
const desk = (await listSessions(home, "claude-desktop")).refs.find((r) => desktopHolds(r, want))
|
|
52
|
+
if (desk) return { ref: desk }
|
|
53
|
+
}
|
|
54
|
+
if (!matches.length && (!tool || tool === "codex")) {
|
|
55
|
+
// A Codex thread the Codex app started is listed as Codex Desktop's (an id from before).
|
|
56
|
+
const desk = (await listSessions(home, "codex-desktop")).refs.find((r) => r.sourceId === want)
|
|
57
|
+
if (desk) return { ref: desk }
|
|
58
|
+
}
|
|
59
|
+
if (!matches.length) return { error: `no session "${id}"; see \`brain session list\`` }
|
|
60
|
+
if (matches.length > 1) return { error: `"${id}" matches ${matches.length} sessions (${matches.slice(0, 4).map((r) => `${r.tool}:${r.sourceId}`).join(", ")}…); give more of the id` }
|
|
61
|
+
return { ref: matches[0]! }
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export async function readSession(home: string, ref: SessionRef): Promise<Session> {
|
|
65
|
+
const { readerFor } = await loadAdapters()
|
|
66
|
+
return readerFor(ref.tool)!.read(ref, home)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface MoveOptions {
|
|
70
|
+
way?: Way
|
|
71
|
+
cwd?: string
|
|
72
|
+
dryRun: boolean
|
|
73
|
+
lean?: boolean
|
|
74
|
+
noMemory?: boolean
|
|
75
|
+
budget?: number
|
|
76
|
+
tidy?: false
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export type MoveOutcome =
|
|
80
|
+
| { ok: true; result: WriteResult; notes: string[]; skipped: string[] }
|
|
81
|
+
| { ok: false; why: string; notes: string[]; skipped: string[] }
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* `source` continued in `target`: the first of its ways that is ready (or `way`), the session prepared as `brain session
|
|
85
|
+
* continue` prepares it (lean, memory, budget), written, and the move logged for the dashboard. Opens nothing.
|
|
86
|
+
*/
|
|
87
|
+
export async function moveSession(home: string, source: Session, target: string, opts: MoveOptions): Promise<MoveOutcome> {
|
|
88
|
+
const { writersFor, readerFor } = await loadAdapters()
|
|
89
|
+
const candidates = writersFor(target).filter((w) => !opts.way || w.way === opts.way)
|
|
90
|
+
if (!candidates.length) return { ok: false, why: `can't continue in "${target}"${opts.way ? ` by ${opts.way}` : ""}; see \`brain session targets\``, notes: [], skipped: [] }
|
|
91
|
+
const cwd = resolve(opts.cwd ?? source.header.project.cwd ?? process.cwd())
|
|
92
|
+
const prepared = await prepare(source, { home, cwd, dryRun: opts.dryRun, ...(opts.lean !== undefined ? { lean: opts.lean } : {}), ...(opts.noMemory ? { noMemory: true } : {}), ...(opts.budget ? { budget: opts.budget } : {}), ...(opts.tidy === false ? { tidy: false } : {}) })
|
|
93
|
+
const wopts = { home, cwd, dryRun: opts.dryRun, brief: prepared.brief }
|
|
94
|
+
const skipped: string[] = []
|
|
95
|
+
for (const w of candidates) {
|
|
96
|
+
const ready = await w.ready(wopts)
|
|
97
|
+
if (!ready.ok) {
|
|
98
|
+
skipped.push(`${w.way}: ${ready.why}`)
|
|
99
|
+
continue
|
|
100
|
+
}
|
|
101
|
+
const r = await w.write(prepared.session, wopts)
|
|
102
|
+
if (!r.dryRun) {
|
|
103
|
+
// The dashboard's conversion log: tools, way, title, project folder name and sizes; never the chat (src/dashboard/conversions.ts).
|
|
104
|
+
const { logConversion } = await import("../dashboard/conversions.ts")
|
|
105
|
+
const to = r.tool === "cursor" ? "cursor-ide" : r.tool
|
|
106
|
+
logConversion(home, { from: source.header.tool, fromLabel: readerFor(source.header.tool)?.label ?? toolLabel(source.header.tool), to: r.tool, toLabel: readerFor(to)?.label ?? w.label, way: r.way, title: source.header.title, cwd: source.header.project.cwd ?? cwd, turns: source.turns.length, tokens: sessionTokens(prepared.session), lean: prepared.lean })
|
|
107
|
+
}
|
|
108
|
+
return { ok: true, result: r, notes: prepared.notes, skipped }
|
|
109
|
+
}
|
|
110
|
+
return { ok: false, why: `no way to continue in "${target}" right now: ${skipped.join("; ")}`, notes: prepared.notes, skipped }
|
|
111
|
+
}
|
package/src/settings/account.ts
CHANGED
|
@@ -18,7 +18,7 @@ import { gatewayUrl, safeUrl } from "../dashboard/send.ts"
|
|
|
18
18
|
import { brainDir, MACHINE_TOKEN, machineId, machineKeyPath, readMachineToken } from "../dashboard/state.ts"
|
|
19
19
|
import { readOrgConfig } from "../org/config.ts"
|
|
20
20
|
import { type Row, reconcile } from "./apply.ts"
|
|
21
|
-
import { checkValue, parseId } from "./catalog.ts"
|
|
21
|
+
import { checkValue, parseId, resolve } from "./catalog.ts"
|
|
22
22
|
import { type Entry, type Level, readSettingsFile, same, type SettingsDoc, type Source, updateDoc, type Value } from "./store.ts"
|
|
23
23
|
import { memoryOn } from "./values.ts"
|
|
24
24
|
|
|
@@ -40,14 +40,16 @@ export function forgetMachineToken(home: string): boolean {
|
|
|
40
40
|
/**
|
|
41
41
|
* A computer the account knows afresh (paired again after "Remove this machine", or a copied ~/.brain paired as a computer
|
|
42
42
|
* of its own) has an empty document there: every value set at this computer's level here is sent again, and the next fetch
|
|
43
|
-
* starts from nothing, so the account's empty document never erases them.
|
|
43
|
+
* starts from nothing, so the account's empty document never erases them. Not the dashboard's or an admin's: those are the
|
|
44
|
+
* account's to keep (a computer never sends them as its own; the gateway refuses that), and an account that forgot this
|
|
45
|
+
* computer forgot them too.
|
|
44
46
|
*/
|
|
45
47
|
export function sendAllAgain(home: string): void {
|
|
46
48
|
updateDoc(home, (d) => {
|
|
47
49
|
const at = new Date().toISOString()
|
|
48
50
|
for (const [id, e] of Object.entries(d.levels.computer)) {
|
|
49
51
|
const ref = parseId(id)
|
|
50
|
-
if ("error" in ref || ref.family.remote !== "yes") continue
|
|
52
|
+
if ("error" in ref || ref.family.remote !== "yes" || !COMPUTER_SOURCES.includes(e.source)) continue
|
|
51
53
|
d.outbox = d.outbox.filter((c) => !(c.id === id && c.level === "computer"))
|
|
52
54
|
d.outbox.push({ id, level: "computer", value: e.value, source: e.source, at: e.at || at, ...(e.command ? { command: e.command } : {}) })
|
|
53
55
|
}
|
|
@@ -73,6 +75,8 @@ export interface Exchange {
|
|
|
73
75
|
|
|
74
76
|
const LEVEL_OUT: Record<"computer" | "person", "machine" | "account"> = { computer: "machine", person: "account" }
|
|
75
77
|
const SOURCES: Source[] = ["settings", "command", "dashboard", "admin", "local-page"]
|
|
78
|
+
/** The sources a computer sends its changes with; the dashboard's and an admin's come only from the account. */
|
|
79
|
+
const COMPUTER_SOURCES: Source[] = ["settings", "command", "local-page"]
|
|
76
80
|
const isValue = (v: unknown): v is Value => typeof v === "boolean" || (typeof v === "number" && Number.isFinite(v)) || typeof v === "string"
|
|
77
81
|
const obj = (v: unknown): Record<string, any> => (v && typeof v === "object" && !Array.isArray(v) ? (v as Record<string, any>) : {})
|
|
78
82
|
const isObj = (v: unknown) => !!v && typeof v === "object" && !Array.isArray(v)
|
|
@@ -87,6 +91,9 @@ function entryFrom(id: string, level: Level, e: unknown): Entry | undefined {
|
|
|
87
91
|
return { value: x.value, source: SOURCES.includes(x.source) ? x.source : "dashboard", at: x.at, ...(Number.isSafeInteger(x.rev) ? { rev: x.rev } : {}) }
|
|
88
92
|
}
|
|
89
93
|
|
|
94
|
+
const sameEntry = (x: Entry | undefined, y: Entry | undefined) =>
|
|
95
|
+
x === y || (!!x && !!y && same(x.value, y.value) && x.source === y.source && x.at === y.at && x.rev === y.rev)
|
|
96
|
+
|
|
90
97
|
/** "<machine>.<account>" as two numbers; undefined when it is not one. */
|
|
91
98
|
const revParts = (r: unknown): [number, number] | undefined => {
|
|
92
99
|
const m = typeof r === "string" ? /^(\d{1,15})\.(\d{1,15})$/.exec(r) : null
|
|
@@ -120,8 +127,6 @@ export async function exchange(home: string, o: { env?: Env; fetchImpl?: typeof
|
|
|
120
127
|
if (file.problem) return { ...out, why: file.problem === "newer" ? "settings.json was written by a newer brain: update brain to exchange settings here" : "settings.json could not be read: `brain settings set` writes a new one" }
|
|
121
128
|
const base = gatewayUrl(home)
|
|
122
129
|
if (!safeUrl(`${base}/api/brain/settings`)) return { ...out, why: `the gateway address ${base} is not https; the token is never sent over it` }
|
|
123
|
-
// A computer the account knows afresh: everything set here goes again (see sendAllAgain).
|
|
124
|
-
if (file.doc.sync.machine !== machine) sendAllAgain(home)
|
|
125
130
|
const deadline = Date.now() + (o.timeoutMs ?? 3_000)
|
|
126
131
|
const call = async (method: string, path: string, body?: unknown): Promise<{ status: number; json: unknown }> => {
|
|
127
132
|
const left = deadline - Date.now()
|
|
@@ -162,6 +167,14 @@ export async function exchange(home: string, o: { env?: Env; fetchImpl?: typeof
|
|
|
162
167
|
}
|
|
163
168
|
|
|
164
169
|
try {
|
|
170
|
+
// A computer the account knows afresh: everything set here goes again (see sendAllAgain).
|
|
171
|
+
if (file.doc.sync.machine !== machine) sendAllAgain(home)
|
|
172
|
+
// A change this computer cannot send as its own (an older brain queued the dashboard's) would wait for good and keep
|
|
173
|
+
// the account's value from being taken: it leaves the outbox.
|
|
174
|
+
if (readSettingsFile(home).doc.outbox.some((c) => !COMPUTER_SOURCES.includes(c.source)))
|
|
175
|
+
updateDoc(home, (d) => {
|
|
176
|
+
d.outbox = d.outbox.filter((c) => COMPUTER_SOURCES.includes(c.source))
|
|
177
|
+
})
|
|
165
178
|
// 1. Send what changed here.
|
|
166
179
|
const snapshot = readSettingsFile(home).doc.outbox
|
|
167
180
|
for (const level of ["computer", "person"] as const) {
|
|
@@ -220,6 +233,10 @@ export async function exchange(home: string, o: { env?: Env; fetchImpl?: typeof
|
|
|
220
233
|
const stored = revParts(d.sync.rev)
|
|
221
234
|
if (stored && (fetched[0] < stored[0] || fetched[1] < stored[1])) return
|
|
222
235
|
const pending = new Set(d.outbox.map((c) => `${c.level}\0${c.id}`))
|
|
236
|
+
// The levels as they were, to tell what taking the answer changes in the value in force here.
|
|
237
|
+
const before: SettingsDoc = { ...d, levels: { ...d.levels } }
|
|
238
|
+
// The same value written again on the account (a newer rev at another time: the dashboard's "try again").
|
|
239
|
+
const again: string[] = []
|
|
223
240
|
const next: Record<"computer" | "person", Record<string, Entry>> = { computer: {}, person: {} }
|
|
224
241
|
for (const [level, srv] of [
|
|
225
242
|
["computer", j.machine.entries],
|
|
@@ -235,8 +252,14 @@ export async function exchange(home: string, o: { env?: Env; fetchImpl?: typeof
|
|
|
235
252
|
const e = entryFrom(id, level, raw)
|
|
236
253
|
if (!e) continue
|
|
237
254
|
const was = d.levels[level][id]
|
|
238
|
-
|
|
239
|
-
|
|
255
|
+
if (was && same(was.value, e.value) && was.source === e.source) {
|
|
256
|
+
if (e.rev !== undefined && was.rev !== undefined && e.rev > was.rev && e.at !== was.at) {
|
|
257
|
+
next[level][id] = { ...e, ...(was.command ? { command: was.command } : {}) }
|
|
258
|
+
again.push(id)
|
|
259
|
+
}
|
|
260
|
+
// The same value from the same source: kept as it is here (with the command that set it).
|
|
261
|
+
else next[level][id] = { ...was, ...(e.rev !== undefined ? { rev: e.rev } : {}) }
|
|
262
|
+
} else next[level][id] = e
|
|
240
263
|
}
|
|
241
264
|
}
|
|
242
265
|
// Memory turned on from afar on a computer that sends it to a team server: only this computer turns that on. Judged
|
|
@@ -275,6 +298,20 @@ export async function exchange(home: string, o: { env?: Env; fetchImpl?: typeof
|
|
|
275
298
|
const locks: SettingsDoc["locks"] = {}
|
|
276
299
|
for (const [id, l] of Object.entries(obj(j.locks))) if (Object.hasOwn(obj(j.locks), id) && isValue(obj(l).value) && !("error" in parseId(id))) locks[id] = { value: obj(l).value }
|
|
277
300
|
d.locks = locks
|
|
301
|
+
// The value in force here set from the account (set, set again, the same value from another source, or cleared down
|
|
302
|
+
// to another level's equal value) is applied afresh, even where it failed or was changed by hand since: that is the
|
|
303
|
+
// person setting it (again). A change at a level that does not decide here applies nothing.
|
|
304
|
+
// A setting with nothing to apply set again is still taken, so what it gave is said (below, after applying).
|
|
305
|
+
for (const id of new Set([...out.received, ...again])) {
|
|
306
|
+
const ref = parseId(id)
|
|
307
|
+
if ("error" in ref) continue
|
|
308
|
+
const a = resolve(d, ref)
|
|
309
|
+
if (a.value === undefined || (a.from !== "computer" && a.from !== "person" && a.from !== "team")) continue
|
|
310
|
+
const b = resolve(before, ref)
|
|
311
|
+
if (b.from === a.from && sameEntry(b.entry, a.entry)) continue
|
|
312
|
+
if (ref.family.apply) delete d.applied[id]
|
|
313
|
+
if (!out.received.includes(id)) out.received.push(id)
|
|
314
|
+
}
|
|
278
315
|
d.history = d.history.slice(-200)
|
|
279
316
|
d.sync.rev = `${fetched[0]}.${fetched[1]}`
|
|
280
317
|
d.sync.lastSyncAt = now
|
|
@@ -295,13 +332,31 @@ export async function exchange(home: string, o: { env?: Env; fetchImpl?: typeof
|
|
|
295
332
|
|
|
296
333
|
// 4. Apply what changed, and say what it gave (a value held back here counts as failed).
|
|
297
334
|
if (o.apply !== false) {
|
|
335
|
+
let elsewhere: string[] = []
|
|
298
336
|
try {
|
|
299
|
-
|
|
337
|
+
const r = await reconcile(home, { env: o.env ?? process.env })
|
|
338
|
+
out.applied = r.applied
|
|
339
|
+
elsewhere = r.elsewhere
|
|
300
340
|
} catch {}
|
|
301
341
|
const applied: Record<string, unknown> = {}
|
|
342
|
+
const at = new Date().toISOString()
|
|
302
343
|
for (const row of out.applied)
|
|
303
|
-
applied[row.id] = { want: row.value ?? null, ok: row.state !== "failed" && row.state !== "not on this computer" && row.state !== "can't read", ...(row.error ? { error: row.error } : {}), ...(row.notes.length ? { notes: row.notes.slice(0, 5) } : {}), at
|
|
304
|
-
|
|
344
|
+
applied[row.id] = { want: row.value ?? null, ok: row.state !== "failed" && row.state !== "not on this computer" && row.state !== "can't read", ...(row.error ? { error: row.error } : {}), ...(row.notes.length ? { notes: row.notes.slice(0, 5) } : {}), at }
|
|
345
|
+
// A value taken that has nothing to apply (brain reads it where it is used: lean.*, memory.enabled) is in force once
|
|
346
|
+
// taken; one for a tool not on this computer waits for that tool. Both are said, so the dashboard never waits on them.
|
|
347
|
+
// (A value in force set from the account is applied afresh above, so it is among the applied rows.)
|
|
348
|
+
if (out.received.length) {
|
|
349
|
+
const doc = readSettingsFile(home).doc
|
|
350
|
+
for (const id of out.received) {
|
|
351
|
+
const ref = parseId(id)
|
|
352
|
+
if ("error" in ref || Object.hasOwn(applied, id) || out.held.some((h) => h.id === id)) continue
|
|
353
|
+
const want = resolve(doc, ref).value
|
|
354
|
+
if (want === undefined) continue
|
|
355
|
+
if (!ref.family.apply) applied[id] = { want, ok: true, at }
|
|
356
|
+
else if (elsewhere.includes(id)) applied[id] = { want, ok: false, error: `${ref.tool ?? "this tool"} is not on this computer: brain applies it once it is`, at }
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
for (const h of out.held) applied[h.id] = { want: true, ok: false, error: h.why, at }
|
|
305
360
|
if (Object.keys(applied).length && Date.now() < deadline) {
|
|
306
361
|
try {
|
|
307
362
|
await call("POST", "/api/brain/settings/applied", { machine, applied })
|
package/src/settings/catalog.ts
CHANGED
|
@@ -168,7 +168,7 @@ async function reconnect(ctx: Ctx, tool: string, change: { model?: string; conte
|
|
|
168
168
|
if (cs.state === "not-connected") return { ok: true }
|
|
169
169
|
if (!cs.settings) return { ok: false, error: olderBrain(tool) }
|
|
170
170
|
if (!hasKey(ctx.home)) return { ok: false, error: "not signed in to the gateway (brain gateway login)" }
|
|
171
|
-
const r = await connect(ctx.home, tool, { settings: connectSettings(ctx, tool, change), probe: false, env: ctx.env })
|
|
171
|
+
const r = await connect(ctx.home, tool, { settings: connectSettings(ctx, tool, change), probe: false, env: ctx.env, keepTheirs: { for: change.model !== undefined ? "model" : "contextEdit" } })
|
|
172
172
|
return r.ok ? { ok: true, notes: notesOf(r.lines, ctx.home, /^warning: /) } : { ok: false, error: cleanText(r.lines.join("; "), ctx.home) }
|
|
173
173
|
}
|
|
174
174
|
|
|
@@ -397,7 +397,25 @@ const reports: Family = {
|
|
|
397
397
|
},
|
|
398
398
|
}
|
|
399
399
|
|
|
400
|
-
|
|
400
|
+
// Only this computer decides whether the website may move its sessions, and whether its session list leaves it.
|
|
401
|
+
const movesFromDashboard = internal("moves.fromDashboard", {
|
|
402
|
+
label: "Moves from the dashboard",
|
|
403
|
+
help: "A session move asked from your dashboard: ask (this computer asks you first), allow (it runs), or off (it is refused).",
|
|
404
|
+
type: { kind: "choice", choices: ["ask", "allow", "off"] },
|
|
405
|
+
levels: ["computer"],
|
|
406
|
+
remote: "no",
|
|
407
|
+
team: "none",
|
|
408
|
+
})
|
|
409
|
+
const reportsSessions = internal("reports.sessions", {
|
|
410
|
+
label: "Session list to your account",
|
|
411
|
+
help: "This computer sends its session list (titles and project folder names; never chat text) so the dashboard can list and move sessions.",
|
|
412
|
+
type: { kind: "bool" },
|
|
413
|
+
levels: ["computer"],
|
|
414
|
+
remote: "no",
|
|
415
|
+
team: "none",
|
|
416
|
+
})
|
|
417
|
+
|
|
418
|
+
export const FAMILIES: Family[] = [gateway, model, contextEdit, mcp, hooks, leanAuto, leanThreshold, leanMinChars, memoryEnabled, reports, reportsSessions, movesFromDashboard]
|
|
401
419
|
|
|
402
420
|
// ---- ids and values -----------------------------------------------------------------------------------------------------
|
|
403
421
|
|
package/src/settings/cli.ts
CHANGED
|
@@ -173,6 +173,9 @@ async function withAccount(home: string, env: Env, say: (l: string) => void, fet
|
|
|
173
173
|
return x
|
|
174
174
|
}
|
|
175
175
|
|
|
176
|
+
/** An exchange that took or applied something changed what the report says (a cleared value shows only there). */
|
|
177
|
+
export const tookSomething = (x: Exchange | undefined) => !!x?.ok && (x.received.length > 0 || x.applied.length > 0)
|
|
178
|
+
|
|
176
179
|
/**
|
|
177
180
|
* `brain settings ...`. `hooks.changed` is called when a set or unset saved something (the dashboard report is sent then,
|
|
178
181
|
* not after a refusal). A paired computer exchanges with the account first (and, after a change, again).
|
|
@@ -195,7 +198,7 @@ export async function settingsCommand(
|
|
|
195
198
|
if (file.problem === "damaged") note(`note: ${settingsFile(home)} could not be read: brain's defaults apply (memory counts as off), and it is kept aside on the next change`)
|
|
196
199
|
// What the account changed is taken (and applied) before anything is shown or changed here; the whole command keeps to 3 s.
|
|
197
200
|
const deadline = Date.now() + 3_000
|
|
198
|
-
if (["list", "get", "history", "set", "unset"].includes(sub) && !file.problem
|
|
201
|
+
if (["list", "get", "history", "set", "unset"].includes(sub) && !file.problem && tookSomething(await withAccount(home, env, note, hooks.fetchImpl, deadline))) hooks.changed?.()
|
|
199
202
|
switch (sub) {
|
|
200
203
|
case "list": {
|
|
201
204
|
const ti = rest.indexOf("--tool")
|
|
@@ -262,7 +265,7 @@ export async function settingsCommand(
|
|
|
262
265
|
if (o.change) hooks.changed?.()
|
|
263
266
|
const code = printOutcome(o, io, sub === "set" ? "saved" : "cleared")
|
|
264
267
|
// The change goes to the account at once.
|
|
265
|
-
if (o.change
|
|
268
|
+
if (o.change && tookSomething(await withAccount(home, env, io.out, hooks.fetchImpl, deadline))) hooks.changed?.()
|
|
266
269
|
return code
|
|
267
270
|
}
|
|
268
271
|
case "history": {
|
|
@@ -319,8 +322,10 @@ const productName = (alias: unknown) => (typeof alias === "string" && MODEL_ALIA
|
|
|
319
322
|
* `brain status`: this computer at a glance, with whether its settings are applied. On a paired computer it first exchanges
|
|
320
323
|
* with the account (so a change made in the dashboard is taken and applied); otherwise it only reads.
|
|
321
324
|
*/
|
|
322
|
-
export async function statusCommand(home: string, io: Io = { out: (l) => console.log(l) }, env: Env = process.env, deps: { fetchImpl?: typeof fetch } = {}): Promise<number> {
|
|
325
|
+
export async function statusCommand(home: string, io: Io = { out: (l) => console.log(l) }, env: Env = process.env, deps: { fetchImpl?: typeof fetch; changed?: () => void } = {}): Promise<number> {
|
|
323
326
|
const exchanged = readSettingsFile(home).problem ? undefined : await exchange(home, { env, ...(deps.fetchImpl ? { fetchImpl: deps.fetchImpl } : {}) })
|
|
327
|
+
// What it took from the account goes to the dashboard in a report too (the computer may have no background service).
|
|
328
|
+
if (tookSomething(exchanged)) deps.changed?.()
|
|
324
329
|
const { VERSION } = await import("../self.ts")
|
|
325
330
|
const { machineName, gatewayState } = await import("../dashboard/report.ts")
|
|
326
331
|
const { machineId, readCache } = await import("../dashboard/state.ts")
|
|
@@ -392,6 +397,13 @@ export async function statusCommand(home: string, io: Io = { out: (l) => console
|
|
|
392
397
|
for (const h of acct.held) io.out(` ${h.id}: not taken from your account: ${h.why}`)
|
|
393
398
|
const refused = acct.refused.slice(-3)
|
|
394
399
|
if (refused.length) io.out(` refused by your account (this computer went back to its value): ${refused.map((r) => `${r.id} = ${String(r.value)} (${r.message || r.code})`).join("; ")}`)
|
|
400
|
+
const { serviceStatusLines } = await import("../service.ts")
|
|
401
|
+
if (acct.paired) serviceStatusLines(home).forEach((l) => io.out(l))
|
|
402
|
+
if (acct.paired) {
|
|
403
|
+
const { movesState } = await import("../moves.ts")
|
|
404
|
+
const mv = movesState(home)
|
|
405
|
+
if (mv.waiting) io.out(`moves from the dashboard: ${mv.waiting} wait${mv.waiting === 1 ? "s" : ""} for you here (brain moves)`)
|
|
406
|
+
}
|
|
395
407
|
const recorded = readRecordedEnv(home)
|
|
396
408
|
// As the code that obeys them reads them: BRAIN_NO_REPORT anything but empty, 0 or false; BRAIN_NO_MEMORY exactly 1.
|
|
397
409
|
const envOff = [...(off && /BRAIN_NO_REPORT/.test(off) ? ["BRAIN_NO_REPORT"] : []), ...(env.BRAIN_NO_MEMORY === "1" ? ["BRAIN_NO_MEMORY"] : [])]
|
|
@@ -432,6 +444,9 @@ export async function syncCommand(home: string, io: Io = { out: (l) => console.l
|
|
|
432
444
|
else {
|
|
433
445
|
noticeOnce(home, io.out)
|
|
434
446
|
const { buildReport } = await import("../dashboard/report.ts")
|
|
447
|
+
const { sendSessions } = await import("../dashboard/sessions.ts")
|
|
448
|
+
const listSent = await sendSessions(home, { env, force: true, ...(deps.fetchImpl ? { fetchImpl: deps.fetchImpl } : {}) })
|
|
449
|
+
if (listSent.sent) io.out(`sessions: sent this computer's list (${listSent.total}) for the dashboard`)
|
|
435
450
|
const sent = await sendReport(home, await buildReport(home, { env }), { timeoutMs: 5_000, ...(deps.fetchImpl ? { fetchImpl: deps.fetchImpl } : {}) })
|
|
436
451
|
io.out(sent.ok ? `report: sent to ${gatewayUrl(home)}` : `report: not sent (${sent.why})`)
|
|
437
452
|
}
|
package/src/settings/env.ts
CHANGED
|
@@ -95,6 +95,8 @@ export function isPersonsCommand(argv: string[]): boolean {
|
|
|
95
95
|
// The session hook (run by a tool), the MCP server (started by a tool), and the background children brain starts.
|
|
96
96
|
if (cmd === "hook" || (cmd === "session" && rest[0] === "hook")) return false
|
|
97
97
|
if (cmd === "mcp" && rest[0] === "serve") return false
|
|
98
|
+
// The background service is started by launchd or systemd, with the environment it was given, not the person's shell.
|
|
99
|
+
if (cmd === "service" && rest[0] === "run") return false
|
|
98
100
|
if (cmd === "dashboard" && rest[0] === "push" && (rest.includes("--quiet") || rest.includes("--at"))) return false
|
|
99
101
|
if (cmd === "memory" && rest[0] === "sync" && rest.includes("--quiet")) return false
|
|
100
102
|
return true
|
package/src/settings/pair.ts
CHANGED
|
@@ -79,5 +79,8 @@ export async function pairCommand(args: string[], home: string, io: Io = { out:
|
|
|
79
79
|
io.out(`paired${had ? " again" : ""}: ${machineName()} is linked to your 100xprompt account; its token is in ~/.brain/machine.key (readable only by you).`)
|
|
80
80
|
io.out(x.ok ? `settings: ${x.sent} change${x.sent === 1 ? "" : "s"} made here sent to your account; the dashboard's Brain view shows this computer's settings.` : `settings: not exchanged yet (${x.why}); \`brain sync\` tries again.`)
|
|
81
81
|
if (x.ok) for (const l of exchangeLines({ ...x, sent: 0 }, 0)) io.out(l)
|
|
82
|
+
const { serviceState } = await import("../service.ts")
|
|
83
|
+
// macOS and Linux only for now; elsewhere `brain sync` takes the dashboard's changes.
|
|
84
|
+
if ((process.platform === "darwin" || process.platform === "linux") && !serviceState(home).installed) io.out("next: `brain service install` runs brain in the background, so changes you make in the dashboard apply here by themselves.")
|
|
82
85
|
return 0
|
|
83
86
|
}
|
package/src/settings/values.ts
CHANGED
|
@@ -12,15 +12,22 @@ export const LIMITS = {
|
|
|
12
12
|
"lean.minChars": { min: 300, max: 100_000 },
|
|
13
13
|
} as const
|
|
14
14
|
|
|
15
|
+
/** Whether a session move asked from the dashboard runs here: ask the person first, run it, or refuse it. */
|
|
16
|
+
export const MOVES_CHOICES = ["ask", "allow", "off"] as const
|
|
17
|
+
export type MovesSetting = (typeof MOVES_CHOICES)[number]
|
|
18
|
+
|
|
15
19
|
export const DEFAULTS: Record<string, Value> = {
|
|
16
20
|
"lean.auto": LEAN_DEFAULTS.auto,
|
|
17
21
|
"lean.threshold": LEAN_DEFAULTS.threshold,
|
|
18
22
|
"lean.minChars": LEAN_DEFAULTS.minChars,
|
|
19
23
|
"memory.enabled": true,
|
|
24
|
+
"moves.fromDashboard": "ask",
|
|
25
|
+
"reports.sessions": true,
|
|
20
26
|
}
|
|
21
27
|
|
|
22
28
|
/** Whether a value is a valid one for this setting (a hand-edited or newer file may hold anything). */
|
|
23
29
|
export function validValue(id: string, v: Value): boolean {
|
|
30
|
+
if (id === "moves.fromDashboard") return typeof v === "string" && (MOVES_CHOICES as readonly string[]).includes(v)
|
|
24
31
|
if (typeof DEFAULTS[id] === "boolean") return typeof v === "boolean"
|
|
25
32
|
const lim = LIMITS[id as keyof typeof LIMITS]
|
|
26
33
|
return typeof v === "number" && Number.isInteger(v) && (!lim || (v >= lim.min && v <= lim.max))
|
|
@@ -55,6 +62,27 @@ export function memoryOn(home: string, d?: SettingsDoc): boolean {
|
|
|
55
62
|
return r.problem === "damaged" ? false : (valueOf(r.doc, "memory.enabled") as boolean)
|
|
56
63
|
}
|
|
57
64
|
|
|
65
|
+
/** A setting only this computer can have: its own level counts, nothing set from elsewhere or by a team. */
|
|
66
|
+
const localValue = (d: SettingsDoc, id: string): Value => {
|
|
67
|
+
const e = d.levels.computer[id]
|
|
68
|
+
return e && validValue(id, e.value) ? e.value : DEFAULTS[id]!
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Whether moves asked from the dashboard run here (`brain settings set moves.fromDashboard ask|allow|off`; ask by default). */
|
|
72
|
+
export function movesSetting(home: string, d?: SettingsDoc): MovesSetting {
|
|
73
|
+
if (d) return localValue(d, "moves.fromDashboard") as MovesSetting
|
|
74
|
+
const r = readSettingsFile(home)
|
|
75
|
+
// A file that cannot be read gives no permission: the person is asked.
|
|
76
|
+
return r.problem === "damaged" ? "ask" : (localValue(r.doc, "moves.fromDashboard") as MovesSetting)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Whether this computer sends its session list (titles, project folder names) to the account; on by default. */
|
|
80
|
+
export function sessionsListOn(home: string, d?: SettingsDoc): boolean {
|
|
81
|
+
if (d) return localValue(d, "reports.sessions") as boolean
|
|
82
|
+
const r = readSettingsFile(home)
|
|
83
|
+
return r.problem === "damaged" ? false : (localValue(r.doc, "reports.sessions") as boolean)
|
|
84
|
+
}
|
|
85
|
+
|
|
58
86
|
/** What brain says where memory would have been used. */
|
|
59
87
|
export const MEMORY_OFF = "memory is turned off on this computer (`brain settings set memory.enabled on` turns it on)"
|
|
60
88
|
|