@assuredloop/orca-bot-kit 0.35.0 → 0.36.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -403,6 +403,18 @@ bot's sessions, set `"auto-mode-setup": "on"` under `skillOverrides` in the bot'
403
403
  `.claude/settings.local.json`, which wins over the kit's entry. The kit never
404
404
  writes that file.
405
405
 
406
+ Under an Orca Claude account, the kit's line for a Claude session also carries
407
+ `--settings` with one key, `autoMemoryDirectory`. It names the physical folder
408
+ behind the account's linked `projects` folder, so a session saves its memory
409
+ with no permission dialog. The memory is the same memory, and the kit writes no
410
+ file for this. A bot, its repository or an account that names its own
411
+ `autoMemoryDirectory` keeps it. A conversation that began before this keeps its old path until the
412
+ session restarts under this version and then begins a new conversation with
413
+ `/clear`. A restart alone or a `/clear` alone does not change the path. After
414
+ Orca brings a tab back by itself, a `/clear` before a
415
+ restart brings the dialog back; `obk health` names such a session
416
+ ([ADR 0054](docs/adr/0054-the-kits-launch-line-names-the-physical-memory-folder.md)).
417
+
406
418
  So: kill a tab, or reboot, and `obk up` brings the session back with the
407
419
  conversation it was having, rather than starting a new one. Clear a session —
408
420
  `/clear` on Claude Code, `/new` on Codex — and the kit writes down the new id,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@assuredloop/orca-bot-kit",
3
- "version": "0.35.0",
3
+ "version": "0.36.0",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -20,7 +20,7 @@ import { eachLine, firstLine } from './lines.js';
20
20
  const claudeDir = (home) => path.join(homedir(), '.claude', 'projects', slug(home));
21
21
 
22
22
  /** The folder's name is the working directory with everything else turned into a dash. */
23
- const slug = (home) => home.replaceAll(/[^A-Za-z0-9]/g, '-');
23
+ export const slug = (home) => home.replaceAll(/[^A-Za-z0-9]/g, '-');
24
24
 
25
25
  /**
26
26
  * Pi's sessions: one folder per working directory, named from its real path
package/src/launch.js CHANGED
@@ -294,11 +294,12 @@ const NO_DAEMON = '--no-daemon';
294
294
 
295
295
  // A string is shell text, so it is read as the shell will split it: `'--no-daemon'`
296
296
  // is the flag, and `'/tmp/a --no-daemon b'` is one path that merely says it.
297
- const setsNoDaemon = (session) => {
298
- const extra = session.extra_args;
299
- const words = Array.isArray(extra) ? extra.map(String) : set(extra) ? shellWords(String(extra)) : [];
300
- return words.includes(NO_DAEMON);
301
- };
297
+ const extraArgWords = (extra) => (Array.isArray(extra) ? extra.map(String) : set(extra) ? shellWords(String(extra)) : []);
298
+
299
+ const setsNoDaemon = (session) => extraArgWords(session.extra_args).includes(NO_DAEMON);
300
+
301
+ /** Whether the session's own extra arguments hold Claude Code's `--settings`. */
302
+ const setsSettings = (session) => extraArgWords(session.extra_args).some((word) => word === '--settings' || word.startsWith('--settings='));
302
303
 
303
304
  /**
304
305
  * The words a shell makes of `text`: split on blanks outside quotes, quotes
@@ -412,7 +413,7 @@ const where = (session) => (set(session.name) ? `session ${session.name}` : 'thi
412
413
  * send would land on that list and answer it. A prompt given as an argument is
413
414
  * held by the harness until it is ready for it (tech notes, section 1).
414
415
  */
