agent-trellis 0.1.0 → 0.3.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 +69 -17
- package/dist/adapters/claude-code.d.ts +5 -3
- package/dist/adapters/claude-code.js +27 -14
- package/dist/adapters/codex.d.ts +8 -4
- package/dist/adapters/codex.js +47 -16
- package/dist/adapters/jsonMcp.d.ts +16 -5
- package/dist/adapters/jsonMcp.js +38 -29
- package/dist/adapters/kiro.d.ts +5 -3
- package/dist/adapters/kiro.js +29 -16
- package/dist/adapters/mcpPlan.d.ts +11 -6
- package/dist/adapters/mcpPlan.js +40 -7
- package/dist/adapters/pi.d.ts +2 -1
- package/dist/adapters/pi.js +4 -4
- package/dist/adapters/symlinkPlan.d.ts +7 -3
- package/dist/adapters/symlinkPlan.js +42 -16
- package/dist/cli.js +161 -18
- package/dist/commands/init.js +11 -0
- package/dist/commands/mcp.d.ts +114 -7
- package/dist/commands/mcp.js +258 -17
- package/dist/commands/memory.d.ts +39 -0
- package/dist/commands/memory.js +78 -0
- package/dist/commands/migrate.d.ts +30 -4
- package/dist/commands/migrate.js +83 -16
- package/dist/commands/onboard.d.ts +52 -7
- package/dist/commands/onboard.js +318 -35
- package/dist/commands/rollback.d.ts +44 -0
- package/dist/commands/rollback.js +201 -0
- package/dist/commands/secretsAudit.d.ts +7 -0
- package/dist/commands/secretsAudit.js +14 -7
- package/dist/commands/skill.d.ts +51 -0
- package/dist/commands/skill.js +104 -0
- package/dist/commands/sync.d.ts +13 -0
- package/dist/commands/sync.js +31 -5
- package/dist/core/adapter.d.ts +28 -11
- package/dist/core/adapter.js +2 -2
- package/dist/core/canonical.d.ts +26 -1
- package/dist/core/canonical.js +103 -3
- package/dist/core/types.d.ts +29 -1
- package/dist/core/types.js +11 -2
- package/dist/lib/backup.d.ts +56 -0
- package/dist/lib/backup.js +98 -0
- package/dist/lib/deepEqual.d.ts +8 -0
- package/dist/lib/deepEqual.js +26 -0
- package/dist/lib/dirEquals.d.ts +9 -0
- package/dist/lib/dirEquals.js +15 -1
- package/dist/lib/installAgent.d.ts +26 -0
- package/dist/lib/installAgent.js +46 -0
- package/dist/lib/mcpMigrateRead.d.ts +69 -0
- package/dist/lib/mcpMigrateRead.js +188 -0
- package/dist/lib/mcpOwnership.d.ts +25 -0
- package/dist/lib/mcpOwnership.js +50 -0
- package/dist/lib/memoryGraph.d.ts +60 -0
- package/dist/lib/memoryGraph.js +101 -0
- package/dist/lib/realHomeSnapshot.d.ts +26 -0
- package/dist/lib/realHomeSnapshot.js +77 -0
- package/dist/lib/terminalPicker.d.ts +45 -0
- package/dist/lib/terminalPicker.js +193 -0
- package/dist/lib/tomlSection.d.ts +20 -6
- package/dist/lib/tomlSection.js +78 -12
- package/dist/pi-bridge/bundle.js +100 -51
- package/dist/pi-bridge/index.js +14 -2
- package/dist/probes/codex.js +10 -2
- package/docs/architecture.md +7 -4
- package/docs/getting-started.md +267 -33
- package/docs/roadmap.md +444 -0
- package/package.json +1 -1
- package/schema/servers.example.yaml +39 -2
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reads an agent's own real, already-configured MCP servers and converts
|
|
3
|
+
* each into canonical's `McpServerDef` shape — `trellis migrate --only
|
|
4
|
+
* mcp`'s read path (trellis-migrate-mcp-servers). Deliberately separate
|
|
5
|
+
* from each probe's own `AgentSnapshotMcpServer` (src/probes/*.ts) and its
|
|
6
|
+
* thin `{name, transport, probe?}` shape used by `doctor` — that shape
|
|
7
|
+
* discards exactly the fields this module needs, and extending it would
|
|
8
|
+
* risk `doctor`'s existing, extensively-tested behavior for no reason
|
|
9
|
+
* (design.md D1). pi has no static MCP config to read at all — no reader
|
|
10
|
+
* is defined for it; `migrate.ts` skips pi for the `mcp` category
|
|
11
|
+
* entirely, the same fact already established for skills/instructions.
|
|
12
|
+
*/
|
|
13
|
+
import { execFileSync } from "node:child_process";
|
|
14
|
+
import { readFileSync } from "node:fs";
|
|
15
|
+
import { join } from "node:path";
|
|
16
|
+
import { readJsonFile } from "./probeCommon.js";
|
|
17
|
+
import { readServerEnvTable } from "./tomlSection.js";
|
|
18
|
+
const EMPTY_RESULT = { entries: [], unsupported: [] };
|
|
19
|
+
/**
|
|
20
|
+
* Trellis's own `env` (name-only reference) vs. `staticEnv` (literal
|
|
21
|
+
* value) split lives in one JSON object on disk (`jsonMcp.ts`'s
|
|
22
|
+
* `renderJsonServerEntry`) — a value is a "name" entry only when it's
|
|
23
|
+
* exactly `${KEY}` referencing its OWN key, matching exactly what that
|
|
24
|
+
* renderer ever produces; anything else (a literal value, or a `${...}`
|
|
25
|
+
* referencing a *different* name) is treated as a literal `staticEnv`
|
|
26
|
+
* value instead of guessed at.
|
|
27
|
+
*/
|
|
28
|
+
const SELF_VAR_REF_RE = /^\$\{([A-Za-z_][A-Za-z0-9_]*)\}$/;
|
|
29
|
+
function splitJsonEnvMap(env) {
|
|
30
|
+
if (!env)
|
|
31
|
+
return {};
|
|
32
|
+
const names = [];
|
|
33
|
+
const literal = {};
|
|
34
|
+
for (const [key, value] of Object.entries(env)) {
|
|
35
|
+
const match = SELF_VAR_REF_RE.exec(value);
|
|
36
|
+
if (match && match[1] === key) {
|
|
37
|
+
names.push(key);
|
|
38
|
+
}
|
|
39
|
+
else {
|
|
40
|
+
literal[key] = value;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
const result = {};
|
|
44
|
+
if (names.length > 0)
|
|
45
|
+
result.env = names;
|
|
46
|
+
if (Object.keys(literal).length > 0)
|
|
47
|
+
result.staticEnv = literal;
|
|
48
|
+
return result;
|
|
49
|
+
}
|
|
50
|
+
function fromRichJsonServerDef(raw) {
|
|
51
|
+
if (raw.url) {
|
|
52
|
+
const transport = raw.type === "sse" ? "sse" : "http";
|
|
53
|
+
const def = { transport, url: raw.url };
|
|
54
|
+
if (raw.headers && Object.keys(raw.headers).length > 0)
|
|
55
|
+
def.headers = raw.headers;
|
|
56
|
+
return def;
|
|
57
|
+
}
|
|
58
|
+
if (!raw.command)
|
|
59
|
+
return undefined;
|
|
60
|
+
const def = { transport: "stdio", command: raw.command };
|
|
61
|
+
if (raw.args && raw.args.length > 0)
|
|
62
|
+
def.args = raw.args;
|
|
63
|
+
Object.assign(def, splitJsonEnvMap(raw.env));
|
|
64
|
+
return def;
|
|
65
|
+
}
|
|
66
|
+
function readRichJsonMcpDefs(configPath) {
|
|
67
|
+
const parsed = readJsonFile(configPath);
|
|
68
|
+
const entries = [];
|
|
69
|
+
const unsupported = [];
|
|
70
|
+
for (const [name, raw] of Object.entries(parsed?.mcpServers ?? {})) {
|
|
71
|
+
const def = fromRichJsonServerDef(raw);
|
|
72
|
+
if (def) {
|
|
73
|
+
entries.push({ name, def });
|
|
74
|
+
}
|
|
75
|
+
else {
|
|
76
|
+
unsupported.push({ name, reason: "stdio entry has no command — malformed, skipped" });
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return { entries, unsupported };
|
|
80
|
+
}
|
|
81
|
+
export function readClaudeCodeMcpDefs(homeDir) {
|
|
82
|
+
return readRichJsonMcpDefs(join(homeDir, ".claude.json"));
|
|
83
|
+
}
|
|
84
|
+
export function readKiroMcpDefs(homeDir) {
|
|
85
|
+
return readRichJsonMcpDefs(join(homeDir, ".kiro", "settings", "mcp.json"));
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Pure conversion half — separated from the subprocess/file-read effects
|
|
89
|
+
* below so the stdio/non-stdio/malformed decision logic is unit-testable
|
|
90
|
+
* without a real `codex` binary on PATH (this codebase has no fake-codex
|
|
91
|
+
* fixture anywhere; codex's subprocess dependency is otherwise entirely
|
|
92
|
+
* untested at the unit level).
|
|
93
|
+
*/
|
|
94
|
+
export function buildCodexMcpReadResult(mcpEntries, tomlContent) {
|
|
95
|
+
const entries = [];
|
|
96
|
+
const unsupported = [];
|
|
97
|
+
for (const entry of mcpEntries) {
|
|
98
|
+
if (entry.transport.type !== "stdio") {
|
|
99
|
+
const remote = buildCodexRemoteDef(entry);
|
|
100
|
+
if (remote.def) {
|
|
101
|
+
entries.push({ name: entry.name, def: remote.def });
|
|
102
|
+
}
|
|
103
|
+
else {
|
|
104
|
+
unsupported.push({ name: entry.name, reason: remote.reason });
|
|
105
|
+
}
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
if (!entry.transport.command) {
|
|
109
|
+
unsupported.push({ name: entry.name, reason: "stdio entry has no command — malformed, skipped" });
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
const def = { transport: "stdio", command: entry.transport.command };
|
|
113
|
+
if (entry.transport.args && entry.transport.args.length > 0)
|
|
114
|
+
def.args = entry.transport.args;
|
|
115
|
+
if (entry.transport.env_vars && entry.transport.env_vars.length > 0)
|
|
116
|
+
def.env = entry.transport.env_vars;
|
|
117
|
+
if (tomlContent) {
|
|
118
|
+
const staticEnv = readServerEnvTable(tomlContent, entry.name);
|
|
119
|
+
if (staticEnv && Object.keys(staticEnv).length > 0)
|
|
120
|
+
def.staticEnv = staticEnv;
|
|
121
|
+
}
|
|
122
|
+
entries.push({ name: entry.name, def });
|
|
123
|
+
}
|
|
124
|
+
return { entries, unsupported };
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* A non-stdio Codex entry converts only when it's exactly `url` (+
|
|
128
|
+
* optional `bearer_token_env_var`) — the one shape this project has
|
|
129
|
+
* verified evidence for, and the only shape Trellis's own writer
|
|
130
|
+
* (`renderServerSection`'s else-branch) ever produces for Codex. Any of
|
|
131
|
+
* the three unexplained `*headers*` fields being non-null means the real
|
|
132
|
+
* server uses a mechanism this codebase has no verified shape for — that
|
|
133
|
+
* entry stays unsupported rather than silently dropping whatever those
|
|
134
|
+
* fields represent.
|
|
135
|
+
*/
|
|
136
|
+
function buildCodexRemoteDef(entry) {
|
|
137
|
+
const { url, bearer_token_env_var: bearerVar, http_headers, env_http_headers, http_headers_helper } = entry.transport;
|
|
138
|
+
if (http_headers != null || env_http_headers != null || http_headers_helper != null) {
|
|
139
|
+
return { reason: `codex migrate-in does not support this server's header mechanism (http_headers/env_http_headers/http_headers_helper) — no verified shape for it` };
|
|
140
|
+
}
|
|
141
|
+
if (!url) {
|
|
142
|
+
return { reason: `codex migrate-in: non-stdio entry has no url — malformed, skipped` };
|
|
143
|
+
}
|
|
144
|
+
const def = { transport: "http", url };
|
|
145
|
+
if (bearerVar) {
|
|
146
|
+
def.headers = { Authorization: `Bearer \${${bearerVar}}` };
|
|
147
|
+
}
|
|
148
|
+
return { def };
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* Stdio only (design.md D2) — `codex mcp list --json`'s own output never
|
|
152
|
+
* exposes `url`/`bearer_token_env_var` in this codebase's `CodexMcpEntry`
|
|
153
|
+
* (src/probes/codex.ts), and this codebase has no verified evidence for
|
|
154
|
+
* what that subprocess reports for a non-stdio server. Guessing at an
|
|
155
|
+
* external tool's unverified output shape is exactly what this project's
|
|
156
|
+
* "verify, don't assume" discipline exists to prevent — a non-stdio
|
|
157
|
+
* entry is reported as unsupported instead.
|
|
158
|
+
*/
|
|
159
|
+
export function readCodexMcpDefs(homeDir) {
|
|
160
|
+
let raw;
|
|
161
|
+
try {
|
|
162
|
+
// `codex` resolves its own config via $HOME (confirmed by running it
|
|
163
|
+
// with an overridden HOME against an empty scratch dir — it returns
|
|
164
|
+
// `[]`, not the real machine's servers), so this must be scoped to
|
|
165
|
+
// `homeDir` explicitly — `src/probes/codex.ts`'s own equivalent call
|
|
166
|
+
// has this same gap, unaddressed there; not touched by this change.
|
|
167
|
+
raw = execFileSync("codex", ["mcp", "list", "--json"], { encoding: "utf-8", timeout: 5_000, env: { ...process.env, HOME: homeDir } });
|
|
168
|
+
}
|
|
169
|
+
catch {
|
|
170
|
+
return EMPTY_RESULT;
|
|
171
|
+
}
|
|
172
|
+
let mcpEntries;
|
|
173
|
+
try {
|
|
174
|
+
mcpEntries = JSON.parse(raw);
|
|
175
|
+
}
|
|
176
|
+
catch {
|
|
177
|
+
return EMPTY_RESULT;
|
|
178
|
+
}
|
|
179
|
+
const configToml = join(homeDir, ".codex", "config.toml");
|
|
180
|
+
let tomlContent;
|
|
181
|
+
try {
|
|
182
|
+
tomlContent = readFileSync(configToml, "utf-8");
|
|
183
|
+
}
|
|
184
|
+
catch {
|
|
185
|
+
tomlContent = undefined;
|
|
186
|
+
}
|
|
187
|
+
return buildCodexMcpReadResult(mcpEntries, tomlContent);
|
|
188
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Persisted record of what `mcp sync` itself last wrote for each agent —
|
|
3
|
+
* the ownership marker a bare TOML/JSON key otherwise lacks (mcpPlan.ts's
|
|
4
|
+
* own D7 reasoning: unlike a skill's symlink, there's nothing on disk to
|
|
5
|
+
* prove Trellis, not the user, put a given entry there). This ledger is
|
|
6
|
+
* that proof, kept separately from canonical (`~/.trellis/mcp/
|
|
7
|
+
* servers.yaml`) since it's Trellis's own private bookkeeping, not
|
|
8
|
+
* user-authored content.
|
|
9
|
+
*
|
|
10
|
+
* Stores each entry as the exact *rendered* value written at the time
|
|
11
|
+
* (a plain object for JSON agents, a TOML section string for Codex) —
|
|
12
|
+
* deliberately not a hash, so the later "is this still what we wrote"
|
|
13
|
+
* check can reuse each format's own already-existing equality check
|
|
14
|
+
* (`deepEqual` for JSON, `===` for TOML text) instead of a second,
|
|
15
|
+
* parallel comparison mechanism.
|
|
16
|
+
*/
|
|
17
|
+
import type { AgentId } from "../core/types.js";
|
|
18
|
+
export type McpOwnershipLedger = Record<string, Record<string, unknown>>;
|
|
19
|
+
export declare function mcpOwnershipPath(homeDir: string): string;
|
|
20
|
+
export declare function loadMcpOwnership(homeDir: string): McpOwnershipLedger;
|
|
21
|
+
export declare function saveMcpOwnership(homeDir: string, ledger: McpOwnershipLedger): void;
|
|
22
|
+
/** This agent's own slice — `{}` if nothing has ever been recorded for it. */
|
|
23
|
+
export declare function ownedByAgent(ledger: McpOwnershipLedger, agentId: AgentId): Record<string, unknown>;
|
|
24
|
+
export declare function recordOwned(ledger: McpOwnershipLedger, agentId: AgentId, name: string, rendered: unknown): McpOwnershipLedger;
|
|
25
|
+
export declare function forgetOwned(ledger: McpOwnershipLedger, agentId: AgentId, name: string): McpOwnershipLedger;
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Persisted record of what `mcp sync` itself last wrote for each agent —
|
|
3
|
+
* the ownership marker a bare TOML/JSON key otherwise lacks (mcpPlan.ts's
|
|
4
|
+
* own D7 reasoning: unlike a skill's symlink, there's nothing on disk to
|
|
5
|
+
* prove Trellis, not the user, put a given entry there). This ledger is
|
|
6
|
+
* that proof, kept separately from canonical (`~/.trellis/mcp/
|
|
7
|
+
* servers.yaml`) since it's Trellis's own private bookkeeping, not
|
|
8
|
+
* user-authored content.
|
|
9
|
+
*
|
|
10
|
+
* Stores each entry as the exact *rendered* value written at the time
|
|
11
|
+
* (a plain object for JSON agents, a TOML section string for Codex) —
|
|
12
|
+
* deliberately not a hash, so the later "is this still what we wrote"
|
|
13
|
+
* check can reuse each format's own already-existing equality check
|
|
14
|
+
* (`deepEqual` for JSON, `===` for TOML text) instead of a second,
|
|
15
|
+
* parallel comparison mechanism.
|
|
16
|
+
*/
|
|
17
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
18
|
+
import { join } from "node:path";
|
|
19
|
+
export function mcpOwnershipPath(homeDir) {
|
|
20
|
+
return join(homeDir, ".trellis", "mcp", "ownership.json");
|
|
21
|
+
}
|
|
22
|
+
export function loadMcpOwnership(homeDir) {
|
|
23
|
+
const path = mcpOwnershipPath(homeDir);
|
|
24
|
+
if (!existsSync(path))
|
|
25
|
+
return {};
|
|
26
|
+
try {
|
|
27
|
+
return JSON.parse(readFileSync(path, "utf-8"));
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
return {}; // unreadable/corrupt ledger — treat as "nothing recorded yet", never crash a sync over it
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
export function saveMcpOwnership(homeDir, ledger) {
|
|
34
|
+
mkdirSync(join(homeDir, ".trellis", "mcp"), { recursive: true });
|
|
35
|
+
writeFileSync(mcpOwnershipPath(homeDir), `${JSON.stringify(ledger, null, 2)}\n`);
|
|
36
|
+
}
|
|
37
|
+
/** This agent's own slice — `{}` if nothing has ever been recorded for it. */
|
|
38
|
+
export function ownedByAgent(ledger, agentId) {
|
|
39
|
+
return ledger[agentId] ?? {};
|
|
40
|
+
}
|
|
41
|
+
export function recordOwned(ledger, agentId, name, rendered) {
|
|
42
|
+
return { ...ledger, [agentId]: { ...(ledger[agentId] ?? {}), [name]: rendered } };
|
|
43
|
+
}
|
|
44
|
+
export function forgetOwned(ledger, agentId, name) {
|
|
45
|
+
if (!(name in (ledger[agentId] ?? {})))
|
|
46
|
+
return ledger;
|
|
47
|
+
const agentEntries = { ...ledger[agentId] };
|
|
48
|
+
delete agentEntries[name];
|
|
49
|
+
return { ...ledger, [agentId]: agentEntries };
|
|
50
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Converts canonical `memories/*.md` into `@modelcontextprotocol/
|
|
3
|
+
* server-memory`'s own on-disk JSON-lines knowledge-graph format, and
|
|
4
|
+
* upserts them into an existing graph file without disturbing anything
|
|
5
|
+
* else in it (trellis-memory-sync) — closing P6's explicitly-left-open
|
|
6
|
+
* "auto-ingesting memories/*.md content into the running memory
|
|
7
|
+
* server's store" gap.
|
|
8
|
+
*
|
|
9
|
+
* Each line in that file is one JSON object, either
|
|
10
|
+
* `{type:"entity", name, entityType, observations}` or
|
|
11
|
+
* `{type:"relation", from, to, relationType}` — the real, documented
|
|
12
|
+
* shape the official server persists and reads back on its own next
|
|
13
|
+
* startup (this project writes the file directly; it never spawns or
|
|
14
|
+
* talks to a running server process).
|
|
15
|
+
*
|
|
16
|
+
* `entityType: "trellis-memory"` is this project's own in-band ownership
|
|
17
|
+
* marker — simpler than a separate ledger file (unlike MCP server sync,
|
|
18
|
+
* every agent connected to this one server shares the exact same graph,
|
|
19
|
+
* so there's no per-agent rendering to track). An existing entity with
|
|
20
|
+
* the same name but a different `entityType` was created by something
|
|
21
|
+
* else (an agent's own runtime tool calls, most likely) and is left
|
|
22
|
+
* completely untouched — reported as a conflict, never overwritten.
|
|
23
|
+
*/
|
|
24
|
+
import type { CanonicalSource } from "../core/types.js";
|
|
25
|
+
export interface MemoryEntity {
|
|
26
|
+
type: "entity";
|
|
27
|
+
name: string;
|
|
28
|
+
entityType: string;
|
|
29
|
+
observations: string[];
|
|
30
|
+
}
|
|
31
|
+
export interface MemoryRelation {
|
|
32
|
+
type: "relation";
|
|
33
|
+
from: string;
|
|
34
|
+
to: string;
|
|
35
|
+
relationType: string;
|
|
36
|
+
}
|
|
37
|
+
export type MemoryGraphLine = MemoryEntity | MemoryRelation;
|
|
38
|
+
export declare const TRELLIS_MEMORY_ENTITY_TYPE = "trellis-memory";
|
|
39
|
+
export declare function parseMemoryGraph(content: string): MemoryGraphLine[];
|
|
40
|
+
export declare function renderMemoryGraph(lines: readonly MemoryGraphLine[]): string;
|
|
41
|
+
export type MemorySyncAction = "create" | "already-synced" | "remove" | "conflict";
|
|
42
|
+
export interface MemorySyncItem {
|
|
43
|
+
name: string;
|
|
44
|
+
action: MemorySyncAction;
|
|
45
|
+
detail: string;
|
|
46
|
+
}
|
|
47
|
+
export interface MemorySyncPlan {
|
|
48
|
+
items: MemorySyncItem[];
|
|
49
|
+
/** The full graph content to write — `undefined` when there is
|
|
50
|
+
* nothing to change (no create/remove items). */
|
|
51
|
+
nextGraph?: MemoryGraphLine[];
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Pure: computes the next graph state and a human-readable plan, given
|
|
55
|
+
* the current graph's raw content (or `undefined` if the file doesn't
|
|
56
|
+
* exist yet) and canonical's memory entries. Every non-`trellis-memory`
|
|
57
|
+
* entity and every relation passes through completely untouched,
|
|
58
|
+
* regardless of what canonical wants.
|
|
59
|
+
*/
|
|
60
|
+
export declare function planMemorySync(canonical: Pick<CanonicalSource, "memories">, currentGraphContent: string | undefined): MemorySyncPlan;
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Converts canonical `memories/*.md` into `@modelcontextprotocol/
|
|
3
|
+
* server-memory`'s own on-disk JSON-lines knowledge-graph format, and
|
|
4
|
+
* upserts them into an existing graph file without disturbing anything
|
|
5
|
+
* else in it (trellis-memory-sync) — closing P6's explicitly-left-open
|
|
6
|
+
* "auto-ingesting memories/*.md content into the running memory
|
|
7
|
+
* server's store" gap.
|
|
8
|
+
*
|
|
9
|
+
* Each line in that file is one JSON object, either
|
|
10
|
+
* `{type:"entity", name, entityType, observations}` or
|
|
11
|
+
* `{type:"relation", from, to, relationType}` — the real, documented
|
|
12
|
+
* shape the official server persists and reads back on its own next
|
|
13
|
+
* startup (this project writes the file directly; it never spawns or
|
|
14
|
+
* talks to a running server process).
|
|
15
|
+
*
|
|
16
|
+
* `entityType: "trellis-memory"` is this project's own in-band ownership
|
|
17
|
+
* marker — simpler than a separate ledger file (unlike MCP server sync,
|
|
18
|
+
* every agent connected to this one server shares the exact same graph,
|
|
19
|
+
* so there's no per-agent rendering to track). An existing entity with
|
|
20
|
+
* the same name but a different `entityType` was created by something
|
|
21
|
+
* else (an agent's own runtime tool calls, most likely) and is left
|
|
22
|
+
* completely untouched — reported as a conflict, never overwritten.
|
|
23
|
+
*/
|
|
24
|
+
import { readFileSync } from "node:fs";
|
|
25
|
+
export const TRELLIS_MEMORY_ENTITY_TYPE = "trellis-memory";
|
|
26
|
+
export function parseMemoryGraph(content) {
|
|
27
|
+
const lines = [];
|
|
28
|
+
for (const raw of content.split("\n")) {
|
|
29
|
+
const trimmed = raw.trim();
|
|
30
|
+
if (!trimmed)
|
|
31
|
+
continue;
|
|
32
|
+
try {
|
|
33
|
+
const parsed = JSON.parse(trimmed);
|
|
34
|
+
if (parsed.type === "entity" || parsed.type === "relation") {
|
|
35
|
+
lines.push(parsed);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
// Not valid JSON — skip rather than fail the whole file; a
|
|
40
|
+
// hand-edited or partially-written line shouldn't block every
|
|
41
|
+
// other, unrelated line in the graph.
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return lines;
|
|
45
|
+
}
|
|
46
|
+
export function renderMemoryGraph(lines) {
|
|
47
|
+
if (lines.length === 0)
|
|
48
|
+
return "";
|
|
49
|
+
return `${lines.map((line) => JSON.stringify(line)).join("\n")}\n`;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Pure: computes the next graph state and a human-readable plan, given
|
|
53
|
+
* the current graph's raw content (or `undefined` if the file doesn't
|
|
54
|
+
* exist yet) and canonical's memory entries. Every non-`trellis-memory`
|
|
55
|
+
* entity and every relation passes through completely untouched,
|
|
56
|
+
* regardless of what canonical wants.
|
|
57
|
+
*/
|
|
58
|
+
export function planMemorySync(canonical, currentGraphContent) {
|
|
59
|
+
const existingLines = currentGraphContent !== undefined ? parseMemoryGraph(currentGraphContent) : [];
|
|
60
|
+
const untouchedLines = existingLines.filter((line) => !(line.type === "entity" && line.entityType === TRELLIS_MEMORY_ENTITY_TYPE));
|
|
61
|
+
const existingTrellisEntities = new Map(existingLines.filter((line) => line.type === "entity" && line.entityType === TRELLIS_MEMORY_ENTITY_TYPE).map((e) => [e.name, e]));
|
|
62
|
+
const existingOtherEntityNames = new Set(existingLines.filter((line) => line.type === "entity" && line.entityType !== TRELLIS_MEMORY_ENTITY_TYPE).map((e) => e.name));
|
|
63
|
+
const items = [];
|
|
64
|
+
const nextTrellisEntities = [];
|
|
65
|
+
let changed = false;
|
|
66
|
+
const canonicalNames = new Set(canonical.memories.map((m) => m.name));
|
|
67
|
+
for (const memory of canonical.memories) {
|
|
68
|
+
if (existingOtherEntityNames.has(memory.name)) {
|
|
69
|
+
items.push({ name: memory.name, action: "conflict", detail: `an entity named "${memory.name}" already exists in the graph and wasn't created by Trellis — resolve by hand` });
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
let content;
|
|
73
|
+
try {
|
|
74
|
+
content = readFileSync(memory.file, "utf-8");
|
|
75
|
+
}
|
|
76
|
+
catch (err) {
|
|
77
|
+
items.push({ name: memory.name, action: "conflict", detail: `could not read ${memory.file}: ${err instanceof Error ? err.message : String(err)}` });
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
const desired = { type: "entity", name: memory.name, entityType: TRELLIS_MEMORY_ENTITY_TYPE, observations: [content] };
|
|
81
|
+
const existing = existingTrellisEntities.get(memory.name);
|
|
82
|
+
nextTrellisEntities.push(desired);
|
|
83
|
+
if (existing && existing.observations.length === 1 && existing.observations[0] === content) {
|
|
84
|
+
items.push({ name: memory.name, action: "already-synced", detail: "graph content is already identical" });
|
|
85
|
+
}
|
|
86
|
+
else {
|
|
87
|
+
items.push({ name: memory.name, action: "create", detail: existing ? "will update the existing entity's observation" : "will create a new entity" });
|
|
88
|
+
changed = true;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
for (const [name] of existingTrellisEntities) {
|
|
92
|
+
if (canonicalNames.has(name))
|
|
93
|
+
continue; // still wanted, handled above
|
|
94
|
+
items.push({ name, action: "remove", detail: "no longer in canonical — will be removed from the graph" });
|
|
95
|
+
changed = true;
|
|
96
|
+
}
|
|
97
|
+
if (!changed) {
|
|
98
|
+
return { items };
|
|
99
|
+
}
|
|
100
|
+
return { items, nextGraph: [...untouchedLines, ...nextTrellisEntities] };
|
|
101
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `--real` sandbox mode's allowlist (trellis-real-sandbox-verification
|
|
3
|
+
* design.md D1): every real dotfile path a probe (src/probes/*.ts) is
|
|
4
|
+
* already known to read, and nothing else. Allowlist, not denylist —
|
|
5
|
+
* anything this project doesn't already know to be structural (real
|
|
6
|
+
* OAuth token storage, session/history logs, credentials) is never
|
|
7
|
+
* copied out of a developer's real `$HOME` because it was never named
|
|
8
|
+
* here, not because it was excluded after the fact. Kept in sync with
|
|
9
|
+
* each probe's own `join(homeDir, ...)` calls by direct source
|
|
10
|
+
* cross-reference, not by assumption.
|
|
11
|
+
*/
|
|
12
|
+
export declare const REAL_HOME_ALLOWLIST: readonly string[];
|
|
13
|
+
/**
|
|
14
|
+
* Copies only allowlisted paths that actually exist under `sourceHome`
|
|
15
|
+
* into `destHome` — a missing entry is a normal, expected state (not
|
|
16
|
+
* every agent is installed), never an error. `dereference: true` is
|
|
17
|
+
* required, not incidental: `sync`'s own real output is symlinks
|
|
18
|
+
* (skills, instructions) pointing back at `sourceHome`'s own
|
|
19
|
+
* `.trellis/` — a container mounting a snapshot that kept those
|
|
20
|
+
* symlinks as symlinks would get a dangling reference to a path that
|
|
21
|
+
* doesn't exist inside it. Copying real content instead is what makes
|
|
22
|
+
* the snapshot self-contained (found by actually running this against
|
|
23
|
+
* a real, already-synced machine — not by inspection). Returns the
|
|
24
|
+
* relative paths actually copied, for the caller to report.
|
|
25
|
+
*/
|
|
26
|
+
export declare function buildRealHomeSnapshot(sourceHome: string, destHome: string): string[];
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `--real` sandbox mode's allowlist (trellis-real-sandbox-verification
|
|
3
|
+
* design.md D1): every real dotfile path a probe (src/probes/*.ts) is
|
|
4
|
+
* already known to read, and nothing else. Allowlist, not denylist —
|
|
5
|
+
* anything this project doesn't already know to be structural (real
|
|
6
|
+
* OAuth token storage, session/history logs, credentials) is never
|
|
7
|
+
* copied out of a developer's real `$HOME` because it was never named
|
|
8
|
+
* here, not because it was excluded after the fact. Kept in sync with
|
|
9
|
+
* each probe's own `join(homeDir, ...)` calls by direct source
|
|
10
|
+
* cross-reference, not by assumption.
|
|
11
|
+
*/
|
|
12
|
+
import { cpSync, existsSync, mkdirSync } from "node:fs";
|
|
13
|
+
import { dirname, join } from "node:path";
|
|
14
|
+
export const REAL_HOME_ALLOWLIST = [
|
|
15
|
+
// claude-code (src/probes/claude-code.ts)
|
|
16
|
+
".claude.json",
|
|
17
|
+
".claude/skills",
|
|
18
|
+
".claude/agents",
|
|
19
|
+
".claude/CLAUDE.md",
|
|
20
|
+
// codex (src/probes/codex.ts)
|
|
21
|
+
".codex/config.toml",
|
|
22
|
+
".agents/skills",
|
|
23
|
+
".codex/skills",
|
|
24
|
+
// kiro (src/probes/kiro.ts)
|
|
25
|
+
".kiro/settings/mcp.json",
|
|
26
|
+
".kiro/skills",
|
|
27
|
+
".kiro/steering/CLAUDE.md",
|
|
28
|
+
// pi (src/probes/pi.ts)
|
|
29
|
+
".pi/agent/settings.json",
|
|
30
|
+
".pi/agent/skills",
|
|
31
|
+
".pi/agent/AGENTS.override.md",
|
|
32
|
+
".pi/agent/AGENTS.md",
|
|
33
|
+
".pi/agent/AGENTS.MD",
|
|
34
|
+
".pi/agent/CLAUDE.md",
|
|
35
|
+
".pi/agent/CLAUDE.MD",
|
|
36
|
+
// this machine's own real canonical source, if it already has one —
|
|
37
|
+
// Trellis's own managed data, not a third-party agent's.
|
|
38
|
+
".trellis",
|
|
39
|
+
];
|
|
40
|
+
/**
|
|
41
|
+
* Copies only allowlisted paths that actually exist under `sourceHome`
|
|
42
|
+
* into `destHome` — a missing entry is a normal, expected state (not
|
|
43
|
+
* every agent is installed), never an error. `dereference: true` is
|
|
44
|
+
* required, not incidental: `sync`'s own real output is symlinks
|
|
45
|
+
* (skills, instructions) pointing back at `sourceHome`'s own
|
|
46
|
+
* `.trellis/` — a container mounting a snapshot that kept those
|
|
47
|
+
* symlinks as symlinks would get a dangling reference to a path that
|
|
48
|
+
* doesn't exist inside it. Copying real content instead is what makes
|
|
49
|
+
* the snapshot self-contained (found by actually running this against
|
|
50
|
+
* a real, already-synced machine — not by inspection). Returns the
|
|
51
|
+
* relative paths actually copied, for the caller to report.
|
|
52
|
+
*/
|
|
53
|
+
export function buildRealHomeSnapshot(sourceHome, destHome) {
|
|
54
|
+
const copied = [];
|
|
55
|
+
for (const rel of REAL_HOME_ALLOWLIST) {
|
|
56
|
+
const src = join(sourceHome, rel);
|
|
57
|
+
if (!existsSync(src))
|
|
58
|
+
continue;
|
|
59
|
+
const dest = join(destHome, rel);
|
|
60
|
+
// On a case-insensitive filesystem (macOS default), two distinct
|
|
61
|
+
// allowlist entries (e.g. `AGENTS.md`/`AGENTS.MD` — pi's own
|
|
62
|
+
// case-sensitive candidate list, src/probes/pi.ts) can resolve to
|
|
63
|
+
// the identical real file; a `dest` an earlier entry already
|
|
64
|
+
// created (case-insensitively) needs no second, redundant copy —
|
|
65
|
+
// found by actually running this against a real machine, where it
|
|
66
|
+
// also tripped a Node `cpSync` quirk re-copying a symlink onto its
|
|
67
|
+
// own already-materialized destination.
|
|
68
|
+
if (existsSync(dest)) {
|
|
69
|
+
copied.push(rel);
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
mkdirSync(dirname(dest), { recursive: true });
|
|
73
|
+
cpSync(src, dest, { recursive: true, dereference: true });
|
|
74
|
+
copied.push(rel);
|
|
75
|
+
}
|
|
76
|
+
return copied;
|
|
77
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal, dependency-free arrow-key/checkbox terminal picker
|
|
3
|
+
* (trellis-onboard-interactive-picker design.md D1) — the interaction
|
|
4
|
+
* surface `trellis onboard`'s two prompts need (at most four rows, no
|
|
5
|
+
* search, no pagination) doesn't need a full prompt library; this
|
|
6
|
+
* hand-rolls just enough raw-mode key handling and ANSI rendering for
|
|
7
|
+
* that bounded case, matching the project's existing narrow-dependency
|
|
8
|
+
* precedent (src/lib/envVarNames.ts, src/lib/secretEnv.ts).
|
|
9
|
+
*
|
|
10
|
+
* `input`/`output` are injectable (never a CLI flag) purely so tests can
|
|
11
|
+
* drive the real key-parsing/render loop against a plain stream instead
|
|
12
|
+
* of a real TTY — same seam pattern (`homeDir`, etc.) used everywhere
|
|
13
|
+
* else in this project.
|
|
14
|
+
*/
|
|
15
|
+
import type { Readable, Writable } from "node:stream";
|
|
16
|
+
export interface PickerStreams {
|
|
17
|
+
input: NodeJS.ReadStream | Readable;
|
|
18
|
+
output: NodeJS.WriteStream | Writable;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* True only when both streams are real, raw-mode-capable terminals — the
|
|
22
|
+
* exact gate deciding picker vs. the pre-existing numbered-typing prompt
|
|
23
|
+
* (design.md D2). A stream lacking `setRawMode` (piped input, some
|
|
24
|
+
* minimal TTYs) always falls back, never hangs or guesses.
|
|
25
|
+
*/
|
|
26
|
+
export declare function canUseInteractivePicker(streams?: PickerStreams): boolean;
|
|
27
|
+
/** Wrapping index navigation — pure, exported for direct unit testing. */
|
|
28
|
+
export declare function nextIndex(current: number, delta: number, length: number): number;
|
|
29
|
+
/** Pure single-index toggle — exported for direct unit testing. */
|
|
30
|
+
export declare function toggled(checked: readonly boolean[], index: number): boolean[];
|
|
31
|
+
/**
|
|
32
|
+
* Single-select: Up/Down/j/k moves the highlight, Enter confirms. Resolves
|
|
33
|
+
* the confirmed index, or `null` on Ctrl+C cancel. Caller (onboard.ts)
|
|
34
|
+
* must have already confirmed `canUseInteractivePicker()` — this function
|
|
35
|
+
* does not re-check, and assumes `streams.input` genuinely supports raw
|
|
36
|
+
* mode.
|
|
37
|
+
*/
|
|
38
|
+
export declare function runSingleSelectPicker(items: string[], streams?: PickerStreams): Promise<number | null>;
|
|
39
|
+
/**
|
|
40
|
+
* Multi-select (checkbox): Up/Down/j/k moves the highlight, Space toggles
|
|
41
|
+
* the current row, Enter confirms. Resolves the checked indices at
|
|
42
|
+
* confirm time, or `null` on Ctrl+C cancel. Same precondition as
|
|
43
|
+
* `runSingleSelectPicker`.
|
|
44
|
+
*/
|
|
45
|
+
export declare function runMultiSelectPicker(items: string[], initiallyChecked: readonly boolean[], streams?: PickerStreams): Promise<number[] | null>;
|