@projectctx/agent 0.1.0-alpha.4 → 0.1.0-alpha.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +27 -0
- package/assets/instructions/README.md +35 -0
- package/assets/instructions/chatgpt-claudeai.v1.md +6 -0
- package/assets/instructions/chatgpt-claudeai.v2.md +6 -0
- package/assets/instructions/claude-code.v1.md +15 -0
- package/assets/instructions/claude-code.v2.md +15 -0
- package/assets/instructions/codex.v1.md +10 -0
- package/assets/instructions/codex.v2.md +10 -0
- package/assets/instructions/cursor.v1.md +10 -0
- package/assets/instructions/cursor.v2.md +10 -0
- package/dist/cli.d.ts +27 -0
- package/dist/cli.js +117 -0
- package/dist/connect.d.ts +101 -0
- package/dist/connect.js +544 -0
- package/dist/doctor.js +19 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/mcp.js +2 -2
- package/dist/token-commands.d.ts +40 -0
- package/dist/token-commands.js +127 -0
- package/package.json +13 -12
package/dist/connect.js
ADDED
|
@@ -0,0 +1,544 @@
|
|
|
1
|
+
import { copyFileSync, existsSync, mkdirSync, readFileSync, realpathSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { execFileSync } from "node:child_process";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
// `projectctx connect` wires the project-memory MCP into each locally installed
|
|
7
|
+
// AI client and installs the T-002 instruction blocks. Sacred constraint
|
|
8
|
+
// (data-loss): every config file is parsed -> merged -> written atomically
|
|
9
|
+
// (temp file + rename) with a timestamped backup taken before modification. A
|
|
10
|
+
// --dry-run mode prints the would-be changes without touching disk.
|
|
11
|
+
export const MCP_SERVER_NAME = "project-memory";
|
|
12
|
+
export const MCP_URL = "https://projectctx.com/mcp";
|
|
13
|
+
// Which instruction-text version is installed today. v1 = goal-mode wording
|
|
14
|
+
// (get_context {mode:"checkin"} FAILS Zod validation until T-301 ships). Flip to
|
|
15
|
+
// "v2" and re-run connect once T-301 has deployed; the marker blocks make the
|
|
16
|
+
// swap a clean replace. See assets/instructions/README.md.
|
|
17
|
+
export const INSTRUCTION_VERSION = "v1";
|
|
18
|
+
const MARKER_BEGIN = "<!-- projectctx:begin -->";
|
|
19
|
+
const MARKER_END = "<!-- projectctx:end -->";
|
|
20
|
+
// All paths derive from HOME so tests can point at a sandbox
|
|
21
|
+
// (HOME=$(mktemp -d)) without touching the real user's configs.
|
|
22
|
+
export function connectPaths(home = homedir()) {
|
|
23
|
+
return {
|
|
24
|
+
home,
|
|
25
|
+
claudeJson: join(home, ".claude.json"),
|
|
26
|
+
claudeMd: join(home, ".claude", "CLAUDE.md"),
|
|
27
|
+
cursorMcp: join(home, ".cursor", "mcp.json"),
|
|
28
|
+
cursorRules: join(home, ".cursor", "rules", "projectctx.mdc"),
|
|
29
|
+
codexToml: join(home, ".codex", "config.toml"),
|
|
30
|
+
codexAgents: join(home, ".codex", "AGENTS.md")
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
function assetPath(name) {
|
|
34
|
+
// Resolves the same from src/ (tsx dev) and dist/ (published): both sit one
|
|
35
|
+
// level below the package root, so ../assets is correct either way.
|
|
36
|
+
return fileURLToPath(new URL(`../assets/instructions/${name}`, import.meta.url));
|
|
37
|
+
}
|
|
38
|
+
function readInstructionBody(client, version) {
|
|
39
|
+
const file = `${client}.${version}.md`;
|
|
40
|
+
return readFileSync(assetPath(file), "utf8").trim();
|
|
41
|
+
}
|
|
42
|
+
export function readManualRemainderText(version) {
|
|
43
|
+
return readFileSync(assetPath(`chatgpt-claudeai.${version}.md`), "utf8").trim();
|
|
44
|
+
}
|
|
45
|
+
// --- marker block install (idempotent replace, never append) ---------------
|
|
46
|
+
function markerBlock(body) {
|
|
47
|
+
return `${MARKER_BEGIN}\n${body}\n${MARKER_END}`;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Insert or replace the projectctx marker block in a markdown-ish file. Content
|
|
51
|
+
* outside the markers is preserved verbatim. Re-running with the same body is a
|
|
52
|
+
* no-op (byte-identical output), which is what makes connect idempotent.
|
|
53
|
+
*/
|
|
54
|
+
export function upsertMarkerBlock(existing, body) {
|
|
55
|
+
const block = markerBlock(body);
|
|
56
|
+
if (existing === null || existing === "") {
|
|
57
|
+
return block + "\n";
|
|
58
|
+
}
|
|
59
|
+
const begin = existing.indexOf(MARKER_BEGIN);
|
|
60
|
+
if (begin !== -1) {
|
|
61
|
+
const endIdx = existing.indexOf(MARKER_END, begin);
|
|
62
|
+
if (endIdx !== -1) {
|
|
63
|
+
const after = existing.slice(endIdx + MARKER_END.length);
|
|
64
|
+
const before = existing.slice(0, begin);
|
|
65
|
+
return before + block + after;
|
|
66
|
+
}
|
|
67
|
+
// Dangling begin with no end: replace from begin to EOF rather than nest.
|
|
68
|
+
return existing.slice(0, begin) + block + "\n";
|
|
69
|
+
}
|
|
70
|
+
// No block yet: append after existing content with a blank-line separator.
|
|
71
|
+
const sep = existing.endsWith("\n") ? "\n" : "\n\n";
|
|
72
|
+
return existing + sep + block + "\n";
|
|
73
|
+
}
|
|
74
|
+
// Cursor .mdc needs YAML frontmatter at the very top (outside the marker block).
|
|
75
|
+
// We install/repair the frontmatter, then upsert the marker block in the body.
|
|
76
|
+
const CURSOR_FRONTMATTER = ["---", "description: ProjectCtx memory usage", "alwaysApply: true", "---"].join("\n");
|
|
77
|
+
export function buildCursorMdc(existing, body) {
|
|
78
|
+
let frontmatter = CURSOR_FRONTMATTER;
|
|
79
|
+
let rest;
|
|
80
|
+
if (existing && existing.startsWith("---")) {
|
|
81
|
+
const end = existing.indexOf("\n---", 3);
|
|
82
|
+
if (end !== -1) {
|
|
83
|
+
const closeLineEnd = existing.indexOf("\n", end + 1);
|
|
84
|
+
frontmatter = existing.slice(0, closeLineEnd === -1 ? existing.length : closeLineEnd);
|
|
85
|
+
rest = closeLineEnd === -1 ? "" : existing.slice(closeLineEnd + 1);
|
|
86
|
+
}
|
|
87
|
+
else {
|
|
88
|
+
rest = existing;
|
|
89
|
+
frontmatter = CURSOR_FRONTMATTER;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
else {
|
|
93
|
+
rest = existing;
|
|
94
|
+
}
|
|
95
|
+
// Strip leading blank lines from the body so re-runs don't accumulate them
|
|
96
|
+
// (the frontmatter separator is re-added below, making the op idempotent).
|
|
97
|
+
const restTrimmed = rest ? rest.replace(/^\s+/, "") : "";
|
|
98
|
+
const bodyOut = upsertMarkerBlock(restTrimmed !== "" ? restTrimmed : null, body);
|
|
99
|
+
return `${frontmatter}\n\n${bodyOut}`;
|
|
100
|
+
}
|
|
101
|
+
function stringifyJson(value) {
|
|
102
|
+
return JSON.stringify(value, null, 2) + "\n";
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Merge the project-memory server into an mcpServers map without disturbing any
|
|
106
|
+
* other key. `entry` is the value stored under the server name (Claude Code and
|
|
107
|
+
* Cursor differ slightly). Returns byte-identical output when already present.
|
|
108
|
+
*/
|
|
109
|
+
// Some editors save JSON with a UTF-8 BOM, which JSON.parse rejects.
|
|
110
|
+
function stripBom(value) {
|
|
111
|
+
return value.replace(/^\uFEFF/, "");
|
|
112
|
+
}
|
|
113
|
+
export function mergeMcpServersJson(existing, entry) {
|
|
114
|
+
let root = {};
|
|
115
|
+
if (existing && stripBom(existing).trim() !== "") {
|
|
116
|
+
const parsed = JSON.parse(stripBom(existing));
|
|
117
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
118
|
+
throw new Error("existing config is not a JSON object");
|
|
119
|
+
}
|
|
120
|
+
root = parsed;
|
|
121
|
+
}
|
|
122
|
+
const servers = (root.mcpServers && typeof root.mcpServers === "object" && !Array.isArray(root.mcpServers)
|
|
123
|
+
? { ...root.mcpServers }
|
|
124
|
+
: {});
|
|
125
|
+
const nextRoot = { ...root, mcpServers: { ...servers, [MCP_SERVER_NAME]: entry } };
|
|
126
|
+
const after = stringifyJson(nextRoot);
|
|
127
|
+
const before = existing ?? "";
|
|
128
|
+
return { after, changed: after !== before };
|
|
129
|
+
}
|
|
130
|
+
// --- TOML block append (Codex) ---------------------------------------------
|
|
131
|
+
//
|
|
132
|
+
// Ladder note: no TOML parser exists anywhere in the workspace (rung 5 miss),
|
|
133
|
+
// and we only need to insert/replace one well-known table
|
|
134
|
+
// ([mcp_servers.project-memory]). A full parser (rung 7 dependency) is overkill
|
|
135
|
+
// for that, so we do a minimal, line-based table splice that preserves every
|
|
136
|
+
// other byte of the file. It handles: a pre-existing project-memory block
|
|
137
|
+
// (replace in place, idempotent) and other [mcp_servers.*] blocks (untouched).
|
|
138
|
+
const CODEX_BLOCK_HEADER = `[mcp_servers.${MCP_SERVER_NAME}]`;
|
|
139
|
+
function codexBlock() {
|
|
140
|
+
return `${CODEX_BLOCK_HEADER}\nurl = "${MCP_URL}"`;
|
|
141
|
+
}
|
|
142
|
+
function isTableHeaderLine(line) {
|
|
143
|
+
return /^\s*\[\[?[^\]]+\]\]?\s*(#.*)?$/.test(line);
|
|
144
|
+
}
|
|
145
|
+
// Extract the table path from a header line, tolerating an inline comment
|
|
146
|
+
// (`[mcp_servers.project-memory] # pinned` is legal TOML) and internal
|
|
147
|
+
// whitespace. Returns null for non-header lines. The finder MUST use this, not
|
|
148
|
+
// a whole-line comparison: missing a commented header would send us down the
|
|
149
|
+
// append path and produce a duplicate table, which TOML forbids — Codex would
|
|
150
|
+
// then refuse to load the entire config (review finding B1).
|
|
151
|
+
function tableHeaderPath(line) {
|
|
152
|
+
const match = /^\s*\[([^\]]+)\]\s*(#.*)?$/.exec(line);
|
|
153
|
+
return match ? match[1].replace(/\s+/g, "") : null;
|
|
154
|
+
}
|
|
155
|
+
export function upsertCodexToml(existing) {
|
|
156
|
+
const block = codexBlock();
|
|
157
|
+
if (existing === null || existing.trim() === "") {
|
|
158
|
+
return { after: block + "\n", changed: true };
|
|
159
|
+
}
|
|
160
|
+
// Preserve the file's own line endings when splicing (CRLF files stay CRLF).
|
|
161
|
+
const eol = existing.includes("\r\n") ? "\r\n" : "\n";
|
|
162
|
+
const lines = existing.split(/\r?\n/);
|
|
163
|
+
const blockLines = block.split("\n");
|
|
164
|
+
const targetPath = `mcp_servers.${MCP_SERVER_NAME}`;
|
|
165
|
+
const headerIdx = lines.findIndex((line) => tableHeaderPath(line) === targetPath);
|
|
166
|
+
if (headerIdx === -1) {
|
|
167
|
+
// No existing project-memory block: append after a separating blank line.
|
|
168
|
+
const trimmedEnd = existing.replace(/(\r?\n)+$/, "");
|
|
169
|
+
const after = `${trimmedEnd}${eol}${eol}${blockLines.join(eol)}${eol}`;
|
|
170
|
+
return { after, changed: after !== existing };
|
|
171
|
+
}
|
|
172
|
+
// Replace the existing block: from its header up to (but not including) the
|
|
173
|
+
// next table header or EOF. A nested subtable header
|
|
174
|
+
// ([mcp_servers.project-memory.env]) counts as a boundary, so subtables and
|
|
175
|
+
// neighbor tables survive untouched. A trailing comment on the url line is
|
|
176
|
+
// simply replaced with the canonical line, so re-runs stay idempotent.
|
|
177
|
+
let end = headerIdx + 1;
|
|
178
|
+
while (end < lines.length && !isTableHeaderLine(lines[end]))
|
|
179
|
+
end++;
|
|
180
|
+
const before = lines.slice(0, headerIdx);
|
|
181
|
+
const afterLines = lines.slice(end);
|
|
182
|
+
const rebuilt = [...before, ...blockLines, ...afterLines].join(eol);
|
|
183
|
+
const normalized = rebuilt.endsWith("\n") ? rebuilt : rebuilt + eol;
|
|
184
|
+
return { after: normalized, changed: normalized !== existing };
|
|
185
|
+
}
|
|
186
|
+
// --- running-process detection ---------------------------------------------
|
|
187
|
+
//
|
|
188
|
+
// ~/.claude.json is actively rewritten by a live Claude Code process; a
|
|
189
|
+
// read-modify-write race can clobber in either direction. We warn (not block)
|
|
190
|
+
// when a matching process appears to be running so the user can quit it first.
|
|
191
|
+
// Match on the executable's basename (pgrep -x against the process name), not a
|
|
192
|
+
// full-command-line substring. The looser -f form gave false positives whenever
|
|
193
|
+
// connect ran from a path containing "claude" (e.g. a ~/.claude worktree). This
|
|
194
|
+
// is a best-effort convenience warning, so a miss (client renamed its binary)
|
|
195
|
+
// is acceptable; a false alarm on every run is not. We also skip the check when
|
|
196
|
+
// connect is itself running under the named client (Claude Code sets
|
|
197
|
+
// CLAUDECODE=1) to avoid warning about our own parent process.
|
|
198
|
+
function isProcessRunning(processName, logger) {
|
|
199
|
+
if (process.platform === "win32")
|
|
200
|
+
return false; // pgrep unavailable; skip.
|
|
201
|
+
try {
|
|
202
|
+
const out = execFileSync("pgrep", ["-x", processName], {
|
|
203
|
+
encoding: "utf8",
|
|
204
|
+
stdio: ["ignore", "pipe", "ignore"]
|
|
205
|
+
});
|
|
206
|
+
return out.trim().length > 0;
|
|
207
|
+
}
|
|
208
|
+
catch (error) {
|
|
209
|
+
// pgrep exits non-zero (1) when nothing matches — that's "not running".
|
|
210
|
+
logger?.debug(`pgrep -x ${processName} -> ${error instanceof Error ? error.message : "no match"}`);
|
|
211
|
+
return false;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
// --- atomic write with backup ----------------------------------------------
|
|
215
|
+
function timestamp() {
|
|
216
|
+
return new Date().toISOString().replace(/[:.]/g, "-");
|
|
217
|
+
}
|
|
218
|
+
/**
|
|
219
|
+
* Write `content` to `path` atomically: back up any existing file to
|
|
220
|
+
* `<target>.bak-<timestamp>`, write to a temp file in the same directory, then
|
|
221
|
+
* rename over the target (atomic on POSIX). The temp+rename means a crash
|
|
222
|
+
* mid-write never leaves a truncated config.
|
|
223
|
+
*
|
|
224
|
+
* Symlinked configs (dotfiles setups) are followed: the write and backup act on
|
|
225
|
+
* the resolved real path, so the link stays intact and the linked target gets
|
|
226
|
+
* the new content. Renaming over the link path itself would silently replace
|
|
227
|
+
* the symlink with a regular file and leave the real target stale (review
|
|
228
|
+
* finding S1).
|
|
229
|
+
*/
|
|
230
|
+
export function atomicWriteWithBackup(path, content) {
|
|
231
|
+
const target = existsSync(path) ? realpathSync(path) : path;
|
|
232
|
+
const dir = dirname(target);
|
|
233
|
+
mkdirSync(dir, { recursive: true });
|
|
234
|
+
let backupPath;
|
|
235
|
+
if (existsSync(target)) {
|
|
236
|
+
backupPath = `${target}.bak-${timestamp()}`;
|
|
237
|
+
copyFileSync(target, backupPath);
|
|
238
|
+
}
|
|
239
|
+
const tmp = join(dir, `.${Math.random().toString(36).slice(2)}.projectctx.tmp`);
|
|
240
|
+
try {
|
|
241
|
+
writeFileSync(tmp, content, { mode: 0o600 });
|
|
242
|
+
renameSync(tmp, target);
|
|
243
|
+
}
|
|
244
|
+
catch (error) {
|
|
245
|
+
rmSync(tmp, { force: true });
|
|
246
|
+
throw error;
|
|
247
|
+
}
|
|
248
|
+
return { backupPath };
|
|
249
|
+
}
|
|
250
|
+
// --- plan builders ----------------------------------------------------------
|
|
251
|
+
function fileChange(path, kind, after, note) {
|
|
252
|
+
const before = existsSync(path) ? readFileSync(path, "utf8") : null;
|
|
253
|
+
return { path, kind, before, after, changed: after !== (before ?? ""), note };
|
|
254
|
+
}
|
|
255
|
+
function planClaudeCode(paths, version, claudeOnPath, logger) {
|
|
256
|
+
const detected = existsSync(paths.claudeJson) || existsSync(dirname(paths.claudeMd));
|
|
257
|
+
const warnings = [];
|
|
258
|
+
if (isProcessRunning("claude", logger)) {
|
|
259
|
+
warnings.push("Claude Code appears to be running. It actively rewrites ~/.claude.json; quit it before applying to avoid a read-modify-write race.");
|
|
260
|
+
}
|
|
261
|
+
const changes = [];
|
|
262
|
+
let cliCommand;
|
|
263
|
+
if (claudeOnPath) {
|
|
264
|
+
cliCommand = {
|
|
265
|
+
argv: ["mcp", "add", "--transport", "http", MCP_SERVER_NAME, MCP_URL, "--scope", "user"],
|
|
266
|
+
description: `claude mcp add --transport http ${MCP_SERVER_NAME} ${MCP_URL} --scope user`
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
else {
|
|
270
|
+
// Fallback: merge directly into ~/.claude.json (Claude Code's HTTP entry
|
|
271
|
+
// shape mirrors Cursor's — a url field).
|
|
272
|
+
const merged = mergeMcpServersJson(existsSync(paths.claudeJson) ? readFileSync(paths.claudeJson, "utf8") : null, {
|
|
273
|
+
type: "http",
|
|
274
|
+
url: MCP_URL
|
|
275
|
+
});
|
|
276
|
+
changes.push({
|
|
277
|
+
path: paths.claudeJson,
|
|
278
|
+
kind: "mcp-config",
|
|
279
|
+
before: existsSync(paths.claudeJson) ? readFileSync(paths.claudeJson, "utf8") : null,
|
|
280
|
+
after: merged.after,
|
|
281
|
+
changed: merged.changed,
|
|
282
|
+
note: "claude CLI not on PATH; merging ~/.claude.json directly"
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
const body = readInstructionBody("claude-code", version);
|
|
286
|
+
const claudeMdBefore = existsSync(paths.claudeMd) ? readFileSync(paths.claudeMd, "utf8") : null;
|
|
287
|
+
changes.push(fileChange(paths.claudeMd, "instructions", upsertMarkerBlock(claudeMdBefore, body)));
|
|
288
|
+
return { client: "claude-code", label: "Claude Code", detected, cliCommand, changes, warnings };
|
|
289
|
+
}
|
|
290
|
+
function planCursor(paths, version, logger) {
|
|
291
|
+
const detected = existsSync(paths.cursorMcp) || existsSync(join(paths.home, ".cursor"));
|
|
292
|
+
const warnings = [];
|
|
293
|
+
if (isProcessRunning("Cursor", logger)) {
|
|
294
|
+
warnings.push("Cursor appears to be running; it may reload its MCP config. Restart Cursor after applying.");
|
|
295
|
+
}
|
|
296
|
+
const merged = mergeMcpServersJson(existsSync(paths.cursorMcp) ? readFileSync(paths.cursorMcp, "utf8") : null, {
|
|
297
|
+
url: MCP_URL
|
|
298
|
+
});
|
|
299
|
+
const changes = [
|
|
300
|
+
{
|
|
301
|
+
path: paths.cursorMcp,
|
|
302
|
+
kind: "mcp-config",
|
|
303
|
+
before: existsSync(paths.cursorMcp) ? readFileSync(paths.cursorMcp, "utf8") : null,
|
|
304
|
+
after: merged.after,
|
|
305
|
+
changed: merged.changed
|
|
306
|
+
}
|
|
307
|
+
];
|
|
308
|
+
const body = readInstructionBody("cursor", version);
|
|
309
|
+
const rulesBefore = existsSync(paths.cursorRules) ? readFileSync(paths.cursorRules, "utf8") : null;
|
|
310
|
+
changes.push(fileChange(paths.cursorRules, "instructions", buildCursorMdc(rulesBefore, body)));
|
|
311
|
+
return { client: "cursor", label: "Cursor", detected, changes, warnings };
|
|
312
|
+
}
|
|
313
|
+
function planCodex(paths, version, logger) {
|
|
314
|
+
const detected = existsSync(paths.codexToml) || existsSync(join(paths.home, ".codex"));
|
|
315
|
+
const warnings = [];
|
|
316
|
+
if (isProcessRunning("codex", logger)) {
|
|
317
|
+
warnings.push("Codex appears to be running; restart it after applying so it re-reads config.toml.");
|
|
318
|
+
}
|
|
319
|
+
const merged = upsertCodexToml(existsSync(paths.codexToml) ? readFileSync(paths.codexToml, "utf8") : null);
|
|
320
|
+
const changes = [
|
|
321
|
+
{
|
|
322
|
+
path: paths.codexToml,
|
|
323
|
+
kind: "mcp-config",
|
|
324
|
+
before: existsSync(paths.codexToml) ? readFileSync(paths.codexToml, "utf8") : null,
|
|
325
|
+
after: merged.after,
|
|
326
|
+
changed: merged.changed
|
|
327
|
+
}
|
|
328
|
+
];
|
|
329
|
+
const body = readInstructionBody("codex", version);
|
|
330
|
+
const agentsBefore = existsSync(paths.codexAgents) ? readFileSync(paths.codexAgents, "utf8") : null;
|
|
331
|
+
changes.push(fileChange(paths.codexAgents, "instructions", upsertMarkerBlock(agentsBefore, body)));
|
|
332
|
+
return { client: "codex", label: "Codex", detected, changes, warnings };
|
|
333
|
+
}
|
|
334
|
+
function claudeCliAvailable(logger) {
|
|
335
|
+
try {
|
|
336
|
+
execFileSync(process.platform === "win32" ? "where" : "which", ["claude"], {
|
|
337
|
+
stdio: ["ignore", "ignore", "ignore"]
|
|
338
|
+
});
|
|
339
|
+
return true;
|
|
340
|
+
}
|
|
341
|
+
catch (error) {
|
|
342
|
+
logger?.debug(`claude not on PATH: ${error instanceof Error ? error.message : "not found"}`);
|
|
343
|
+
return false;
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
function buildManualRemainder(version) {
|
|
347
|
+
const custom = readManualRemainderText(version);
|
|
348
|
+
return [
|
|
349
|
+
"Manual steps (account-side connectors — no local config file to edit):",
|
|
350
|
+
"",
|
|
351
|
+
" ChatGPT (Settings -> Connectors -> Add / MCP servers):",
|
|
352
|
+
` URL: ${MCP_URL}`,
|
|
353
|
+
" claude.ai (Settings -> Connectors -> Add custom connector):",
|
|
354
|
+
` URL: ${MCP_URL}`,
|
|
355
|
+
" Claude Desktop:",
|
|
356
|
+
" Its local config supports stdio servers only today; use the account-side",
|
|
357
|
+
" Connectors UI (same as claude.ai) for this remote HTTP server.",
|
|
358
|
+
"",
|
|
359
|
+
"Then paste this into each tool's custom instructions / personalization box:",
|
|
360
|
+
"",
|
|
361
|
+
...custom.split("\n").map((line) => ` ${line}`),
|
|
362
|
+
"",
|
|
363
|
+
"Note: each client pops its own OAuth browser handshake on first tool use.",
|
|
364
|
+
"The server supports rotating refresh tokens; whether renewal is seamless",
|
|
365
|
+
"depends on the client's OAuth implementation."
|
|
366
|
+
].join("\n");
|
|
367
|
+
}
|
|
368
|
+
/**
|
|
369
|
+
* Build the full plan without writing anything. Pure w.r.t. disk except for
|
|
370
|
+
* reads, so --dry-run and the real run share one code path.
|
|
371
|
+
*/
|
|
372
|
+
// A malformed (or BOM-mangled beyond repair) config must not kill the whole
|
|
373
|
+
// run with a raw SyntaxError: the client is skipped with a clear message —
|
|
374
|
+
// its file is left untouched — and the other clients still get wired (review
|
|
375
|
+
// finding S2).
|
|
376
|
+
function planClientSafely(build, client, label, configPath) {
|
|
377
|
+
try {
|
|
378
|
+
return build();
|
|
379
|
+
}
|
|
380
|
+
catch (error) {
|
|
381
|
+
const detail = error instanceof Error ? error.message : "unknown parse error";
|
|
382
|
+
return {
|
|
383
|
+
client,
|
|
384
|
+
label,
|
|
385
|
+
detected: true,
|
|
386
|
+
changes: [],
|
|
387
|
+
warnings: [
|
|
388
|
+
`Couldn't parse ${configPath} — file left untouched. Fix or remove it, then re-run projectctx connect. (${detail})`
|
|
389
|
+
]
|
|
390
|
+
};
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
export function buildConnectPlan(opts = {}) {
|
|
394
|
+
const version = opts.version ?? INSTRUCTION_VERSION;
|
|
395
|
+
const paths = connectPaths(opts.home);
|
|
396
|
+
const claudeOnPath = claudeCliAvailable(opts.logger);
|
|
397
|
+
const clients = [
|
|
398
|
+
planClientSafely(() => planClaudeCode(paths, version, claudeOnPath, opts.logger), "claude-code", "Claude Code", paths.claudeJson),
|
|
399
|
+
planClientSafely(() => planCursor(paths, version, opts.logger), "cursor", "Cursor", paths.cursorMcp),
|
|
400
|
+
planClientSafely(() => planCodex(paths, version, opts.logger), "codex", "Codex", paths.codexToml)
|
|
401
|
+
];
|
|
402
|
+
return {
|
|
403
|
+
dryRun: false,
|
|
404
|
+
home: paths.home,
|
|
405
|
+
instructionVersion: version,
|
|
406
|
+
clients,
|
|
407
|
+
manualRemainder: buildManualRemainder(version)
|
|
408
|
+
};
|
|
409
|
+
}
|
|
410
|
+
export function runConnect(opts = {}) {
|
|
411
|
+
const dryRun = opts.dryRun ?? false;
|
|
412
|
+
const plan = buildConnectPlan({ home: opts.home, version: opts.version, logger: opts.logger });
|
|
413
|
+
if (dryRun) {
|
|
414
|
+
return { ...plan, dryRun: true, applied: false };
|
|
415
|
+
}
|
|
416
|
+
for (const client of plan.clients) {
|
|
417
|
+
if (!client.detected)
|
|
418
|
+
continue;
|
|
419
|
+
// Run the CLI-delegated MCP wiring (Claude Code) if requested.
|
|
420
|
+
if (client.cliCommand && opts.runCliCommands) {
|
|
421
|
+
try {
|
|
422
|
+
execFileSync("claude", client.cliCommand.argv, { stdio: ["ignore", "pipe", "pipe"] });
|
|
423
|
+
}
|
|
424
|
+
catch (error) {
|
|
425
|
+
client.warnings.push(`Failed to run \`${client.cliCommand.description}\`: ${error instanceof Error ? error.message : "unknown error"}. Wire it manually or re-run.`);
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
for (const change of client.changes) {
|
|
429
|
+
if (!change.changed)
|
|
430
|
+
continue;
|
|
431
|
+
atomicWriteWithBackup(change.path, change.after);
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
return { ...plan, dryRun: false, applied: true };
|
|
435
|
+
}
|
|
436
|
+
function claudeJsonHasEntry(raw) {
|
|
437
|
+
try {
|
|
438
|
+
const parsed = JSON.parse(stripBom(raw));
|
|
439
|
+
const servers = parsed?.mcpServers;
|
|
440
|
+
return Boolean(servers && typeof servers === "object" && MCP_SERVER_NAME in servers);
|
|
441
|
+
}
|
|
442
|
+
catch {
|
|
443
|
+
return false;
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
export function inspectClientConfigs(home = homedir()) {
|
|
447
|
+
const paths = connectPaths(home);
|
|
448
|
+
const statuses = [];
|
|
449
|
+
// Claude Code: ~/.claude.json mcpServers map.
|
|
450
|
+
{
|
|
451
|
+
const exists = existsSync(paths.claudeJson);
|
|
452
|
+
const configured = exists && claudeJsonHasEntry(readFileSync(paths.claudeJson, "utf8"));
|
|
453
|
+
statuses.push({
|
|
454
|
+
client: "claude-code",
|
|
455
|
+
label: "Claude Code",
|
|
456
|
+
configPath: paths.claudeJson,
|
|
457
|
+
configExists: exists,
|
|
458
|
+
mcpConfigured: configured
|
|
459
|
+
});
|
|
460
|
+
}
|
|
461
|
+
// Cursor: ~/.cursor/mcp.json mcpServers map.
|
|
462
|
+
{
|
|
463
|
+
const exists = existsSync(paths.cursorMcp);
|
|
464
|
+
const configured = exists && claudeJsonHasEntry(readFileSync(paths.cursorMcp, "utf8"));
|
|
465
|
+
statuses.push({
|
|
466
|
+
client: "cursor",
|
|
467
|
+
label: "Cursor",
|
|
468
|
+
configPath: paths.cursorMcp,
|
|
469
|
+
configExists: exists,
|
|
470
|
+
mcpConfigured: configured
|
|
471
|
+
});
|
|
472
|
+
}
|
|
473
|
+
// Codex: ~/.codex/config.toml [mcp_servers.project-memory] block.
|
|
474
|
+
{
|
|
475
|
+
const exists = existsSync(paths.codexToml);
|
|
476
|
+
const configured = exists && readFileSync(paths.codexToml, "utf8").includes(CODEX_BLOCK_HEADER);
|
|
477
|
+
statuses.push({
|
|
478
|
+
client: "codex",
|
|
479
|
+
label: "Codex",
|
|
480
|
+
configPath: paths.codexToml,
|
|
481
|
+
configExists: exists,
|
|
482
|
+
mcpConfigured: configured
|
|
483
|
+
});
|
|
484
|
+
}
|
|
485
|
+
return statuses;
|
|
486
|
+
}
|
|
487
|
+
// --- human-readable formatting ---------------------------------------------
|
|
488
|
+
function relHome(path, home) {
|
|
489
|
+
return path.startsWith(home) ? "~" + path.slice(home.length) : path;
|
|
490
|
+
}
|
|
491
|
+
function summarizeChange(change) {
|
|
492
|
+
if (!change.changed)
|
|
493
|
+
return "no change (already up to date)";
|
|
494
|
+
if (change.before === null)
|
|
495
|
+
return "create";
|
|
496
|
+
return "update";
|
|
497
|
+
}
|
|
498
|
+
/**
|
|
499
|
+
* Render the plan as product-grade terminal copy. Doubles as onboarding, so it
|
|
500
|
+
* names every file it would touch, flags detected-vs-not, and prints the manual
|
|
501
|
+
* remainder. In dry-run it says nothing was written.
|
|
502
|
+
*/
|
|
503
|
+
export function formatConnectOutput(result) {
|
|
504
|
+
const home = result.home;
|
|
505
|
+
const lines = [];
|
|
506
|
+
const heading = result.dryRun
|
|
507
|
+
? "projectctx connect — DRY RUN (no files will be written)"
|
|
508
|
+
: "projectctx connect";
|
|
509
|
+
lines.push(heading);
|
|
510
|
+
lines.push("=".repeat(heading.length));
|
|
511
|
+
lines.push("");
|
|
512
|
+
lines.push(`Instruction text version: ${result.instructionVersion}${result.instructionVersion === "v1" ? " (goal-mode; swap to v2 after roadmap T-301)" : ""}`);
|
|
513
|
+
lines.push("");
|
|
514
|
+
for (const client of result.clients) {
|
|
515
|
+
lines.push(`${client.detected ? "[detected]" : "[not found]"} ${client.label}`);
|
|
516
|
+
if (!client.detected) {
|
|
517
|
+
lines.push(" (no config detected — skipped; install the client, then re-run connect)");
|
|
518
|
+
lines.push("");
|
|
519
|
+
continue;
|
|
520
|
+
}
|
|
521
|
+
if (client.cliCommand) {
|
|
522
|
+
lines.push(` MCP: ${client.cliCommand.description}`);
|
|
523
|
+
}
|
|
524
|
+
for (const change of client.changes) {
|
|
525
|
+
lines.push(` ${change.kind === "instructions" ? "instructions" : "MCP config"}: ${relHome(change.path, home)} — ${summarizeChange(change)}`);
|
|
526
|
+
if (change.note)
|
|
527
|
+
lines.push(` note: ${change.note}`);
|
|
528
|
+
}
|
|
529
|
+
for (const warning of client.warnings) {
|
|
530
|
+
lines.push(` ! ${warning}`);
|
|
531
|
+
}
|
|
532
|
+
lines.push("");
|
|
533
|
+
}
|
|
534
|
+
lines.push(result.manualRemainder);
|
|
535
|
+
lines.push("");
|
|
536
|
+
if (result.dryRun) {
|
|
537
|
+
lines.push("Dry run complete. Nothing was written. Re-run without --dry-run to apply.");
|
|
538
|
+
}
|
|
539
|
+
else {
|
|
540
|
+
lines.push("Done. Backups of every modified file were saved alongside them (.bak-<timestamp>).");
|
|
541
|
+
lines.push("Run `projectctx doctor` to verify.");
|
|
542
|
+
}
|
|
543
|
+
return lines.join("\n") + "\n";
|
|
544
|
+
}
|
package/dist/doctor.js
CHANGED
|
@@ -4,6 +4,7 @@ import { scanWorkspace } from "@projectctx/indexer";
|
|
|
4
4
|
import { checkConfigPermissions, configFilePath, readAgentConfig, resolveCloudConfig } from "./config.js";
|
|
5
5
|
import { createProjectCtxClient } from "./cloud-client.js";
|
|
6
6
|
import { getCredentialStore } from "./credentials.js";
|
|
7
|
+
import { inspectClientConfigs } from "./connect.js";
|
|
7
8
|
import { AgentError } from "./errors.js";
|
|
8
9
|
function check(name, fn) {
|
|
9
10
|
try {
|
|
@@ -86,6 +87,19 @@ export async function runDoctor(logger) {
|
|
|
86
87
|
return "scanWorkspace import ok";
|
|
87
88
|
}),
|
|
88
89
|
];
|
|
90
|
+
// Per-client MCP config presence. Informational (always ok): a client that
|
|
91
|
+
// isn't wired yet is a "run connect" prompt, not a doctor failure. These
|
|
92
|
+
// report config PRESENCE only — doctor cannot verify each client's OAuth
|
|
93
|
+
// state (each client owns its own token store). The single live server ping
|
|
94
|
+
// below uses the CLI's own keyring credential and is labeled as such.
|
|
95
|
+
for (const status of inspectClientConfigs()) {
|
|
96
|
+
const message = !status.configExists
|
|
97
|
+
? `${status.label}: no config found (${status.configPath}); run projectctx connect`
|
|
98
|
+
: status.mcpConfigured
|
|
99
|
+
? `${status.label}: project-memory MCP configured in ${status.configPath}`
|
|
100
|
+
: `${status.label}: config exists but project-memory not wired (${status.configPath}); run projectctx connect`;
|
|
101
|
+
checks.push({ name: `client_${status.client.replace(/-/g, "_")}`, ok: true, message });
|
|
102
|
+
}
|
|
89
103
|
const { resolveChecks, config } = await cloudChecks();
|
|
90
104
|
checks.push(...resolveChecks);
|
|
91
105
|
if (config?.token) {
|
|
@@ -94,7 +108,11 @@ export async function runDoctor(logger) {
|
|
|
94
108
|
const client = createProjectCtxClient(config, logger);
|
|
95
109
|
try {
|
|
96
110
|
whoami = await client.whoami();
|
|
97
|
-
checks.push({
|
|
111
|
+
checks.push({
|
|
112
|
+
name: "cloud_auth",
|
|
113
|
+
ok: true,
|
|
114
|
+
message: `Server reachable; authenticated as ${whoami.actor.name} (CLI credential — does not reflect per-client OAuth state)`
|
|
115
|
+
});
|
|
98
116
|
}
|
|
99
117
|
catch (error) {
|
|
100
118
|
checks.push({ name: "cloud_auth", ok: false, message: error instanceof Error ? error.message : "Unknown error" });
|
package/dist/index.d.ts
CHANGED
|
@@ -7,4 +7,6 @@ export { checkConfigPermissions, clearAgentToken, clearStoredToken, configDir, c
|
|
|
7
7
|
export { getCredentialStore, setCredentialStoreForTesting, InMemoryCredentialStore, type CredentialStore } from "./credentials.js";
|
|
8
8
|
export { createProjectCtxClient, requireToken, type ProjectCtxClient } from "./cloud-client.js";
|
|
9
9
|
export { runLogin, runLogout, runWhoami, runWorkspaceUse, runWorkspaces } from "./auth-commands.js";
|
|
10
|
+
export { runConnect, buildConnectPlan, formatConnectOutput, inspectClientConfigs, connectPaths, upsertMarkerBlock, mergeMcpServersJson, upsertCodexToml, buildCursorMdc, atomicWriteWithBackup, MCP_SERVER_NAME, MCP_URL, INSTRUCTION_VERSION, type ClientId, type ConnectPlan, type ConnectResult, type ClientPlan, type FileChange, type ClientConfigStatus, type RunConnectOptions } from "./connect.js";
|
|
11
|
+
export { runTokenCreate, runTokenList, runTokenRevoke, type TokenCreateInput, type TokenScope } from "./token-commands.js";
|
|
10
12
|
export { AgentError, redactSensitiveText, toErrorShape, type AgentErrorCode, type AgentErrorShape } from "./errors.js";
|
package/dist/index.js
CHANGED
|
@@ -7,4 +7,6 @@ export { checkConfigPermissions, clearAgentToken, clearStoredToken, configDir, c
|
|
|
7
7
|
export { getCredentialStore, setCredentialStoreForTesting, InMemoryCredentialStore } from "./credentials.js";
|
|
8
8
|
export { createProjectCtxClient, requireToken } from "./cloud-client.js";
|
|
9
9
|
export { runLogin, runLogout, runWhoami, runWorkspaceUse, runWorkspaces } from "./auth-commands.js";
|
|
10
|
+
export { runConnect, buildConnectPlan, formatConnectOutput, inspectClientConfigs, connectPaths, upsertMarkerBlock, mergeMcpServersJson, upsertCodexToml, buildCursorMdc, atomicWriteWithBackup, MCP_SERVER_NAME, MCP_URL, INSTRUCTION_VERSION } from "./connect.js";
|
|
11
|
+
export { runTokenCreate, runTokenList, runTokenRevoke } from "./token-commands.js";
|
|
10
12
|
export { AgentError, redactSensitiveText, toErrorShape } from "./errors.js";
|
package/dist/mcp.js
CHANGED
|
@@ -29,7 +29,7 @@ export function createMcpServer(logger) {
|
|
|
29
29
|
logger?.debug("creating MCP server");
|
|
30
30
|
const server = new McpServer({ name: "projectctx-agent", version: "0.1.0-alpha.0" });
|
|
31
31
|
logger?.debug("registering MCP tool: scan_workspace");
|
|
32
|
-
server.tool("scan_workspace", "Scan a local workspace with the ProjectCtx scanner and return structured JSON. Read-only: no memory writes or cloud calls. The result includes auditTargets[]: EVERY content-bearing target — git repos, plain folders (docs, notes, research, campaigns), a root-level loose-file cluster (kind file_cluster), and allowlisted agent/tool dot folders such as .claude, .codex, .agents, .cursor, .windsurf, and .github — each with facts, overview excerpts, importantLookingFiles, and a suggestedReadPlan. Coverage mandate: do not silently skip any audit target, and never infer meaning from folder or file names — meaning comes from reading the actual file contents. If skipped contains beyond maxDepth, re-invoke scan_workspace on that subtree with higher maxDepth; depth is iterative, not a reason to stop. Delegation: <=3 targets inspect inline; 4-15 targets use one focused subagent per target; >15 targets cluster related targets by parent folder at 3-5 targets per subagent; never skip a target to save agents. Each subagent prompt must include the target facts block verbatim, overview excerpts, importantLookingFiles, and suggestedReadPlan as a starting point, then read beyond the plan until it can state
|
|
32
|
+
server.tool("scan_workspace", "Scan a local workspace with the ProjectCtx scanner and return structured JSON. Read-only: no memory writes or cloud calls. The result includes auditTargets[]: EVERY content-bearing target — git repos, plain folders (docs, notes, research, campaigns), a root-level loose-file cluster (kind file_cluster), and allowlisted agent/tool dot folders such as .claude, .codex, .agents, .cursor, .windsurf, and .github — each with facts, overview excerpts, importantLookingFiles, and a suggestedReadPlan. Coverage mandate: do not silently skip any audit target, and never infer meaning from folder or file names — meaning comes from reading the actual file contents. If skipped contains beyond maxDepth, re-invoke scan_workspace on that subtree with higher maxDepth; depth is iterative, not a reason to stop. Delegation: <=3 targets inspect inline; 4-15 targets use one focused subagent per target; >15 targets cluster related targets by parent folder at 3-5 targets per subagent; never skip a target to save agents. Each subagent prompt must include the target facts block verbatim, overview excerpts, importantLookingFiles, and suggestedReadPlan as a starting point, then read beyond the plan until it can state the decision, fact, or relationship a future agent would need — if you can only describe what files exist, it is not a candidate. Subagents return memory candidates — never writes — e.g. Project, Person, Decision, Research Thread, Campaign, Experiment, Service, Dataset, Open Question, Source (open-ended; capture other meaningful things too), each with title, type, why it matters, confidence, suggested collection, suggested relationships, and — for every claim — a line-range citation `path:Lstart-Lend` (or a heading anchor for prose docs) plus a verbatim excerpt <=300 chars; these citations become the source `locator` at preview time. When a candidate warrants a NEW collection, also suggest a concise 3-5 typed snake_case field set for it — durable/queryable/at-a-glance dimensions only (status, owner, stage, dates, links); narrative stays in the record markdown, never in fields. Reuse an existing collection's fields rather than proposing parallel ones. CONFIDENCE RUBRIC: high = the claim is stated explicitly in a cited file (README, ADR, manifest); medium = inferred from >=2 consistent independent signals (e.g. dependencies + folder structure + commit messages); low = a single indirect signal. Downgrade low-confidence candidates to Open Question records or drop them — never write them as facts. CONSOLIDATION (when subagents were used): the orchestrator MUST merge all candidates before verification — normalize titles (case/punctuation), merge candidates sharing a normalized title or externalId, union their source citations, keep the highest confidence, and resolve type conflicts explicitly into one merged candidate with the conflict noted (never two records). Only the merged set proceeds to existence checks. VERIFICATION CHECKLIST — output as a table, one row per merged candidate, and do not call any preview tool until it is shown: (1) cited file + line range exists and supports the claim, (2) captures meaning not file inventory, (3) per-candidate existence search performed (show the query used), (4) confidence assigned per the rubric. Check existence PER CANDIDATE, not with one broad sweep: for each one, search_memory/query_records by its own distinctive name/identifier (for a repo, its name and externalId such as github:owner/repo) — search_memory is ranked and capped, so a single multi-term query is not a census and must never be the basis for declaring anything 'new' or 'missing'. When results are ambiguous, confirm with get_workspace_schema + get_context before concluding. Treat the preview/apply duplicate-title/externalId warnings as a final backstop, not your primary existence check. Run a completeness critic: every target has candidates or a concrete 'nothing meaningful' explanation, no skipped deep subtree is left unre-scanned, and another bounded round would produce nothing new. Present a coverage table to the user before preview/apply; ask before writing anything; use the preview/apply flow only.", {
|
|
33
33
|
rootPath: z.string().optional(),
|
|
34
34
|
maxRepos: z.number().int().positive().optional(),
|
|
35
35
|
maxTargets: z.number().int().positive().optional(),
|
|
@@ -59,7 +59,7 @@ export function createMcpServer(logger) {
|
|
|
59
59
|
}
|
|
60
60
|
});
|
|
61
61
|
logger?.debug("registering MCP tool: preview_code_memory");
|
|
62
|
-
server.tool("preview_code_memory", "Compile codebase scan context and an agent-written code brief into a ProjectCtx memory preview. No durable memory writes are applied. Write each repo brief self-contained: describe the repo on its own terms; do not define it by contrast to sibling records. To group repos, reuse an existing Project rather than forking a parallel one — the compiler reuses an exact-name match and returns warnings that surface similar-named existing projects. Read the returned warnings before you apply. To reuse a surfaced project, pass its externalId as projectExternalId when it has one, or reuse it by its exact title/alias when it does not — projectExternalId accepts only an externalId, never a record id. The flow self-provisions its own code ontology: the Repos and Projects collections and the builds relationship type are reused if they already exist, otherwise created on the fly, and are POPULATED in the same pass (repo + project records, builds links) — so scanning into a brand-new empty workspace needs no seeding. Separately, if the repo context clearly describes an entity type no existing collection covers (e.g. Services, People, Datasets), you may propose it in the optional collections[] input; those emergent collections are created/reconciled by name (exact-name reuse, fuzzy warning)
|
|
62
|
+
server.tool("preview_code_memory", "Compile codebase scan context and an agent-written code brief into a ProjectCtx memory preview. No durable memory writes are applied. Write each repo brief self-contained: describe the repo on its own terms; do not define it by contrast to sibling records. To group repos, reuse an existing Project rather than forking a parallel one — the compiler reuses an exact-name match and returns warnings that surface similar-named existing projects. Read the returned warnings before you apply. To reuse a surfaced project, pass its externalId as projectExternalId when it has one, or reuse it by its exact title/alias when it does not — projectExternalId accepts only an externalId, never a record id. The flow self-provisions its own code ontology: the Repos and Projects collections and the builds relationship type are reused if they already exist, otherwise created on the fly, and are POPULATED in the same pass (repo + project records, builds links) — so scanning into a brand-new empty workspace needs no seeding. Fields are agent-authored, with no baked-in defaults: to give Repos/Projects structured fields, include collections[] entries named \"Repos\"/\"Projects\" with a concise fields[] (3-5 typed snake_case fields for durable/queryable dimensions — Repo e.g. primary_language/status/deploy_url; Project e.g. status/stage/owner/next_step). Populate them by passing a repo.fields value map and a repo.project.fields value map whose keys match those fields, and put the project's prose in repo.project.markdownBrief so the Project record is not an empty hub. Separately, if the repo context clearly describes an entity type no existing collection covers (e.g. Services, People, Datasets), you may propose it in the optional collections[] input with its own concise fields[]; those emergent collections are created/reconciled by name (exact-name reuse, fuzzy warning) and their fields reconcile additively (existing fields never change). Keep narrative in markdown, not fields. Prefer an existing collection; do not propose one just because a topic is mentioned in passing. Brief-quality rubric: each repo brief must name purpose, key decisions, current state, and next step — a directory listing is not a brief. When the target collection defines REQUIRED fields, populate them; leaving a required field unset is a gap the server will warn on (optional fields are fine to leave empty). Every claim in a brief that is not self-evident from the manifest should carry a locator-bearing source (create_source with a `locator` of `path:Lstart-Lend` or a heading anchor, plus its verbatim excerpt); the server warns on records with no attached source, on any source missing an excerpt, and on repo_doc sources missing a locator (chat/url sources are not expected to have one).", previewCodeMemorySchema.shape, async (input) => {
|
|
63
63
|
try {
|
|
64
64
|
return jsonText(await runPreviewCodeMemory(previewCodeMemorySchema.parse(input), logger));
|
|
65
65
|
}
|