@inixiative/archive 0.2.1 → 0.4.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 +38 -9
- package/package.json +5 -3
- package/src/agents.ts +386 -0
- package/src/cli.ts +95 -15
- package/src/client.ts +24 -17
- package/src/collector.ts +94 -24
- package/src/config.ts +74 -19
- package/src/import.ts +7 -2
- package/src/local.ts +2 -2
- package/src/tags.ts +78 -0
package/README.md
CHANGED
|
@@ -39,35 +39,62 @@ bun run archive list
|
|
|
39
39
|
bun run archive search --query 'migration'
|
|
40
40
|
```
|
|
41
41
|
|
|
42
|
-
Use `--source claude-code` for Claude histories. Collection matches the exact working-directory metadata recorded by the provider. Nested checkouts are separate mappings. Unknown directories, symlinks and sessions without usable metadata are skipped. Incomplete or changing files remain at source and retry on the next scan. The initial collector rescans files every 30 seconds; it deduplicates normalized content rather than maintaining byte-offset ingestion cursors. It retains prior manually assigned tags. It does not upload: run sync separately.
|
|
42
|
+
Use `--source claude-code` for Claude histories. Collection matches the exact working-directory metadata recorded by the provider. Repeat `--project-root` to map several checkouts to one project; `--worktrees` also accepts every current git worktree of each root, re-read on each scan. Nested checkouts are separate mappings. Unknown directories, symlinks and sessions without usable metadata are skipped. Incomplete or changing files remain at source and retry on the next scan. The initial collector rescans files every 30 seconds; it deduplicates normalized content rather than maintaining byte-offset ingestion cursors. It retains prior manually assigned tags. It does not upload: run sync separately.
|
|
43
43
|
|
|
44
44
|
## Connect to BYO hosting
|
|
45
45
|
|
|
46
|
-
Deploy the included Dockerfile with a persistent volume mounted at `/data`, HTTPS, and a unique `ARCHIVE_SERVER_TOKEN` of at least 32 characters. Compose binds only to loopback; put an HTTPS reverse proxy in front for remote use. Railway needs a `/data` volume. The Render blueprint provisions a dedicated disk and generated token.
|
|
46
|
+
Deploy the included Dockerfile with a persistent volume mounted at `/data`, HTTPS, and a unique `ARCHIVE_SERVER_TOKEN` of at least 32 characters. Compose bind-mounts `./data` (or `ARCHIVE_DATA_DIR`) at `/data`, so the store survives rebuilds, `down -v` and volume prunes, and binds only to loopback on port 4411 (or `ARCHIVE_PORT`); put an HTTPS reverse proxy in front for remote use. Railway needs a `/data` volume. The Render blueprint provisions a dedicated disk and generated token.
|
|
47
47
|
|
|
48
|
-
Place the destination's token in an environment variable, then run:
|
|
48
|
+
Place the destination's token in a private file (`chmod 600`, owned by you) or an environment variable, then run:
|
|
49
49
|
|
|
50
50
|
```sh
|
|
51
|
-
bun run archive connect --url https://your-archive.example --project-id inixiative --token-
|
|
51
|
+
bun run archive connect --url https://your-archive.example --project-id inixiative --token-file ~/.local/share/archive/credentials/inixiative.token
|
|
52
52
|
bun run archive routes
|
|
53
53
|
bun run archive sync
|
|
54
54
|
bun run archive sync --watch
|
|
55
55
|
bun run archive search --remote --query 'migration'
|
|
56
56
|
```
|
|
57
57
|
|
|
58
|
-
`setup` is an alias for `connect`. Setup verifies access before saving configuration and does not upload. The URL locates the server; the token grants access. Configuration stores only the environment variable name.
|
|
58
|
+
`setup` is an alias for `connect`. Setup verifies access before saving configuration and does not upload. The URL locates the server; the token grants access. Configuration stores only the token file path (`tokenFile`) or environment variable name (`--token-env`, `tokenEnv`), never the token. A token file is read on every request, so rotating it needs no restart; symlinks and files readable by group or others are refused. An environment variable must be set in the sync process environment. Redirects are refused; only HTTPS or loopback HTTP is allowed.
|
|
59
59
|
|
|
60
60
|
`--home PATH`, `--store FILE` and `--config FILE` support custom paths. Keep the database with its source UUID when moving machines. Back up the entire database; a new store creates a new source identity.
|
|
61
61
|
|
|
62
|
+
## Always-on local archiving
|
|
63
|
+
|
|
64
|
+
`archive agents` declares long-running agents in `~/.local/share/archive/agents.json` and installs them as launchd agents (macOS) or systemd user units (Linux):
|
|
65
|
+
|
|
66
|
+
```sh
|
|
67
|
+
archive init
|
|
68
|
+
archive connect --url https://your-archive.example --project-id inixiative --token-file ~/.local/share/archive/credentials/inixiative.token
|
|
69
|
+
archive agents add-collector --name claude-code.inixiative --source claude-code --project-id inixiative --project-root ~/code/inixiative --worktrees
|
|
70
|
+
archive agents add-collector --name codex.inixiative --source codex --project-id inixiative --project-root ~/code/inixiative --worktrees
|
|
71
|
+
archive agents serve on
|
|
72
|
+
archive agents sync on
|
|
73
|
+
archive agents install
|
|
74
|
+
archive agents status
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
`add-collector` upserts by `--name` (lowercase letters, digits, `.` and `-`); `--directory` defaults to `~/.claude/projects` or `~/.codex/sessions`, and `--project-root`, `--worktrees` and `--atlas` behave as for `collect`. `remove-collector --name N`, `serve on|off [--port P]` and `sync on|off` edit the same file. `install` writes one unit per agent: `com.inixiative.archive.local` (serve), `com.inixiative.archive.sync` (`sync --watch`) and `com.inixiative.archive.collect.<name>`. Each runs the current Bun with this package's own `src/cli.ts`, restarts on exit (30 second throttle), works in the archive home and logs to `logs/<label>.{out,err}.log` there. Re-running `install` reloads only changed or stopped units and removes Archive units no longer declared; other launchd agents are untouched. `uninstall` removes every Archive unit; `status` reports each unit's load state, PID and last log entry. One archive home per user account: units are named by agent, not by home. Sync reads destination token files itself, so no wrapper script is needed. Re-run `install` after upgrading the package if its install path changes.
|
|
78
|
+
|
|
79
|
+
Docker Compose is the alternative for the server: `docker compose up -d` bind-mounts `./data` (or `ARCHIVE_DATA_DIR`) at `/data` and binds loopback port 4411. It is a separate store, so connect it as a destination and let the `sync` agent publish to it; do not bind-mount the store the host agents are writing, since SQLite locking across the Docker VM boundary is unreliable. Collectors run on the host, where the session histories are.
|
|
80
|
+
|
|
81
|
+
`@inixiative/archive/agents` exports the pure pieces for integrations: `agentsConfigSchema`, `agentUnits`, `renderPlist`, `renderSystemdUnit` and `planAgents`, plus `installAgents`, `uninstallAgents` and `agentStatus` with an injectable command runner and supervisor directory.
|
|
82
|
+
|
|
62
83
|
## Connect through Kingdom
|
|
63
84
|
|
|
64
|
-
|
|
85
|
+
Pair a runtime with Kingdom (Foundry: Settings → Kingdom) and expose its `kastle_runtime_` credential in an environment variable, then either let Kingdom store the archives:
|
|
65
86
|
|
|
66
87
|
```sh
|
|
67
|
-
bun run archive connect --kind kingdom --url https://your-kingdom.example --
|
|
88
|
+
bun run archive connect --kind kingdom --url https://your-kingdom.example --project-id inixiative --token-env KINGDOM_ARCHIVE_TOKEN
|
|
68
89
|
```
|
|
69
90
|
|
|
70
|
-
|
|
91
|
+
or forward them through Kingdom to a hosted Archive it has bound for that project:
|
|
92
|
+
|
|
93
|
+
```sh
|
|
94
|
+
bun run archive connect --kind kingdom --url https://your-kingdom.example --connection-id inixiative --project-id inixiative --token-env KINGDOM_ARCHIVE_TOKEN
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
Kingdom takes the owner from the runtime credential; `--owner-model`, `--organization-id` and `--space-id` narrow it to an organization or space that owner manages. A forwarding connection accepts only its bound `projectId`, and the hosted Archive's own token stays in Kingdom's server environment. A Kingdom runtime credential is distinct from a standalone Archive token. Kingdom retains responsibility for memberships, shares, revocation and hosted browsing.
|
|
71
98
|
|
|
72
99
|
Foundry exposes the same commands through `bun run archive`. Its defaults remain `.foundry/archives/archives.sqlite` and `.foundry/archives.json`. Its durable journal capture automatically publishes matching projects once destination credentials are in the viewer's environment. Restart the viewer after changing destination configuration.
|
|
73
100
|
|
|
@@ -79,7 +106,9 @@ Only explicit `projectId` matches authorize publication. An archive with no matc
|
|
|
79
106
|
bun run archive tag --id ARCHIVE_ID --tag reviewed
|
|
80
107
|
```
|
|
81
108
|
|
|
82
|
-
|
|
109
|
+
Collectors add explicit tags from recorded session metadata: `repo:owner/name` and `branch:name` from git context, and with `--atlas` the `atlas:<concept>` of repository files the session's tool calls touched (from `atlas graph --json` in the session's checkout). Suggestions are computed on read and never stored: heuristic categories, plus `reference` suggestions for linked GitHub pull requests, issues and commits (`github:owner/repo#24`, `github:owner/repo@sha`) and Linear issues (`linear:KEY-12`). A link is a mention, not proof of work, so references stay suggestions.
|
|
110
|
+
|
|
111
|
+
Tags and suggestions do not change ownership or routing. Jev integration is not enabled: the next step is labeled, shadow-mode tag/destination proposals, scored for accuracy and cross-organization mistakes before any automatic action. Session content must not be sent to Jev without selecting that service for the relevant ownership boundary.
|
|
83
112
|
|
|
84
113
|
## Limits
|
|
85
114
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@inixiative/archive",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"exports": {
|
|
6
6
|
".": "./src/index.ts",
|
|
@@ -13,7 +13,9 @@
|
|
|
13
13
|
"./preview": "./src/preview.ts",
|
|
14
14
|
"./cli": "./src/cli.ts",
|
|
15
15
|
"./collector": "./src/collector.ts",
|
|
16
|
-
"./chatgpt": "./src/chatgpt.ts"
|
|
16
|
+
"./chatgpt": "./src/chatgpt.ts",
|
|
17
|
+
"./tags": "./src/tags.ts",
|
|
18
|
+
"./agents": "./src/agents.ts"
|
|
17
19
|
},
|
|
18
20
|
"files": [
|
|
19
21
|
"src"
|
|
@@ -34,7 +36,7 @@
|
|
|
34
36
|
},
|
|
35
37
|
"devDependencies": {
|
|
36
38
|
"@biomejs/biome": "2.5.2",
|
|
37
|
-
"@inixiative/config": "^0.5.
|
|
39
|
+
"@inixiative/config": "^0.5.37",
|
|
38
40
|
"@types/bun": "1.4.2",
|
|
39
41
|
"lefthook": "2.1.9",
|
|
40
42
|
"typescript": "6.0.3"
|
package/src/agents.ts
ADDED
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import {
|
|
3
|
+
chmodSync,
|
|
4
|
+
closeSync,
|
|
5
|
+
existsSync,
|
|
6
|
+
fstatSync,
|
|
7
|
+
mkdirSync,
|
|
8
|
+
openSync,
|
|
9
|
+
readdirSync,
|
|
10
|
+
readFileSync,
|
|
11
|
+
readSync,
|
|
12
|
+
renameSync,
|
|
13
|
+
rmSync,
|
|
14
|
+
writeFileSync,
|
|
15
|
+
} from 'node:fs';
|
|
16
|
+
import { homedir } from 'node:os';
|
|
17
|
+
import { dirname, isAbsolute, join, resolve } from 'node:path';
|
|
18
|
+
import { fileURLToPath } from 'node:url';
|
|
19
|
+
import { z } from 'zod';
|
|
20
|
+
|
|
21
|
+
export const defaultHome = () => join(homedir(), '.local/share/archive');
|
|
22
|
+
export const AGENT_LABEL_PREFIX = 'com.inixiative.archive.';
|
|
23
|
+
/** History each provider writes by default. */
|
|
24
|
+
export const defaultHistoryDirectory = (source: 'claude-code' | 'codex') =>
|
|
25
|
+
join(homedir(), source === 'codex' ? '.codex/sessions' : '.claude/projects');
|
|
26
|
+
|
|
27
|
+
const absolute = z.string().max(4096).refine(isAbsolute, 'Path must be absolute');
|
|
28
|
+
export const collectorAgentSchema = z.strictObject({
|
|
29
|
+
name: z
|
|
30
|
+
.string()
|
|
31
|
+
.max(64)
|
|
32
|
+
.regex(/^[a-z0-9]+(?:[.-][a-z0-9]+)*$/),
|
|
33
|
+
source: z.enum(['claude-code', 'codex']),
|
|
34
|
+
directory: absolute,
|
|
35
|
+
projectId: z.string().min(1).max(256),
|
|
36
|
+
projectRoots: z.array(absolute).min(1).max(100),
|
|
37
|
+
worktrees: z.boolean().optional(),
|
|
38
|
+
atlas: z.boolean().optional(),
|
|
39
|
+
});
|
|
40
|
+
export const agentsConfigSchema = z
|
|
41
|
+
.strictObject({
|
|
42
|
+
serve: z.strictObject({ port: z.number().int().min(1).max(65535).optional() }).optional(),
|
|
43
|
+
sync: z.boolean().optional(),
|
|
44
|
+
collectors: z.array(collectorAgentSchema).max(100),
|
|
45
|
+
})
|
|
46
|
+
.refine(
|
|
47
|
+
(c) => new Set(c.collectors.map((x) => x.name)).size === c.collectors.length,
|
|
48
|
+
'Collector names must be unique',
|
|
49
|
+
);
|
|
50
|
+
export type AgentsConfig = z.infer<typeof agentsConfigSchema>;
|
|
51
|
+
export type CollectorAgent = z.infer<typeof collectorAgentSchema>;
|
|
52
|
+
|
|
53
|
+
export const readAgents = (file: string): AgentsConfig | undefined =>
|
|
54
|
+
existsSync(file) ? agentsConfigSchema.parse(JSON.parse(readFileSync(file, 'utf8'))) : undefined;
|
|
55
|
+
export function writeAgents(file: string, input: AgentsConfig) {
|
|
56
|
+
const config = agentsConfigSchema.parse(input);
|
|
57
|
+
mkdirSync(dirname(file), { recursive: true, mode: 0o700 });
|
|
58
|
+
const temp = `${file}.${randomUUID()}.tmp`;
|
|
59
|
+
writeFileSync(temp, `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600, flag: 'wx' });
|
|
60
|
+
renameSync(temp, file);
|
|
61
|
+
chmodSync(file, 0o600);
|
|
62
|
+
return config;
|
|
63
|
+
}
|
|
64
|
+
export function upsertCollector(config: AgentsConfig | undefined, collector: CollectorAgent) {
|
|
65
|
+
const collectors = (config?.collectors ?? []).filter((c) => c.name !== collector.name);
|
|
66
|
+
return { ...config, collectors: [...collectors, collectorAgentSchema.parse(collector)] };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface AgentUnit {
|
|
70
|
+
label: string;
|
|
71
|
+
/** Full argv: runtime, CLI entrypoint, command. */
|
|
72
|
+
args: string[];
|
|
73
|
+
workingDirectory: string;
|
|
74
|
+
stdout: string;
|
|
75
|
+
stderr: string;
|
|
76
|
+
}
|
|
77
|
+
export interface AgentUnitOptions {
|
|
78
|
+
home: string;
|
|
79
|
+
execPath?: string;
|
|
80
|
+
/** Defaults to this package's own CLI, wherever it is installed. */
|
|
81
|
+
cliPath?: string;
|
|
82
|
+
}
|
|
83
|
+
export const packageCliPath = () => fileURLToPath(new URL('./cli.ts', import.meta.url));
|
|
84
|
+
|
|
85
|
+
export function collectorArgs(c: CollectorAgent) {
|
|
86
|
+
return [
|
|
87
|
+
'collect',
|
|
88
|
+
'--directory',
|
|
89
|
+
c.directory,
|
|
90
|
+
'--source',
|
|
91
|
+
c.source,
|
|
92
|
+
...c.projectRoots.flatMap((root) => ['--project-root', root]),
|
|
93
|
+
...(c.worktrees ? ['--worktrees'] : []),
|
|
94
|
+
...(c.atlas ? ['--atlas'] : []),
|
|
95
|
+
'--project-id',
|
|
96
|
+
c.projectId,
|
|
97
|
+
'--watch',
|
|
98
|
+
];
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** One supervised unit per declared agent. */
|
|
102
|
+
export function agentUnits(config: AgentsConfig, options: AgentUnitOptions): AgentUnit[] {
|
|
103
|
+
const home = resolve(options.home);
|
|
104
|
+
const base = [options.execPath ?? process.execPath, options.cliPath ?? packageCliPath()];
|
|
105
|
+
const homeArgs = home === resolve(defaultHome()) ? [] : ['--home', home];
|
|
106
|
+
const unit = (suffix: string, command: string[]): AgentUnit => {
|
|
107
|
+
const label = AGENT_LABEL_PREFIX + suffix;
|
|
108
|
+
return {
|
|
109
|
+
label,
|
|
110
|
+
args: [...base, ...command, ...homeArgs],
|
|
111
|
+
workingDirectory: home,
|
|
112
|
+
stdout: join(home, 'logs', `${label}.out.log`),
|
|
113
|
+
stderr: join(home, 'logs', `${label}.err.log`),
|
|
114
|
+
};
|
|
115
|
+
};
|
|
116
|
+
return [
|
|
117
|
+
...(config.serve
|
|
118
|
+
? [
|
|
119
|
+
unit('local', [
|
|
120
|
+
'serve',
|
|
121
|
+
...(config.serve.port ? ['--port', String(config.serve.port)] : []),
|
|
122
|
+
]),
|
|
123
|
+
]
|
|
124
|
+
: []),
|
|
125
|
+
...(config.sync ? [unit('sync', ['sync', '--watch'])] : []),
|
|
126
|
+
...config.collectors.map((c) => unit(`collect.${c.name}`, collectorArgs(c))),
|
|
127
|
+
];
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const xml = (value: string) =>
|
|
131
|
+
value.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
132
|
+
export function renderPlist(unit: AgentUnit) {
|
|
133
|
+
const string = (value: string) => `<string>${xml(value)}</string>`;
|
|
134
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
135
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
136
|
+
<plist version="1.0">
|
|
137
|
+
<dict>
|
|
138
|
+
<key>Label</key>
|
|
139
|
+
${string(unit.label)}
|
|
140
|
+
<key>ProgramArguments</key>
|
|
141
|
+
<array>
|
|
142
|
+
${unit.args.map((arg) => ` ${string(arg)}`).join('\n')}
|
|
143
|
+
</array>
|
|
144
|
+
<key>WorkingDirectory</key>
|
|
145
|
+
${string(unit.workingDirectory)}
|
|
146
|
+
<key>RunAtLoad</key>
|
|
147
|
+
<true/>
|
|
148
|
+
<key>KeepAlive</key>
|
|
149
|
+
<true/>
|
|
150
|
+
<key>ThrottleInterval</key>
|
|
151
|
+
<integer>30</integer>
|
|
152
|
+
<key>StandardOutPath</key>
|
|
153
|
+
${string(unit.stdout)}
|
|
154
|
+
<key>StandardErrorPath</key>
|
|
155
|
+
${string(unit.stderr)}
|
|
156
|
+
</dict>
|
|
157
|
+
</plist>
|
|
158
|
+
`;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// systemd expands % specifiers and $ variables even inside quotes.
|
|
162
|
+
const systemdEscape = (value: string) => value.replace(/%/g, '%%').replace(/\$/g, '$$$$');
|
|
163
|
+
const systemdQuote = (arg: string) =>
|
|
164
|
+
`"${systemdEscape(arg.replace(/\\/g, '\\\\').replace(/"/g, '\\"'))}"`;
|
|
165
|
+
export function renderSystemdUnit(unit: AgentUnit) {
|
|
166
|
+
return `[Unit]
|
|
167
|
+
Description=Archive ${unit.label.slice(AGENT_LABEL_PREFIX.length)}
|
|
168
|
+
|
|
169
|
+
[Service]
|
|
170
|
+
ExecStart=${unit.args.map(systemdQuote).join(' ')}
|
|
171
|
+
WorkingDirectory=${systemdEscape(unit.workingDirectory)}
|
|
172
|
+
Restart=always
|
|
173
|
+
RestartSec=30
|
|
174
|
+
StandardOutput=append:${systemdEscape(unit.stdout)}
|
|
175
|
+
StandardError=append:${systemdEscape(unit.stderr)}
|
|
176
|
+
|
|
177
|
+
[Install]
|
|
178
|
+
WantedBy=default.target
|
|
179
|
+
`;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export type AgentPlatform = 'darwin' | 'linux';
|
|
183
|
+
export const unitFileName = (label: string, platform: AgentPlatform) =>
|
|
184
|
+
label + (platform === 'darwin' ? '.plist' : '.service');
|
|
185
|
+
const ownedLabel = (label: string) =>
|
|
186
|
+
label === `${AGENT_LABEL_PREFIX}local` ||
|
|
187
|
+
label === `${AGENT_LABEL_PREFIX}sync` ||
|
|
188
|
+
label.startsWith(`${AGENT_LABEL_PREFIX}collect.`);
|
|
189
|
+
/** Labels of Archive units present in a supervisor directory. */
|
|
190
|
+
export function installedLabels(dir: string, platform: AgentPlatform) {
|
|
191
|
+
const extension = unitFileName('', platform);
|
|
192
|
+
return existsSync(dir)
|
|
193
|
+
? readdirSync(dir)
|
|
194
|
+
.filter((file) => file.endsWith(extension))
|
|
195
|
+
.map((file) => file.slice(0, -extension.length))
|
|
196
|
+
.filter(ownedLabel)
|
|
197
|
+
.sort()
|
|
198
|
+
: [];
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export interface AgentPlan {
|
|
202
|
+
write: { label: string; file: string; content: string }[];
|
|
203
|
+
unchanged: string[];
|
|
204
|
+
remove: string[];
|
|
205
|
+
}
|
|
206
|
+
/** Pure: which unit files to write, keep, or remove, given what is on disk. */
|
|
207
|
+
export function planAgents(
|
|
208
|
+
units: AgentUnit[],
|
|
209
|
+
installed: { label: string; content?: string }[],
|
|
210
|
+
platform: AgentPlatform,
|
|
211
|
+
dir: string,
|
|
212
|
+
): AgentPlan {
|
|
213
|
+
const current = new Map(installed.map((i) => [i.label, i.content]));
|
|
214
|
+
const render = platform === 'darwin' ? renderPlist : renderSystemdUnit;
|
|
215
|
+
const plan: AgentPlan = { write: [], unchanged: [], remove: [] };
|
|
216
|
+
for (const unit of units) {
|
|
217
|
+
const content = render(unit);
|
|
218
|
+
if (current.get(unit.label) === content) plan.unchanged.push(unit.label);
|
|
219
|
+
else
|
|
220
|
+
plan.write.push({
|
|
221
|
+
label: unit.label,
|
|
222
|
+
file: join(dir, unitFileName(unit.label, platform)),
|
|
223
|
+
content,
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
const wanted = new Set(units.map((u) => u.label));
|
|
227
|
+
plan.remove = installed.map((i) => i.label).filter((l) => ownedLabel(l) && !wanted.has(l));
|
|
228
|
+
return plan;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
export type AgentRunner = (argv: string[]) => { code: number; stdout: string; stderr: string };
|
|
232
|
+
const spawnRunner: AgentRunner = (argv) => {
|
|
233
|
+
const run = Bun.spawnSync(argv, { stdout: 'pipe', stderr: 'pipe' });
|
|
234
|
+
return { code: run.exitCode ?? 1, stdout: run.stdout.toString(), stderr: run.stderr.toString() };
|
|
235
|
+
};
|
|
236
|
+
export interface AgentHostOptions {
|
|
237
|
+
home?: string;
|
|
238
|
+
platform?: AgentPlatform;
|
|
239
|
+
/** Supervisor directory; defaults to ~/Library/LaunchAgents or ~/.config/systemd/user. */
|
|
240
|
+
dir?: string;
|
|
241
|
+
run?: AgentRunner;
|
|
242
|
+
uid?: number;
|
|
243
|
+
sleep?: (ms: number) => void;
|
|
244
|
+
execPath?: string;
|
|
245
|
+
cliPath?: string;
|
|
246
|
+
}
|
|
247
|
+
function host(options: AgentHostOptions) {
|
|
248
|
+
const platform =
|
|
249
|
+
options.platform ??
|
|
250
|
+
(process.platform === 'darwin' || process.platform === 'linux'
|
|
251
|
+
? process.platform
|
|
252
|
+
: (() => {
|
|
253
|
+
throw new Error('Agents support macOS launchd and Linux systemd only');
|
|
254
|
+
})());
|
|
255
|
+
const home = resolve(options.home ?? defaultHome());
|
|
256
|
+
const dir =
|
|
257
|
+
options.dir ??
|
|
258
|
+
join(homedir(), platform === 'darwin' ? 'Library/LaunchAgents' : '.config/systemd/user');
|
|
259
|
+
const run = options.run ?? spawnRunner;
|
|
260
|
+
const domain = `gui/${options.uid ?? process.getuid?.()}`;
|
|
261
|
+
const sleep = options.sleep ?? Bun.sleepSync;
|
|
262
|
+
const file = (label: string) => join(dir, unitFileName(label, platform));
|
|
263
|
+
const systemctl = (...args: string[]) => run(['systemctl', '--user', ...args]);
|
|
264
|
+
const unload = (label: string) => {
|
|
265
|
+
if (platform === 'darwin') run(['launchctl', 'bootout', `${domain}/${label}`]);
|
|
266
|
+
else systemctl('disable', '--now', `${label}.service`);
|
|
267
|
+
};
|
|
268
|
+
const load = (label: string) => {
|
|
269
|
+
if (platform === 'linux') {
|
|
270
|
+
const enabled = systemctl('enable', `${label}.service`);
|
|
271
|
+
const started = systemctl('restart', `${label}.service`);
|
|
272
|
+
if (enabled.code || started.code) throw new Error(`systemctl failed for ${label}`);
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
run(['launchctl', 'bootout', `${domain}/${label}`]);
|
|
276
|
+
// bootout returns before launchd finishes tearing down; bootstrap then fails with I/O error 5.
|
|
277
|
+
for (let attempt = 1; ; attempt++) {
|
|
278
|
+
const result = run(['launchctl', 'bootstrap', domain, file(label)]);
|
|
279
|
+
if (!result.code) return;
|
|
280
|
+
if (attempt >= 5 || (result.code !== 5 && !/Input\/output error/.test(result.stderr)))
|
|
281
|
+
throw new Error(`launchctl bootstrap failed for ${label}: ${result.stderr.trim()}`);
|
|
282
|
+
sleep(1000);
|
|
283
|
+
}
|
|
284
|
+
};
|
|
285
|
+
const loaded = (label: string) => {
|
|
286
|
+
if (platform === 'darwin') {
|
|
287
|
+
const result = run(['launchctl', 'print', `${domain}/${label}`]);
|
|
288
|
+
if (result.code) return { loaded: false };
|
|
289
|
+
const pid = /\bpid = (\d+)/.exec(result.stdout)?.[1];
|
|
290
|
+
return { loaded: true, pid: pid ? Number(pid) : undefined };
|
|
291
|
+
}
|
|
292
|
+
const result = systemctl('show', `${label}.service`, '--property=ActiveState,MainPID');
|
|
293
|
+
const state = /ActiveState=(\w+)/.exec(result.stdout)?.[1];
|
|
294
|
+
const pid = Number(/MainPID=(\d+)/.exec(result.stdout)?.[1] ?? 0);
|
|
295
|
+
return { loaded: state === 'active' || state === 'activating', pid: pid || undefined };
|
|
296
|
+
};
|
|
297
|
+
const remove = (labels: string[]) => {
|
|
298
|
+
for (const label of labels) {
|
|
299
|
+
unload(label);
|
|
300
|
+
rmSync(file(label), { force: true });
|
|
301
|
+
}
|
|
302
|
+
if (platform === 'linux' && labels.length) systemctl('daemon-reload');
|
|
303
|
+
};
|
|
304
|
+
return { platform, home, dir, file, load, loaded, remove, systemctl };
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/** Writes and (re)loads declared agents; removes Archive units no longer declared. */
|
|
308
|
+
export function installAgents(config: AgentsConfig, options: AgentHostOptions = {}) {
|
|
309
|
+
const h = host(options);
|
|
310
|
+
const units = agentUnits(config, {
|
|
311
|
+
home: h.home,
|
|
312
|
+
execPath: options.execPath,
|
|
313
|
+
cliPath: options.cliPath,
|
|
314
|
+
});
|
|
315
|
+
mkdirSync(join(h.home, 'logs'), { recursive: true, mode: 0o700 });
|
|
316
|
+
mkdirSync(h.dir, { recursive: true });
|
|
317
|
+
const installed = installedLabels(h.dir, h.platform).map((label) => ({
|
|
318
|
+
label,
|
|
319
|
+
content: readFileSync(h.file(label), 'utf8'),
|
|
320
|
+
}));
|
|
321
|
+
const plan = planAgents(units, installed, h.platform, h.dir);
|
|
322
|
+
h.remove(plan.remove);
|
|
323
|
+
for (const { file, content } of plan.write) {
|
|
324
|
+
const temp = `${file}.${randomUUID()}.tmp`;
|
|
325
|
+
writeFileSync(temp, content, { mode: 0o644, flag: 'wx' });
|
|
326
|
+
renameSync(temp, file);
|
|
327
|
+
}
|
|
328
|
+
if (h.platform === 'linux' && plan.write.length) h.systemctl('daemon-reload');
|
|
329
|
+
// Unchanged units that are not running are loaded again.
|
|
330
|
+
const reload = [
|
|
331
|
+
...plan.write.map((w) => w.label),
|
|
332
|
+
...plan.unchanged.filter((label) => !h.loaded(label).loaded),
|
|
333
|
+
];
|
|
334
|
+
for (const label of reload) h.load(label);
|
|
335
|
+
return {
|
|
336
|
+
platform: h.platform,
|
|
337
|
+
dir: h.dir,
|
|
338
|
+
loaded: reload,
|
|
339
|
+
unchanged: plan.unchanged.filter((l) => !reload.includes(l)),
|
|
340
|
+
removed: plan.remove,
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
export function uninstallAgents(options: AgentHostOptions = {}) {
|
|
345
|
+
const h = host(options);
|
|
346
|
+
const labels = installedLabels(h.dir, h.platform);
|
|
347
|
+
h.remove(labels);
|
|
348
|
+
return { platform: h.platform, dir: h.dir, removed: labels };
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/** Last log entry: CLI output is pretty JSON, so the last top-level value, compacted; else the last line. */
|
|
352
|
+
function lastEntry(file: string) {
|
|
353
|
+
if (!existsSync(file)) return undefined;
|
|
354
|
+
const fd = openSync(file, 'r');
|
|
355
|
+
try {
|
|
356
|
+
const size = fstatSync(fd).size;
|
|
357
|
+
const buffer = Buffer.alloc(Math.min(size, 65_536));
|
|
358
|
+
readSync(fd, buffer, 0, buffer.length, size - buffer.length);
|
|
359
|
+
const lines = buffer.toString('utf8').trimEnd().split('\n');
|
|
360
|
+
const start = lines.findLastIndex((line) => line === '{' || line === '[');
|
|
361
|
+
if (start >= 0)
|
|
362
|
+
try {
|
|
363
|
+
return JSON.stringify(JSON.parse(lines.slice(start).join('\n'))).slice(0, 500);
|
|
364
|
+
} catch {}
|
|
365
|
+
return lines.at(-1) || undefined;
|
|
366
|
+
} finally {
|
|
367
|
+
closeSync(fd);
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
export function agentStatus(config: AgentsConfig | undefined, options: AgentHostOptions = {}) {
|
|
372
|
+
const h = host(options);
|
|
373
|
+
const units = config
|
|
374
|
+
? agentUnits(config, { home: h.home, execPath: options.execPath, cliPath: options.cliPath })
|
|
375
|
+
: [];
|
|
376
|
+
const declared = new Set(units.map((u) => u.label));
|
|
377
|
+
const labels = [...new Set([...declared, ...installedLabels(h.dir, h.platform)])].sort();
|
|
378
|
+
return labels.map((label) => ({
|
|
379
|
+
label,
|
|
380
|
+
declared: declared.has(label),
|
|
381
|
+
installed: existsSync(h.file(label)),
|
|
382
|
+
...h.loaded(label),
|
|
383
|
+
lastOut: lastEntry(join(h.home, 'logs', `${label}.out.log`)),
|
|
384
|
+
lastErr: lastEntry(join(h.home, 'logs', `${label}.err.log`)),
|
|
385
|
+
}));
|
|
386
|
+
}
|
package/src/cli.ts
CHANGED
|
@@ -1,12 +1,22 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
import { randomBytes } from 'node:crypto';
|
|
3
3
|
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
4
|
-
import { homedir } from 'node:os';
|
|
5
4
|
import { join, resolve } from 'node:path';
|
|
6
5
|
import { parseArgs } from 'node:util';
|
|
6
|
+
import {
|
|
7
|
+
agentStatus,
|
|
8
|
+
defaultHistoryDirectory,
|
|
9
|
+
defaultHome,
|
|
10
|
+
installAgents,
|
|
11
|
+
readAgents,
|
|
12
|
+
uninstallAgents,
|
|
13
|
+
upsertCollector,
|
|
14
|
+
writeAgents,
|
|
15
|
+
} from './agents';
|
|
7
16
|
import { importChatGPTThread } from './chatgpt';
|
|
8
17
|
import {
|
|
9
18
|
archiveRequest,
|
|
19
|
+
kingdomFields,
|
|
10
20
|
publishArchive,
|
|
11
21
|
routingPreview,
|
|
12
22
|
searchRemotes,
|
|
@@ -25,15 +35,17 @@ export async function runCli(args = Bun.argv.slice(2)) {
|
|
|
25
35
|
args,
|
|
26
36
|
allowPositionals: true,
|
|
27
37
|
options: {
|
|
28
|
-
home: { type: 'string', default:
|
|
38
|
+
home: { type: 'string', default: defaultHome() },
|
|
29
39
|
store: { type: 'string' },
|
|
30
40
|
config: { type: 'string' },
|
|
31
41
|
url: { type: 'string' },
|
|
32
42
|
kind: { type: 'string', default: 'archive' },
|
|
33
43
|
'project-id': { type: 'string' },
|
|
34
|
-
'
|
|
35
|
-
'
|
|
36
|
-
'
|
|
44
|
+
'connection-id': { type: 'string' },
|
|
45
|
+
'owner-model': { type: 'string' },
|
|
46
|
+
'organization-id': { type: 'string' },
|
|
47
|
+
'space-id': { type: 'string' },
|
|
48
|
+
'token-env': { type: 'string' },
|
|
37
49
|
'token-file': { type: 'string' },
|
|
38
50
|
file: { type: 'string' },
|
|
39
51
|
directory: { type: 'string' },
|
|
@@ -42,10 +54,13 @@ export async function runCli(args = Bun.argv.slice(2)) {
|
|
|
42
54
|
'session-id': { type: 'string' },
|
|
43
55
|
id: { type: 'string' },
|
|
44
56
|
query: { type: 'string', default: '' },
|
|
45
|
-
'project-root': { type: 'string' },
|
|
57
|
+
'project-root': { type: 'string', multiple: true },
|
|
58
|
+
worktrees: { type: 'boolean', default: false },
|
|
59
|
+
atlas: { type: 'boolean', default: false },
|
|
46
60
|
tag: { type: 'string', multiple: true },
|
|
47
61
|
limit: { type: 'string', default: '100' },
|
|
48
|
-
port: { type: 'string'
|
|
62
|
+
port: { type: 'string' },
|
|
63
|
+
name: { type: 'string' },
|
|
49
64
|
hostname: { type: 'string', default: '127.0.0.1' },
|
|
50
65
|
watch: { type: 'boolean', default: false },
|
|
51
66
|
remote: { type: 'boolean', default: false },
|
|
@@ -60,12 +75,14 @@ export async function runCli(args = Bun.argv.slice(2)) {
|
|
|
60
75
|
const command = positionals[0];
|
|
61
76
|
if (v.help || !command) {
|
|
62
77
|
console.log(
|
|
63
|
-
'Archive: init | serve | connect (setup) | preview | import | collect | list | export | tag | routes | sync | search\n' +
|
|
64
|
-
'connect --url HTTPS_URL --project-id ID --token-env ENV [--kind kingdom --
|
|
78
|
+
'Archive: init | serve | connect (setup) | preview | import | collect | list | export | tag | routes | sync | search | agents\n' +
|
|
79
|
+
'connect --url HTTPS_URL --project-id ID (--token-env ENV | --token-file PATH) [--kind kingdom [--connection-id ID] [--owner-model M --organization-id UUID --space-id UUID]]\n' +
|
|
65
80
|
'import --file PATH --source codex|claude-code --project-id ID [--tag TAG]\n' +
|
|
66
|
-
'collect --directory HISTORY --source codex|claude-code --project-root EXACT_CWD --project-id ID [--watch]\n' +
|
|
81
|
+
'collect --directory HISTORY --source codex|claude-code --project-root EXACT_CWD [--project-root ...] [--worktrees] [--atlas] --project-id ID [--watch]\n' +
|
|
67
82
|
'sync [--watch] | routes | search --query TEXT [--remote]\n' +
|
|
68
83
|
'serve --home PATH [--port 4411 --hostname 127.0.0.1]\n' +
|
|
84
|
+
'agents add-collector --name N --source codex|claude-code --project-id ID --project-root DIR [--project-root ...] [--directory HISTORY] [--worktrees] [--atlas]\n' +
|
|
85
|
+
'agents remove-collector --name N | serve on|off [--port P] | sync on|off | install | uninstall | status\n' +
|
|
69
86
|
'All commands accept --home PATH; integrations may use --store FILE --config FILE.',
|
|
70
87
|
);
|
|
71
88
|
return;
|
|
@@ -89,7 +106,7 @@ export async function runCli(args = Bun.argv.slice(2)) {
|
|
|
89
106
|
const instance = startArchiveServer({
|
|
90
107
|
store: storePath,
|
|
91
108
|
token,
|
|
92
|
-
port: Number(v.port),
|
|
109
|
+
port: Number(v.port ?? process.env.PORT ?? 4411),
|
|
93
110
|
hostname: v.hostname,
|
|
94
111
|
});
|
|
95
112
|
output({ listening: instance.server.url.href, store: storePath });
|
|
@@ -102,12 +119,21 @@ export async function runCli(args = Bun.argv.slice(2)) {
|
|
|
102
119
|
return;
|
|
103
120
|
}
|
|
104
121
|
if (command === 'connect' || command === 'setup') {
|
|
122
|
+
const tokenFile = v['token-file'] && resolve(v['token-file']);
|
|
105
123
|
const destination = archiveDestinationSchema.parse({
|
|
106
124
|
projectId: v['project-id'],
|
|
107
125
|
url: v.url,
|
|
108
|
-
tokenEnv: v['token-env'],
|
|
126
|
+
tokenEnv: v['token-env'] ?? (tokenFile ? undefined : 'ARCHIVE_TOKEN'),
|
|
127
|
+
tokenFile,
|
|
109
128
|
kind: v.kind,
|
|
110
|
-
...(v.kind === 'kingdom'
|
|
129
|
+
...(v.kind === 'kingdom'
|
|
130
|
+
? {
|
|
131
|
+
connectionId: v['connection-id'],
|
|
132
|
+
ownerModel: v['owner-model'],
|
|
133
|
+
organizationId: v['organization-id'],
|
|
134
|
+
spaceId: v['space-id'],
|
|
135
|
+
}
|
|
136
|
+
: {}),
|
|
111
137
|
});
|
|
112
138
|
// Verify credentials and protocol before saving; no records uploaded by setup.
|
|
113
139
|
const probe = await archiveRequest(destination, 'search', {
|
|
@@ -116,13 +142,65 @@ export async function runCli(args = Bun.argv.slice(2)) {
|
|
|
116
142
|
limit: 1,
|
|
117
143
|
...(destination.kind === 'archive'
|
|
118
144
|
? { projectId: destination.projectId }
|
|
119
|
-
:
|
|
145
|
+
: kingdomFields(destination)),
|
|
120
146
|
});
|
|
121
147
|
if (!Array.isArray(probe.data?.archives))
|
|
122
148
|
throw new Error('Destination is not an Archive-compatible endpoint');
|
|
123
149
|
output(connectDestination(config, destination));
|
|
124
150
|
return;
|
|
125
151
|
}
|
|
152
|
+
if (command === 'agents') {
|
|
153
|
+
const file = join(home, 'agents.json');
|
|
154
|
+
const current = readAgents(file);
|
|
155
|
+
const [action, state] = positionals.slice(1);
|
|
156
|
+
const toggle = () => {
|
|
157
|
+
if (state !== 'on' && state !== 'off') throw new Error(`agents ${action} requires on|off`);
|
|
158
|
+
return state === 'on';
|
|
159
|
+
};
|
|
160
|
+
const base = { collectors: [], ...current };
|
|
161
|
+
if (action === 'add-collector') {
|
|
162
|
+
if (!v.name || !v['project-id'] || !v['project-root'] || !v.source)
|
|
163
|
+
throw new Error('add-collector requires --name, --source, --project-id and --project-root');
|
|
164
|
+
const source = v.source as 'codex' | 'claude-code';
|
|
165
|
+
output(
|
|
166
|
+
writeAgents(
|
|
167
|
+
file,
|
|
168
|
+
upsertCollector(current, {
|
|
169
|
+
name: v.name,
|
|
170
|
+
source,
|
|
171
|
+
directory: resolve(v.directory ?? defaultHistoryDirectory(source)),
|
|
172
|
+
projectId: v['project-id'],
|
|
173
|
+
projectRoots: v['project-root'].map((root) => resolve(root)),
|
|
174
|
+
...(v.worktrees ? { worktrees: true } : {}),
|
|
175
|
+
...(v.atlas ? { atlas: true } : {}),
|
|
176
|
+
}),
|
|
177
|
+
),
|
|
178
|
+
);
|
|
179
|
+
} else if (action === 'remove-collector') {
|
|
180
|
+
if (!v.name) throw new Error('remove-collector requires --name');
|
|
181
|
+
output(
|
|
182
|
+
writeAgents(file, {
|
|
183
|
+
...base,
|
|
184
|
+
collectors: base.collectors.filter((c) => c.name !== v.name),
|
|
185
|
+
}),
|
|
186
|
+
);
|
|
187
|
+
} else if (action === 'serve') {
|
|
188
|
+
const { serve: _, ...rest } = base;
|
|
189
|
+
output(
|
|
190
|
+
writeAgents(
|
|
191
|
+
file,
|
|
192
|
+
toggle() ? { ...rest, serve: v.port ? { port: Number(v.port) } : {} } : rest,
|
|
193
|
+
),
|
|
194
|
+
);
|
|
195
|
+
} else if (action === 'sync') output(writeAgents(file, { ...base, sync: toggle() }));
|
|
196
|
+
else if (action === 'install') {
|
|
197
|
+
if (!current) throw new Error('No agents.json; declare agents first');
|
|
198
|
+
output(installAgents(current, { home }));
|
|
199
|
+
} else if (action === 'uninstall') output(uninstallAgents({ home }));
|
|
200
|
+
else if (action === 'status') output(agentStatus(current, { home }));
|
|
201
|
+
else throw new Error('Unknown agents command; use --help');
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
126
204
|
if (command === 'preview') {
|
|
127
205
|
if (
|
|
128
206
|
Boolean(v.file) === Boolean(v.directory) ||
|
|
@@ -195,7 +273,9 @@ export async function runCli(args = Bun.argv.slice(2)) {
|
|
|
195
273
|
const result = await collectSessions(store, {
|
|
196
274
|
directory: v.directory!,
|
|
197
275
|
source: v.source as 'codex' | 'claude-code',
|
|
198
|
-
|
|
276
|
+
projectRoots: v['project-root']!,
|
|
277
|
+
worktrees: v.worktrees,
|
|
278
|
+
atlas: v.atlas,
|
|
199
279
|
projectId: v['project-id']!,
|
|
200
280
|
tags: v.tag,
|
|
201
281
|
});
|
package/src/client.ts
CHANGED
|
@@ -2,12 +2,24 @@ import {
|
|
|
2
2
|
type ArchiveDestination,
|
|
3
3
|
archiveDestinationSchema,
|
|
4
4
|
destinationIdentity,
|
|
5
|
+
destinationToken,
|
|
5
6
|
destinationUrl,
|
|
6
7
|
} from './config';
|
|
7
8
|
import type { LocalArchiveStore } from './local';
|
|
8
9
|
|
|
9
10
|
export { type ArchiveDestination, archiveDestinationSchema } from './config';
|
|
10
11
|
|
|
12
|
+
/** Identifies the Kingdom owner and forwarding connection; standalone Archive needs neither. */
|
|
13
|
+
export function kingdomFields(destination: ArchiveDestination) {
|
|
14
|
+
if (destination.kind === 'archive') return {};
|
|
15
|
+
const { ownerModel, organizationId, spaceId, connectionId } = destination;
|
|
16
|
+
return Object.fromEntries(
|
|
17
|
+
Object.entries({ ownerModel, organizationId, spaceId, connectionId }).filter(
|
|
18
|
+
([, value]) => value !== undefined,
|
|
19
|
+
),
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
|
|
11
23
|
export async function archiveRequest(
|
|
12
24
|
destination: ArchiveDestination,
|
|
13
25
|
action: string,
|
|
@@ -15,10 +27,12 @@ export async function archiveRequest(
|
|
|
15
27
|
transport: typeof fetch = fetch,
|
|
16
28
|
) {
|
|
17
29
|
const url = destinationUrl(destination.url);
|
|
18
|
-
const token =
|
|
19
|
-
if (!token || (destination.kind
|
|
30
|
+
const token = destinationToken(destination);
|
|
31
|
+
if (!token || (destination.kind === 'kingdom' && !token.startsWith('kastle_runtime_')))
|
|
20
32
|
throw new Error('Archive runtime credential unavailable');
|
|
21
|
-
const
|
|
33
|
+
const path =
|
|
34
|
+
destination.kind === 'kingdom' && destination.connectionId ? `remote/${action}` : action;
|
|
35
|
+
const response = await transport(new URL(`api/v1/archive/${path}`, url), {
|
|
22
36
|
method: 'POST',
|
|
23
37
|
redirect: 'error',
|
|
24
38
|
signal: AbortSignal.timeout(30_000),
|
|
@@ -41,19 +55,14 @@ export async function publishArchive(
|
|
|
41
55
|
if (!archive || archive.snapshot.projectId !== destination.projectId)
|
|
42
56
|
throw new Error('Archive is outside destination project');
|
|
43
57
|
const receiptKey = destinationIdentity(destination);
|
|
44
|
-
const keepIds = [...new Set(destination.keepIds)].sort();
|
|
45
58
|
let sent = false;
|
|
46
59
|
for (let attempt = 0; attempt < 8; attempt++) {
|
|
47
60
|
const latest = store.read(id)!;
|
|
48
61
|
const previousDigest = store.receipt(id, receiptKey);
|
|
49
62
|
let pending = store.pending(id, receiptKey);
|
|
50
63
|
if (!pending) {
|
|
51
|
-
if (
|
|
52
|
-
|
|
53
|
-
store.receipt(id, `${receiptKey}:keeps`) === JSON.stringify(keepIds)
|
|
54
|
-
)
|
|
55
|
-
return { unchanged: !sent };
|
|
56
|
-
store.enqueue(id, receiptKey, { revision: latest.revision, keepIds });
|
|
64
|
+
if (previousDigest === latest.digest) return { unchanged: !sent };
|
|
65
|
+
store.enqueue(id, receiptKey, { revision: latest.revision, keepIds: [] });
|
|
57
66
|
pending = store.pending(id, receiptKey)!;
|
|
58
67
|
}
|
|
59
68
|
const queued = store.read(id, pending.revision);
|
|
@@ -63,16 +72,14 @@ export async function publishArchive(
|
|
|
63
72
|
destination,
|
|
64
73
|
'ingest',
|
|
65
74
|
{
|
|
66
|
-
...(destination
|
|
67
|
-
? {}
|
|
68
|
-
: { kastleId: destination.kastleId, keepIds: pending.keepIds }),
|
|
75
|
+
...kingdomFields(destination),
|
|
69
76
|
previousDigest,
|
|
70
77
|
snapshot: queued.snapshot,
|
|
71
78
|
},
|
|
72
79
|
transport,
|
|
73
80
|
);
|
|
74
81
|
if (body.data?.digest !== queued.digest) throw new Error('Archive acknowledgement mismatch');
|
|
75
|
-
store.delivered(id, receiptKey, queued.digest,
|
|
82
|
+
store.delivered(id, receiptKey, queued.digest, '[]');
|
|
76
83
|
sent = true;
|
|
77
84
|
}
|
|
78
85
|
throw new Error('Archive changed repeatedly during publication; retry sync');
|
|
@@ -87,9 +94,9 @@ export function routingPreview(store: LocalArchiveStore, destinations: ArchiveDe
|
|
|
87
94
|
destinations: destinations
|
|
88
95
|
.filter((d) => d.projectId === archive.projectId)
|
|
89
96
|
.map((d) => ({
|
|
90
|
-
kind: d.kind
|
|
97
|
+
kind: d.kind,
|
|
91
98
|
url: d.url,
|
|
92
|
-
...(d
|
|
99
|
+
...kingdomFields(d),
|
|
93
100
|
})),
|
|
94
101
|
}));
|
|
95
102
|
}
|
|
@@ -129,7 +136,7 @@ export async function searchRemotes(
|
|
|
129
136
|
budget,
|
|
130
137
|
...(destination.kind === 'archive'
|
|
131
138
|
? { projectId: destination.projectId }
|
|
132
|
-
:
|
|
139
|
+
: kingdomFields(destination)),
|
|
133
140
|
});
|
|
134
141
|
return {
|
|
135
142
|
destination: destination.url,
|
package/src/collector.ts
CHANGED
|
@@ -1,15 +1,19 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { isAbsolute, join,
|
|
3
|
-
import { createInterface } from 'node:readline';
|
|
1
|
+
import { closeSync, existsSync, lstatSync, openSync, readdirSync, readSync } from 'node:fs';
|
|
2
|
+
import { isAbsolute, join, resolve } from 'node:path';
|
|
4
3
|
import { importTranscriptFile } from './import-file';
|
|
5
4
|
import { archiveKey } from './index';
|
|
6
5
|
import type { LocalArchiveStore } from './local';
|
|
6
|
+
import { type AtlasFileConcepts, atlasTags, provenanceTags } from './tags';
|
|
7
7
|
|
|
8
8
|
export interface CollectionSource {
|
|
9
9
|
directory: string;
|
|
10
10
|
source: 'codex' | 'claude-code';
|
|
11
|
-
|
|
11
|
+
projectRoots: string[];
|
|
12
12
|
projectId: string;
|
|
13
|
+
/** Also match the git worktrees of each root; nested checkouts stay separate. */
|
|
14
|
+
worktrees?: boolean;
|
|
15
|
+
/** Tag sessions with the Atlas concepts of files they touched, from `atlas graph` in the session's checkout. */
|
|
16
|
+
atlas?: boolean;
|
|
13
17
|
tags?: string[];
|
|
14
18
|
}
|
|
15
19
|
|
|
@@ -25,36 +29,88 @@ function files(directory: string): string[] {
|
|
|
25
29
|
return result;
|
|
26
30
|
}
|
|
27
31
|
|
|
32
|
+
type SessionMetadata = { cwd: string; branch?: string; repository?: string };
|
|
33
|
+
|
|
28
34
|
/** Match provider metadata, never text/tags/model suggestions. No implicit parent-directory routing. */
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
35
|
+
function sessionMetadata(
|
|
36
|
+
file: string,
|
|
37
|
+
source: CollectionSource['source'],
|
|
38
|
+
): SessionMetadata | undefined {
|
|
39
|
+
// A bounded synchronous read: streamed readline could leave a watch loop waiting forever.
|
|
40
|
+
const buffer = Buffer.alloc(Math.min(lstatSync(file).size, 1_000_000));
|
|
41
|
+
const fd = openSync(file, 'r');
|
|
32
42
|
try {
|
|
33
|
-
|
|
34
|
-
let row: any;
|
|
35
|
-
try {
|
|
36
|
-
row = JSON.parse(line);
|
|
37
|
-
} catch {
|
|
38
|
-
return undefined;
|
|
39
|
-
}
|
|
40
|
-
const cwd =
|
|
41
|
-
source === 'codex' ? (row.type === 'session_meta' ? row.payload?.cwd : undefined) : row.cwd;
|
|
42
|
-
if (typeof cwd === 'string' && isAbsolute(cwd)) return resolve(cwd);
|
|
43
|
-
}
|
|
43
|
+
readSync(fd, buffer, 0, buffer.length, 0);
|
|
44
44
|
} finally {
|
|
45
|
-
|
|
46
|
-
|
|
45
|
+
closeSync(fd);
|
|
46
|
+
}
|
|
47
|
+
for (const line of buffer.toString('utf8').split('\n')) {
|
|
48
|
+
if (!line) continue;
|
|
49
|
+
let row: any;
|
|
50
|
+
try {
|
|
51
|
+
row = JSON.parse(line);
|
|
52
|
+
} catch {
|
|
53
|
+
return undefined;
|
|
54
|
+
}
|
|
55
|
+
const meta = source === 'codex' ? (row.type === 'session_meta' ? row.payload : undefined) : row;
|
|
56
|
+
if (typeof meta?.cwd !== 'string' || !isAbsolute(meta.cwd)) continue;
|
|
57
|
+
const text = (value: unknown) => (typeof value === 'string' && value ? value : undefined);
|
|
58
|
+
return source === 'codex'
|
|
59
|
+
? {
|
|
60
|
+
cwd: resolve(meta.cwd),
|
|
61
|
+
branch: text(meta.git?.branch),
|
|
62
|
+
repository: text(meta.git?.repository_url),
|
|
63
|
+
}
|
|
64
|
+
: { cwd: resolve(meta.cwd), branch: text(meta.gitBranch) };
|
|
47
65
|
}
|
|
48
66
|
}
|
|
49
67
|
|
|
68
|
+
const git = (cwd: string, ...args: string[]) => {
|
|
69
|
+
const run = Bun.spawnSync(['git', '-C', cwd, ...args], { stderr: 'ignore' });
|
|
70
|
+
return run.success ? run.stdout.toString().trim() : undefined;
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
function atlasGraph(cwd: string): AtlasFileConcepts | undefined {
|
|
74
|
+
if (!existsSync(`${cwd}/.atlas`)) return;
|
|
75
|
+
const run = Bun.spawnSync([process.execPath, 'x', 'atlas', 'graph', '--json'], {
|
|
76
|
+
cwd,
|
|
77
|
+
stderr: 'ignore',
|
|
78
|
+
});
|
|
79
|
+
if (!run.success) return;
|
|
80
|
+
try {
|
|
81
|
+
return JSON.parse(run.stdout.toString()).fileToConcepts;
|
|
82
|
+
} catch {}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function worktreesOf(root: string): string[] {
|
|
86
|
+
const listed = Bun.spawnSync(['git', '-C', root, 'worktree', 'list', '--porcelain'], {
|
|
87
|
+
stderr: 'ignore',
|
|
88
|
+
});
|
|
89
|
+
if (!listed.success) return [];
|
|
90
|
+
return listed.stdout
|
|
91
|
+
.toString()
|
|
92
|
+
.split('\n')
|
|
93
|
+
.filter((line) => line.startsWith('worktree '))
|
|
94
|
+
.map((line) => resolve(line.slice('worktree '.length)));
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Exact directories this collection accepts, re-read each scan so new worktrees join. */
|
|
98
|
+
export function collectionRoots(config: CollectionSource): Set<string> {
|
|
99
|
+
const roots = config.projectRoots.map((root) => resolve(root));
|
|
100
|
+
return new Set(config.worktrees ? [...roots, ...roots.flatMap(worktreesOf)] : roots);
|
|
101
|
+
}
|
|
102
|
+
|
|
50
103
|
export async function collectSessions(store: LocalArchiveStore, config: CollectionSource) {
|
|
51
|
-
const
|
|
104
|
+
const roots = collectionRoots(config);
|
|
52
105
|
const result = { imported: 0, unchanged: 0, skipped: 0, failed: 0 };
|
|
106
|
+
// Per scan: checkouts change between scans.
|
|
107
|
+
const repositories = new Map<string, string | undefined>();
|
|
108
|
+
const graphs = new Map<string, AtlasFileConcepts | undefined>();
|
|
53
109
|
for (const file of files(config.directory)) {
|
|
54
110
|
try {
|
|
55
|
-
const
|
|
111
|
+
const meta = sessionMetadata(file, config.source);
|
|
56
112
|
// Exact root selection avoids sweeping nested checkouts belonging to another organization.
|
|
57
|
-
if (!
|
|
113
|
+
if (!meta || !roots.has(meta.cwd)) {
|
|
58
114
|
result.skipped++;
|
|
59
115
|
continue;
|
|
60
116
|
}
|
|
@@ -74,7 +130,21 @@ export async function collectSessions(store: LocalArchiveStore, config: Collecti
|
|
|
74
130
|
result.failed++;
|
|
75
131
|
continue;
|
|
76
132
|
}
|
|
77
|
-
|
|
133
|
+
const { cwd } = meta;
|
|
134
|
+
if (!repositories.has(cwd)) repositories.set(cwd, git(cwd, 'remote', 'get-url', 'origin'));
|
|
135
|
+
if (config.atlas && !graphs.has(cwd)) graphs.set(cwd, atlasGraph(cwd));
|
|
136
|
+
const graph = graphs.get(cwd);
|
|
137
|
+
snapshot.tags = [
|
|
138
|
+
...new Set([
|
|
139
|
+
...(old?.snapshot.tags ?? []),
|
|
140
|
+
...(config.tags ?? []),
|
|
141
|
+
...provenanceTags({
|
|
142
|
+
branch: meta.branch,
|
|
143
|
+
repository: meta.repository ?? repositories.get(cwd),
|
|
144
|
+
}),
|
|
145
|
+
...(graph ? atlasTags(snapshot, graph, roots) : []),
|
|
146
|
+
]),
|
|
147
|
+
].slice(0, 100);
|
|
78
148
|
if (store.capture(snapshot).changed) result.imported++;
|
|
79
149
|
else result.unchanged++;
|
|
80
150
|
} catch {
|
package/src/config.ts
CHANGED
|
@@ -1,28 +1,74 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
|
-
import {
|
|
3
|
-
|
|
2
|
+
import {
|
|
3
|
+
chmodSync,
|
|
4
|
+
closeSync,
|
|
5
|
+
constants,
|
|
6
|
+
existsSync,
|
|
7
|
+
fstatSync,
|
|
8
|
+
mkdirSync,
|
|
9
|
+
openSync,
|
|
10
|
+
readFileSync,
|
|
11
|
+
renameSync,
|
|
12
|
+
writeFileSync,
|
|
13
|
+
} from 'node:fs';
|
|
14
|
+
import { dirname, isAbsolute } from 'node:path';
|
|
4
15
|
import { z } from 'zod';
|
|
5
16
|
|
|
6
17
|
const common = {
|
|
7
18
|
projectId: z.string().min(1).max(256),
|
|
8
19
|
url: z.url(),
|
|
9
|
-
tokenEnv: z
|
|
20
|
+
tokenEnv: z
|
|
21
|
+
.string()
|
|
22
|
+
.regex(/^[A-Z][A-Z0-9_]+$/)
|
|
23
|
+
.optional(),
|
|
24
|
+
tokenFile: z.string().max(4096).refine(isAbsolute, 'tokenFile must be absolute').optional(),
|
|
10
25
|
};
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
26
|
+
/** Kingdom derives the owner from the runtime credential; these narrow it to a space or organization it manages. */
|
|
27
|
+
export const kingdomOwnerFields = {
|
|
28
|
+
ownerModel: z.enum(['User', 'OrganizationUser', 'Organization', 'Space', 'SpaceUser']).optional(),
|
|
29
|
+
organizationId: z.uuid().optional(),
|
|
30
|
+
spaceId: z.uuid().optional(),
|
|
31
|
+
};
|
|
32
|
+
export const archiveDestinationSchema = z
|
|
33
|
+
.union([
|
|
34
|
+
z.strictObject({
|
|
35
|
+
...common,
|
|
36
|
+
kind: z.literal('archive'),
|
|
37
|
+
keepIds: z.array(z.string()).max(0).optional(),
|
|
38
|
+
}),
|
|
39
|
+
z.strictObject({
|
|
40
|
+
...common,
|
|
41
|
+
kind: z.literal('kingdom'),
|
|
42
|
+
/** A hosted Archive that Kingdom forwards to; omitted, Kingdom stores the archive itself. */
|
|
43
|
+
connectionId: z
|
|
44
|
+
.string()
|
|
45
|
+
.regex(/^[a-z0-9-]+$/)
|
|
46
|
+
.max(120)
|
|
47
|
+
.optional(),
|
|
48
|
+
...kingdomOwnerFields,
|
|
49
|
+
}),
|
|
50
|
+
])
|
|
51
|
+
.refine(
|
|
52
|
+
(d) => Boolean(d.tokenEnv) !== Boolean(d.tokenFile),
|
|
53
|
+
'Set exactly one of tokenEnv or tokenFile',
|
|
54
|
+
);
|
|
24
55
|
export type ArchiveDestination = z.infer<typeof archiveDestinationSchema>;
|
|
25
56
|
|
|
57
|
+
/** Private regular file owned by this user; a symlink or group/world access is refused. */
|
|
58
|
+
export function readTokenFile(path: string) {
|
|
59
|
+
const fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
60
|
+
try {
|
|
61
|
+
const stat = fstatSync(fd);
|
|
62
|
+
if (!stat.isFile() || stat.uid !== process.getuid?.() || stat.mode & 0o077)
|
|
63
|
+
throw new Error('Token file must be a regular file owned by you with mode 0600');
|
|
64
|
+
return readFileSync(fd, 'utf8').trim();
|
|
65
|
+
} finally {
|
|
66
|
+
closeSync(fd);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
export const destinationToken = (destination: ArchiveDestination) =>
|
|
70
|
+
destination.tokenFile ? readTokenFile(destination.tokenFile) : process.env[destination.tokenEnv!];
|
|
71
|
+
|
|
26
72
|
export function destinationUrl(input: string) {
|
|
27
73
|
const url = new URL(input);
|
|
28
74
|
if (
|
|
@@ -52,7 +98,14 @@ export function destinationIdentity(destination: ArchiveDestination) {
|
|
|
52
98
|
return JSON.stringify([
|
|
53
99
|
url.origin,
|
|
54
100
|
new URL(destination.url).pathname,
|
|
55
|
-
destination.kind === 'archive'
|
|
101
|
+
destination.kind === 'archive'
|
|
102
|
+
? 'standalone'
|
|
103
|
+
: [
|
|
104
|
+
destination.connectionId ?? 'kingdom',
|
|
105
|
+
destination.ownerModel ?? null,
|
|
106
|
+
destination.organizationId ?? null,
|
|
107
|
+
destination.spaceId ?? null,
|
|
108
|
+
],
|
|
56
109
|
]);
|
|
57
110
|
}
|
|
58
111
|
export function connectDestination(file: string, input: unknown) {
|
|
@@ -72,9 +125,11 @@ export function connectDestination(file: string, input: unknown) {
|
|
|
72
125
|
chmodSync(file, 0o600);
|
|
73
126
|
return {
|
|
74
127
|
configured: true,
|
|
75
|
-
kind: destination.kind
|
|
128
|
+
kind: destination.kind,
|
|
76
129
|
projectId: destination.projectId,
|
|
77
130
|
url: destination.url,
|
|
78
|
-
|
|
131
|
+
...(destination.tokenFile
|
|
132
|
+
? { tokenFile: destination.tokenFile }
|
|
133
|
+
: { tokenEnv: destination.tokenEnv }),
|
|
79
134
|
};
|
|
80
135
|
}
|
package/src/import.ts
CHANGED
|
@@ -40,8 +40,13 @@ export function importTranscriptLines(
|
|
|
40
40
|
sessionId ??= id;
|
|
41
41
|
}
|
|
42
42
|
if (options.source === 'claude-code' && row.sessionId) {
|
|
43
|
-
|
|
44
|
-
|
|
43
|
+
// Subagent transcripts reuse their parent's sessionId; each is its own archive.
|
|
44
|
+
const id =
|
|
45
|
+
row.isSidechain && typeof row.agentId === 'string'
|
|
46
|
+
? `${row.sessionId}/agent-${row.agentId}`
|
|
47
|
+
: row.sessionId;
|
|
48
|
+
if (sessionId && sessionId !== id) throw new Error('Mixed transcript sessions');
|
|
49
|
+
sessionId = id;
|
|
45
50
|
}
|
|
46
51
|
const timestamp = typeof row.timestamp === 'number' ? row.timestamp : Date.parse(row.timestamp);
|
|
47
52
|
const add = (kind: ArchiveEntry['kind'], content: unknown, suffix: string, callId?: string) => {
|
package/src/local.ts
CHANGED
|
@@ -7,11 +7,11 @@ import {
|
|
|
7
7
|
type ArchiveSnapshot,
|
|
8
8
|
archiveKey,
|
|
9
9
|
archiveSnapshotSchema,
|
|
10
|
-
categorySuggestions,
|
|
11
10
|
chunkArchive,
|
|
12
11
|
selectChunks,
|
|
13
12
|
snapshotDigest,
|
|
14
13
|
} from './index';
|
|
14
|
+
import { suggestTags } from './tags';
|
|
15
15
|
|
|
16
16
|
export class LocalArchiveStore {
|
|
17
17
|
private readonly db: Database;
|
|
@@ -94,7 +94,7 @@ export class LocalArchiveStore {
|
|
|
94
94
|
digest: archive.digest,
|
|
95
95
|
...snapshot,
|
|
96
96
|
entries: entries.length,
|
|
97
|
-
suggestedTags:
|
|
97
|
+
suggestedTags: suggestTags(archive.snapshot),
|
|
98
98
|
};
|
|
99
99
|
});
|
|
100
100
|
}
|
package/src/tags.ts
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { type ArchiveSnapshot, categorySuggestions } from './index';
|
|
2
|
+
|
|
3
|
+
export type TagSuggestion = { tag: string; origin: 'heuristic' | 'reference' };
|
|
4
|
+
|
|
5
|
+
const references: [RegExp, (match: RegExpExecArray) => string][] = [
|
|
6
|
+
[
|
|
7
|
+
/github\.com\/([\w.-]+)\/([\w.-]+)\/(?:pull|issues)\/(\d+)/g,
|
|
8
|
+
(m) => `github:${m[1]}/${m[2]}#${m[3]}`.toLowerCase(),
|
|
9
|
+
],
|
|
10
|
+
[
|
|
11
|
+
/github\.com\/([\w.-]+)\/([\w.-]+)\/commit\/([0-9a-f]{7,40})\b/g,
|
|
12
|
+
(m) => `github:${m[1]}/${m[2]}@${m[3].slice(0, 7)}`.toLowerCase(),
|
|
13
|
+
],
|
|
14
|
+
[
|
|
15
|
+
/linear\.app\/[\w-]+\/issue\/([A-Za-z][A-Za-z0-9]*-\d+)/g,
|
|
16
|
+
(m) => `linear:${m[1].toUpperCase()}`,
|
|
17
|
+
],
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
/** Issues, pull requests and commits the transcript links to, most-referenced first. A mention is not proof of work, so these stay suggestions. */
|
|
21
|
+
export function referenceSuggestions(snapshot: ArchiveSnapshot, limit = 25): TagSuggestion[] {
|
|
22
|
+
const counts = new Map<string, number>();
|
|
23
|
+
for (const entry of snapshot.entries)
|
|
24
|
+
for (const [pattern, tag] of references)
|
|
25
|
+
for (const match of entry.text.matchAll(pattern)) {
|
|
26
|
+
const value = tag(match);
|
|
27
|
+
counts.set(value, (counts.get(value) ?? 0) + 1);
|
|
28
|
+
}
|
|
29
|
+
return [...counts]
|
|
30
|
+
.sort((a, b) => b[1] - a[1])
|
|
31
|
+
.slice(0, limit)
|
|
32
|
+
.map(([tag]) => ({ tag, origin: 'reference' }));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Every computed suggestion for an archive. Suggestions never route or authorize. */
|
|
36
|
+
export function suggestTags(snapshot: ArchiveSnapshot): TagSuggestion[] {
|
|
37
|
+
return [...categorySuggestions(snapshot), ...referenceSuggestions(snapshot)];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Recorded git context of a session, as explicit tags. */
|
|
41
|
+
export function provenanceTags(context: { repository?: string; branch?: string }): string[] {
|
|
42
|
+
const repository = context.repository?.match(/github\.com[:/]([\w.-]+\/[\w.-]+?)(?:\.git)?\/?$/);
|
|
43
|
+
return [
|
|
44
|
+
...(repository ? [`repo:${repository[1].toLowerCase()}`] : []),
|
|
45
|
+
...(context.branch && context.branch !== 'HEAD' ? [`branch:${context.branch}`] : []),
|
|
46
|
+
].filter((tag) => tag.length <= 120);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** `atlas graph --json`: repository-relative file paths to the concepts they are part of. */
|
|
50
|
+
export type AtlasFileConcepts = Record<string, { partOf: string[] }>;
|
|
51
|
+
|
|
52
|
+
/** Atlas concepts of the repository files a session's tool calls touched. */
|
|
53
|
+
export function atlasTags(
|
|
54
|
+
snapshot: ArchiveSnapshot,
|
|
55
|
+
fileToConcepts: AtlasFileConcepts,
|
|
56
|
+
roots: Iterable<string>,
|
|
57
|
+
limit = 20,
|
|
58
|
+
): string[] {
|
|
59
|
+
// Longest first: worktrees can live inside their main checkout.
|
|
60
|
+
const prefixes = [...roots]
|
|
61
|
+
.map((root) => `${root.replace(/\/+$/, '')}/`)
|
|
62
|
+
.sort((a, b) => b.length - a.length);
|
|
63
|
+
const counts = new Map<string, number>();
|
|
64
|
+
for (const entry of snapshot.entries) {
|
|
65
|
+
if (entry.kind !== 'tool-call') continue;
|
|
66
|
+
for (const [path] of entry.text.matchAll(/[\w@.~/-]+\.[A-Za-z]{1,6}\b/g)) {
|
|
67
|
+
const prefix = prefixes.find((candidate) => path.startsWith(candidate));
|
|
68
|
+
const relative = prefix ? path.slice(prefix.length) : path;
|
|
69
|
+
for (const concept of fileToConcepts[relative]?.partOf ?? [])
|
|
70
|
+
counts.set(concept, (counts.get(concept) ?? 0) + 1);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return [...counts]
|
|
74
|
+
.sort((a, b) => b[1] - a[1])
|
|
75
|
+
.slice(0, limit)
|
|
76
|
+
.map(([concept]) => `atlas:${concept}`)
|
|
77
|
+
.filter((tag) => tag.length <= 120);
|
|
78
|
+
}
|