415
- export function launchCommand(session, { harness, home, workDir, prompt, promptFile: fromFile, resume, address, kit, sessionId, name }) {
416
+ export function launchCommand(session, { harness, home, workDir, prompt, promptFile: fromFile, resume, address, kit, sessionId, name, memoryFolder }) {
416
417
  const trouble = sessionTrouble(session, harness, home);
417
418
  if (trouble !== undefined) throw new Error(trouble);
418
419
  // Pi is told which conversation it is, a new one too: the kit names it, and
@@ -440,6 +441,10 @@ export function launchCommand(session, { harness, home, workDir, prompt, promptF
440
441
  // The context window rides on the model name: `sonnet[1m]`.
441
442
  if (set(session.model)) words.push('--model', set(session.context) ? `${session.model}[${session.context}]` : session.model);
442
443
  if (set(session.effort)) words.push('--effort', String(session.effort));
444
+ // This one key and no other: `--settings` can carry rules, folders and
445
+ // hooks, which is why a user's extra argument may not use it (ADR 0054,
446
+ // ADR 0049). A hand-written one in bot.yaml is the user's, and wins.
447
+ if (memoryFolder !== undefined && !setsSettings(session)) words.push('--settings', JSON.stringify({ autoMemoryDirectory: memoryFolder }));
443
448
  } else if (harness === 'pi') {
444
449
  // Pi trusts none of the folder's own files (`.pi/`, `.agents/skills`), so it
445
450
  // shows no trust screen and runs nothing the kit has not looked at (#578).
package/src/memory.js ADDED
@@ -0,0 +1,113 @@
1
+ // The physical memory folder of a Claude session, when the path Claude Code
2
+ // would use leads through a link (#722, ADR 0054).
3
+ //
4
+ // Orca starts a Claude tab with CLAUDE_CONFIG_DIR at a profile of its own,
5
+ // where `projects` is a link to `~/.claude/projects`. Claude Code names its
6
+ // memory folder through that link, and a write there stops on a dialog that
7
+ // only a person can answer (tech notes, section 2). Named by its real path, the
8
+ // same folder has no dialog. Whenever the answer is not sure, there is no
9
+ // answer, and the session runs as it did: a dialog costs a wait, and a wrong
10
+ // folder costs the session its memory.
11
+
12
+ import { spawnSync } from 'node:child_process';
13
+ import { existsSync, lstatSync, readFileSync, realpathSync, statSync } from 'node:fs';
14
+ import path from 'node:path';
15
+
16
+ import { slug } from './conversations.js';
17
+ import { selectedClaudeProfile } from './orca.js';
18
+
19
+ /** The longest slug Claude Code uses as it is. Past this it adds a hash of its own. */
20
+ const LONGEST_SLUG = 200;
21
+
22
+ /**
23
+ * The folder to name for a session of the bot at `home`, or undefined when the
24
+ * kit should name none: no profile is routed, its `projects` is no link, the
25
+ * bot or the profile names a folder of its own, the project root is not sure,
26
+ * or the slug is too long.
27
+ */
28
+ export function physicalMemoryFolder(home) {
29
+ const profile = selectedClaudeProfile();
30
+ if (profile === undefined) return undefined;
31
+ const projects = linkedFolder(path.join(profile, 'projects'));
32
+ if (projects === undefined) return undefined;
33
+ const root = projectRoot(home);
34
+ if (root === undefined) return undefined;
35
+ // Claude Code reads a project's local settings from the top of its repository as well as from the bot's folder.
36
+ const named = [
37
+ path.join(home, '.claude', 'settings.json'),
38
+ path.join(home, '.claude', 'settings.local.json'),
39
+ path.join(root, '.claude', 'settings.local.json'),
40
+ path.join(profile, 'settings.json'),
41
+ ];
42
+ if (named.some(namesMemoryFolder)) return undefined;
43
+ // Claude Code holds its project root in NFC, whatever form the disk has.
44
+ const name = slug(root.normalize('NFC'));
45
+ return name.length > LONGEST_SLUG ? undefined : path.join(projects, name, 'memory');
46
+ }
47
+
48
+ /** The real path of the folder the link `entry` leads to, or undefined when it is not such a link. */
49
+ function linkedFolder(entry) {
50
+ try {
51
+ if (!lstatSync(entry).isSymbolicLink()) return undefined;
52
+ return statSync(entry).isDirectory() ? realpathSync(entry) : undefined;
53
+ } catch {
54
+ return undefined;
55
+ }
56
+ }
57
+
58
+ /**
59
+ * Whether the settings file `file` may name a memory folder of its own: it has
60
+ * the key, or it is there and cannot be read, so the kit cannot tell.
61
+ */
62
+ function namesMemoryFolder(file) {
63
+ let text;
64
+ try {
65
+ text = readFileSync(file, 'utf8');
66
+ } catch (error) {
67
+ return error.code !== 'ENOENT' && error.code !== 'ENOTDIR';
68
+ }
69
+ try {
70
+ const settings = JSON.parse(text);
71
+ return settings !== null && typeof settings === 'object' && Object.hasOwn(settings, 'autoMemoryDirectory');
72
+ } catch {
73
+ return true;
74
+ }
75
+ }
76
+
77
+ /**
78
+ * The folder Claude Code keys memory by: the top of the git repository that
79
+ * holds `home`, the main repository's for a linked worktree, since every
80
+ * worktree of a repository shares one memory (tech notes, section 2). With no
81
+ * repository, it is `home` itself. Undefined when the kit is not sure: a `.git`
82
+ * is on the way and git cannot say what it is, a linked worktree's shared git
83
+ * folder is not named `.git`, or the `.git` at the top is a link.
84
+ */
85
+ function projectRoot(home) {
86
+ const shared = revParse(home, '--git-common-dir');
87
+ if (shared === undefined) return gitOnTheWay(home) ? undefined : realpathSync(home);
88
+ const own = revParse(home, '--git-dir');
89
+ if (own === undefined) return undefined;
90
+ const main = realpathSync(path.resolve(home, shared));
91
+ const linked = realpathSync(path.resolve(home, own)) !== main;
92
+ // Only a linked worktree tells its main repository by the shared git folder:
93
+ // `--separate-git-dir` puts a repository's own git folder away from its top.
94
+ const top = !linked ? revParse(home, '--show-toplevel') : path.basename(main) === '.git' ? path.dirname(main) : undefined;
95
+ if (top === undefined) return undefined;
96
+ return lstatSync(path.join(top, '.git'), { throwIfNoEntry: false })?.isSymbolicLink() ? undefined : top;
97
+ }
98
+
99
+ /** Whether `folder` or a folder above it holds a `.git` entry. */
100
+ function gitOnTheWay(folder) {
101
+ for (let at = realpathSync(folder); ; at = path.dirname(at)) {
102
+ if (existsSync(path.join(at, '.git'))) return true;
103
+ if (path.dirname(at) === at) return false;
104
+ }
105
+ }
106
+
107
+ /** One answer of `git rev-parse` in `folder`, or undefined when git gives none. */
108
+ function revParse(folder, question) {
109
+ const asked = spawnSync('git', ['-C', folder, 'rev-parse', question], { encoding: 'utf8' });
110
+ // Only git's record terminator comes off: trailing whitespace may be part of the folder's name.
111
+ const answer = (asked.stdout ?? '').replace(/\n$/, '');
112
+ return asked.status === 0 && answer !== '' ? answer : undefined;
113
+ }
package/src/orca.js CHANGED
@@ -146,10 +146,25 @@ export function findProject(home) {
146
146
  * names its CLI: a build's folder is written only inside its own CLI, so the
147
147
  * kit cannot tell it from the app (#551).
148
148
  */
149
- export const profilesDir = () => path.join(
150
- process.env.OBK_ORCA_DATA || path.join(homedir(), 'Library', 'Application Support', 'orca'),
151
- 'profiles',
152
- );
149
+ export const profilesDir = () => path.join(orcaData(), 'profiles');
150
+
151
+ const orcaData = () => process.env.OBK_ORCA_DATA || path.join(homedir(), 'Library', 'Application Support', 'orca');
152
+
153
+ /**
154
+ * The home of the Claude profile Orca routes a new Claude tab to, or undefined
155
+ * when it routes none. Orca writes the path to `selected-host`, with nothing
156
+ * in it when no profile is routed, and removes the file when it has no accounts
157
+ * (read in the bundle of Orca 1.4.245, tech notes, section 2).
158
+ */
159
+ export function selectedClaudeProfile() {
160
+ let home;
161
+ try {
162
+ home = readFileSync(path.join(orcaData(), 'claude-profiles', 'selected-host'), 'utf8').replace(/\r?\n$/, '');
163
+ } catch {
164
+ return undefined;
165
+ }
166
+ return path.isAbsolute(home) ? home : undefined;
167
+ }
153
168
 
154
169
  /**
155
170
  * Orca's own default launch arguments, per profile it keeps:
package/src/up.js CHANGED
@@ -14,6 +14,7 @@ import { botDir, botNames, displayName, readBot } from './bot.js';
14
14
  import { conversationsIn, hasConversation, heldAsUserTurn, transcriptsIn } from './conversations.js';
15
15
  import { dropRemovedHooks, installHook } from './hooks.js';
16
16
  import { writePermissions } from './permissions.js';
17
+ import { physicalMemoryFolder } from './memory.js';
17
18
  import { piTrouble } from './pi.js';
18
19
  import { addressOf, HARNESSES, harnessOf, isAddressOf, isShortPrompt, kitFolders, launchCommand, mailboxStep, ownCli, reachesMail, sessionTrouble, shellWord, startPrompt, workDirOf } from './launch.js';
19
20
  import { asFolderProject, coordinatorOf, dropLeftGit, findProject, harnessInTab, loadInWindow, makeMailbox, makeProject, openTab, QUESTION_ON_SCREEN, retitleTab, runMissing, screenRows, shellInTab, tabs, tabToTypeInto, TERMINAL_ENV, TIMED_OUT, tellWindow, typeIntoTab, useMailbox } from './orca.js';
@@ -610,6 +611,7 @@ function launchFor(bots, bot, session, harness, home, workDir, resume, held, fre
610
611
  resume,
611
612
  address,
612
613
  kit: kitFolders(bots, bot.name),
614
+ ...(harness === 'claude' ? { memoryFolder: physicalMemoryFolder(home) } : {}),
613
615
  // A new Pi conversation's id, and the name its tab and session list show,
614
616
  // `<bot>.<session>` as a Codex thread is named (#480).
615
617
  ...(harness === 'pi' ? { sessionId: resume === undefined ? fresh : undefined, name: `${bot.name}.${session.name}` } : {}),