@contextline/contextline 0.1.1 → 0.2.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 +154 -178
- package/dist/cli.js +324 -545
- package/dist/cli.js.map +1 -1
- package/dist/postinstall.js +1 -1
- package/dist/postinstall.js.map +1 -1
- package/docs/why-contextline.md +116 -120
- package/package.json +56 -59
- package/dist/index.d.ts +0 -2
- package/dist/index.js +0 -695
- package/dist/index.js.map +0 -1
package/dist/cli.js
CHANGED
|
@@ -3,9 +3,6 @@
|
|
|
3
3
|
// src/cli.ts
|
|
4
4
|
import { Command } from "commander";
|
|
5
5
|
|
|
6
|
-
// src/tools/appendFragment.ts
|
|
7
|
-
import fs5 from "fs/promises";
|
|
8
|
-
|
|
9
6
|
// src/config.ts
|
|
10
7
|
import fs from "fs";
|
|
11
8
|
import path from "path";
|
|
@@ -17,9 +14,145 @@ function resolveRoot(folder) {
|
|
|
17
14
|
if (folder) return path.resolve(folder);
|
|
18
15
|
return getMemoryRoot();
|
|
19
16
|
}
|
|
17
|
+
function hasMemoryRoot(folder) {
|
|
18
|
+
const root = resolveRoot(folder);
|
|
19
|
+
try {
|
|
20
|
+
return fs.statSync(root).isDirectory();
|
|
21
|
+
} catch (error) {
|
|
22
|
+
if (error.code === "ENOENT") return false;
|
|
23
|
+
throw error;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
20
26
|
|
|
21
|
-
// src/
|
|
27
|
+
// src/core/instructions.ts
|
|
28
|
+
var CONTEXTLINE_INSTRUCTIONS_FILE = "contextline_instructions.md";
|
|
29
|
+
function memorySaveRules(root) {
|
|
30
|
+
const l2 = root ? `"${root}/L2_Deep/[topic].md"` : `L2_Deep/[topic].md`;
|
|
31
|
+
const l1 = root ? `"${root}/L1_Quick.md"` : `L1_Quick.md`;
|
|
32
|
+
return `SAVE THESE \u2014 do not skip any:
|
|
33
|
+
- Any decision made, even informal ("skip this", "do it this way", "won't fix")
|
|
34
|
+
- Any finding or discovery made by either user or assistant
|
|
35
|
+
- Any correction to prior understanding
|
|
36
|
+
- Any document reviewed and key feedback given
|
|
37
|
+
- Any item resolved, approved, or declined
|
|
38
|
+
- Any new direction chosen \u2014 save alternatives considered and rationale too
|
|
39
|
+
|
|
40
|
+
DO NOT SAVE: transient tool outputs, session navigation, file paths read, intermediate mechanics.
|
|
41
|
+
|
|
42
|
+
HOW TO SAVE (use file read/write tools \u2014 NOT Bash or shell commands):
|
|
43
|
+
1. Save to ${l2} \u2014 the DETAIL layer. Write 2\u20134 sentences per fact:
|
|
44
|
+
what happened + why + what was considered + what it means going forward.
|
|
45
|
+
Format: "(YYYY-MM-DD) [full context with rationale and impact]"
|
|
46
|
+
- Read the file, then Write it back with the new entry at the TOP, above existing entries (after any leading # header line)
|
|
47
|
+
- Create the file if the topic is new
|
|
48
|
+
- Semantic filenames: project_x.md, preference_user.md, person_alice.md, area_work.md
|
|
49
|
+
- Save to ALL relevant topic files when a fact involves multiple topics
|
|
50
|
+
- If your L2 entry looks like your L1 summary, it is too thin \u2014 add the why and context.
|
|
51
|
+
2. Save a SHORT pointer to ${l1} \u2014 the INDEX layer. \u226415 words, no detail.
|
|
52
|
+
"(YYYY-MM-DD) [subject + what happened]. \u2192 [topic].md"
|
|
53
|
+
- Read the file, then Write it back with the new line at the TOP of the entries (after any leading header lines)
|
|
54
|
+
- L1 is a scannable index only. If you are writing more than 15 words, move the detail to L2.
|
|
55
|
+
|
|
56
|
+
ADD ONLY \u2014 never modify or delete existing lines. New entries go at the top. Date every L2 entry: (YYYY-MM-DD).
|
|
57
|
+
|
|
58
|
+
"No new facts to save" should be RARE. If in doubt, save. Missed saves break continuity; redundant saves are harmless.
|
|
59
|
+
|
|
60
|
+
Before responding, confirm on one line \u2014 either "Saved: [what] \u2192 [topic].md" or "Nothing saved: [reason]". This line is mandatory. Skipping it means the user cannot verify whether memory was updated.`;
|
|
61
|
+
}
|
|
62
|
+
var DEFAULT_CONTEXTLINE_INSTRUCTIONS = `# ContextLine Memory
|
|
63
|
+
|
|
64
|
+
CONTEXTLINE-INSTRUCTIONS-V2
|
|
65
|
+
|
|
66
|
+
Memory lives in two layers inside this folder:
|
|
67
|
+
|
|
68
|
+
- **L1_Quick.md** \u2014 compact one-line facts and topic pointers. One line per topic, kept brief.
|
|
69
|
+
- **L2_Deep/[topic].md** \u2014 full dated detail per topic. Always append; never edit existing lines.
|
|
70
|
+
|
|
71
|
+
## Rules
|
|
72
|
+
|
|
73
|
+
ADD ONLY. Every write adds new lines at the top of the file's entry list (after any # header lines). Never modify or delete existing content.
|
|
74
|
+
|
|
75
|
+
Date every L2 line: (YYYY-MM-DD). Newer entries win on contradictions \u2014 do not remove older ones. New entries go at the top so the LLM encounters the most recent context first.
|
|
76
|
+
|
|
77
|
+
Use semantic filenames for L2 files: project_debatrix.md, preference_user.md, person_alice.md, area_work.md, decision_auth.md, event_launch.md.
|
|
78
|
+
|
|
79
|
+
Cross-save: if a fact involves multiple topics (person + project, place + event), append the relevant angle to each file.
|
|
80
|
+
|
|
81
|
+
## Saving
|
|
82
|
+
|
|
83
|
+
${memorySaveRules()}
|
|
84
|
+
`;
|
|
85
|
+
|
|
86
|
+
// src/hook.ts
|
|
22
87
|
import path2 from "path";
|
|
88
|
+
async function readStdin() {
|
|
89
|
+
const chunks = [];
|
|
90
|
+
for await (const chunk of process.stdin) chunks.push(Buffer.from(chunk));
|
|
91
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
92
|
+
}
|
|
93
|
+
function sessionStartInstruction(root) {
|
|
94
|
+
return `ContextLine memory is available. Read the file at "${root}/L1_Quick.md" to load your memory index. It contains compact facts and topic pointers. If you need detail on a specific topic, read the relevant file from "${root}/L2_Deep/". Only read L2 files when needed \u2014 do not load the entire directory.`;
|
|
95
|
+
}
|
|
96
|
+
function checkpointInstruction(root) {
|
|
97
|
+
return `ContextLine memory checkpoint. Before responding, append any new durable facts to disk.
|
|
98
|
+
|
|
99
|
+
${memorySaveRules(root)}`;
|
|
100
|
+
}
|
|
101
|
+
function continueWithoutContext() {
|
|
102
|
+
process.stdout.write(JSON.stringify({ continue: true }));
|
|
103
|
+
}
|
|
104
|
+
async function runHook(rootInput) {
|
|
105
|
+
const raw = await readStdin();
|
|
106
|
+
const event = raw.trim() ? JSON.parse(raw) : {};
|
|
107
|
+
const root = rootInput ? path2.resolve(rootInput) : resolveRoot();
|
|
108
|
+
const hasRoot = hasMemoryRoot(root);
|
|
109
|
+
if (event.hook_event_name === "SessionStart") {
|
|
110
|
+
if (!hasRoot) {
|
|
111
|
+
continueWithoutContext();
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
process.stdout.write(
|
|
115
|
+
JSON.stringify({
|
|
116
|
+
continue: true,
|
|
117
|
+
hookSpecificOutput: {
|
|
118
|
+
hookEventName: "SessionStart",
|
|
119
|
+
additionalContext: sessionStartInstruction(root)
|
|
120
|
+
}
|
|
121
|
+
})
|
|
122
|
+
);
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
if (event.hook_event_name === "UserPromptSubmit") {
|
|
126
|
+
if (!hasRoot) {
|
|
127
|
+
continueWithoutContext();
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
process.stdout.write(
|
|
131
|
+
JSON.stringify({
|
|
132
|
+
continue: true,
|
|
133
|
+
hookSpecificOutput: {
|
|
134
|
+
hookEventName: "UserPromptSubmit",
|
|
135
|
+
additionalContext: checkpointInstruction(root)
|
|
136
|
+
}
|
|
137
|
+
})
|
|
138
|
+
);
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// src/setup.ts
|
|
144
|
+
import { cwd } from "process";
|
|
145
|
+
|
|
146
|
+
// src/host/claude.ts
|
|
147
|
+
import fs3 from "fs/promises";
|
|
148
|
+
import os from "os";
|
|
149
|
+
import path4 from "path";
|
|
150
|
+
|
|
151
|
+
// src/tools/createMemorySkeleton.ts
|
|
152
|
+
import fs2 from "fs/promises";
|
|
153
|
+
|
|
154
|
+
// src/paths.ts
|
|
155
|
+
import path3 from "path";
|
|
23
156
|
|
|
24
157
|
// src/core/errors.ts
|
|
25
158
|
var ContextLineError = class extends Error {
|
|
@@ -38,12 +171,11 @@ var PathSafetyError = class extends ContextLineError {
|
|
|
38
171
|
// src/paths.ts
|
|
39
172
|
var L1_FILE = "L1_Quick.md";
|
|
40
173
|
var L2_DIR = "L2_Deep";
|
|
41
|
-
var L3_DIR = "L3_Details";
|
|
42
174
|
function assertSafeRelative(input) {
|
|
43
175
|
if (!input || input.trim() !== input) {
|
|
44
176
|
throw new PathSafetyError("Path input must be non-empty and contain no leading/trailing whitespace.");
|
|
45
177
|
}
|
|
46
|
-
if (
|
|
178
|
+
if (path3.isAbsolute(input)) {
|
|
47
179
|
throw new PathSafetyError("Absolute paths are not allowed in tool inputs.");
|
|
48
180
|
}
|
|
49
181
|
const parts = input.split(/[\\/]+/);
|
|
@@ -51,454 +183,185 @@ function assertSafeRelative(input) {
|
|
|
51
183
|
throw new PathSafetyError("Path traversal and empty path segments are not allowed.");
|
|
52
184
|
}
|
|
53
185
|
}
|
|
54
|
-
function assertSafeRelativeFile(input) {
|
|
55
|
-
assertSafeRelative(input);
|
|
56
|
-
if (!input.endsWith(".md")) {
|
|
57
|
-
throw new PathSafetyError("Memory file paths must end with .md.");
|
|
58
|
-
}
|
|
59
|
-
}
|
|
60
186
|
function safeJoin(root, ...segments) {
|
|
61
187
|
for (const segment of segments) {
|
|
62
188
|
assertSafeRelative(segment);
|
|
63
189
|
}
|
|
64
|
-
const resolvedRoot =
|
|
65
|
-
const target =
|
|
66
|
-
const rel =
|
|
67
|
-
if (rel.startsWith("..") ||
|
|
190
|
+
const resolvedRoot = path3.resolve(root);
|
|
191
|
+
const target = path3.resolve(resolvedRoot, ...segments);
|
|
192
|
+
const rel = path3.relative(resolvedRoot, target);
|
|
193
|
+
if (rel.startsWith("..") || path3.isAbsolute(rel)) {
|
|
68
194
|
throw new PathSafetyError("Resolved path escapes ContextLine root.");
|
|
69
195
|
}
|
|
70
196
|
return target;
|
|
71
197
|
}
|
|
72
|
-
function deepPath(root, file) {
|
|
73
|
-
assertSafeRelativeFile(file);
|
|
74
|
-
if (file.includes("/") || file.includes("\\")) {
|
|
75
|
-
throw new PathSafetyError("L2 deep memory files must be flat filenames inside L2_Deep for V1.");
|
|
76
|
-
}
|
|
77
|
-
return safeJoin(root, L2_DIR, file);
|
|
78
|
-
}
|
|
79
|
-
function detailPath(root, file) {
|
|
80
|
-
assertSafeRelativeFile(file);
|
|
81
|
-
return safeJoin(root, L3_DIR, file);
|
|
82
|
-
}
|
|
83
|
-
function l1Path(root) {
|
|
84
|
-
return safeJoin(root, L1_FILE);
|
|
85
|
-
}
|
|
86
198
|
|
|
87
|
-
// src/
|
|
88
|
-
|
|
89
|
-
import path3 from "path";
|
|
90
|
-
async function atomicWriteFile(filePath, content) {
|
|
91
|
-
await fs2.mkdir(path3.dirname(filePath), { recursive: true });
|
|
92
|
-
const tmp = path3.join(path3.dirname(filePath), `.${path3.basename(filePath)}.${process.pid}.${Date.now()}.tmp`);
|
|
93
|
-
const handle = await fs2.open(tmp, "w");
|
|
94
|
-
try {
|
|
95
|
-
await handle.writeFile(content, "utf8");
|
|
96
|
-
await handle.sync();
|
|
97
|
-
} finally {
|
|
98
|
-
await handle.close();
|
|
99
|
-
}
|
|
100
|
-
await fs2.rename(tmp, filePath);
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
// src/core/markdown.ts
|
|
104
|
-
function ensureTrailingNewline(content) {
|
|
105
|
-
return content.endsWith("\n") ? content : `${content}
|
|
106
|
-
`;
|
|
107
|
-
}
|
|
108
|
-
function upsertLineByPrefix(content, prefix, nextLine) {
|
|
109
|
-
const lines = ensureTrailingNewline(content).split("\n");
|
|
110
|
-
const index = lines.findIndex((line) => line.trimStart().startsWith(prefix));
|
|
111
|
-
if (index >= 0) {
|
|
112
|
-
lines[index] = nextLine;
|
|
113
|
-
} else {
|
|
114
|
-
lines.splice(Math.max(0, lines.length - 1), 0, nextLine);
|
|
115
|
-
}
|
|
116
|
-
return ensureTrailingNewline(lines.join("\n").replace(/\n{3,}/g, "\n\n"));
|
|
117
|
-
}
|
|
199
|
+
// src/tools/createMemorySkeleton.ts
|
|
200
|
+
var DEFAULT_L1 = `# ContextLine Memory Index
|
|
118
201
|
|
|
119
|
-
|
|
120
|
-
import { format, parseISO } from "date-fns";
|
|
121
|
-
function detailFilename(dateInput) {
|
|
122
|
-
const date = dateInput ? parseISO(dateInput) : /* @__PURE__ */ new Date();
|
|
123
|
-
if (Number.isNaN(date.getTime())) {
|
|
124
|
-
throw new Error("Invalid date. Expected YYYY-MM-DD.");
|
|
125
|
-
}
|
|
126
|
-
return `L3_${format(date, "yyyy_MM")}.md`;
|
|
127
|
-
}
|
|
128
|
-
function detailHeader(dateInput) {
|
|
129
|
-
const date = dateInput ? parseISO(dateInput) : /* @__PURE__ */ new Date();
|
|
130
|
-
if (Number.isNaN(date.getTime())) {
|
|
131
|
-
throw new Error("Invalid date. Expected YYYY-MM-DD.");
|
|
132
|
-
}
|
|
133
|
-
return `# ${format(date, "MMMM yyyy")} Detailed Memory
|
|
202
|
+
Compact facts and topic pointers. One line per topic.
|
|
134
203
|
|
|
135
204
|
`;
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
const
|
|
143
|
-
|
|
144
|
-
|
|
205
|
+
async function createMemorySkeleton(root) {
|
|
206
|
+
const created = [];
|
|
207
|
+
await fs2.mkdir(root, { recursive: true });
|
|
208
|
+
const l2Dir = safeJoin(root, L2_DIR);
|
|
209
|
+
await fs2.mkdir(l2Dir, { recursive: true });
|
|
210
|
+
created.push(L2_DIR);
|
|
211
|
+
const l1Target = safeJoin(root, L1_FILE);
|
|
212
|
+
try {
|
|
213
|
+
await fs2.writeFile(l1Target, DEFAULT_L1, { encoding: "utf8", flag: "wx" });
|
|
214
|
+
created.push(L1_FILE);
|
|
215
|
+
} catch (error) {
|
|
216
|
+
if (error.code !== "EEXIST") throw error;
|
|
145
217
|
}
|
|
146
|
-
|
|
218
|
+
const instructionsTarget = safeJoin(root, CONTEXTLINE_INSTRUCTIONS_FILE);
|
|
219
|
+
await fs2.writeFile(instructionsTarget, DEFAULT_CONTEXTLINE_INSTRUCTIONS, { encoding: "utf8" });
|
|
220
|
+
created.push(CONTEXTLINE_INSTRUCTIONS_FILE);
|
|
221
|
+
return { ok: true, root, created };
|
|
147
222
|
}
|
|
148
223
|
|
|
149
|
-
// src/
|
|
150
|
-
|
|
151
|
-
import path4 from "path";
|
|
152
|
-
var sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
153
|
-
async function withLock(root, fn) {
|
|
154
|
-
const lockPath = path4.join(root, ".lock");
|
|
155
|
-
const start = Date.now();
|
|
156
|
-
let handle;
|
|
157
|
-
while (!handle) {
|
|
158
|
-
try {
|
|
159
|
-
await fs3.mkdir(root, { recursive: true });
|
|
160
|
-
handle = await fs3.open(lockPath, "wx");
|
|
161
|
-
} catch (error) {
|
|
162
|
-
const code = error.code;
|
|
163
|
-
if (code !== "EEXIST" || Date.now() - start > 5e3) {
|
|
164
|
-
throw error;
|
|
165
|
-
}
|
|
166
|
-
await sleep(50);
|
|
167
|
-
}
|
|
168
|
-
}
|
|
224
|
+
// src/host/claude.ts
|
|
225
|
+
async function readJsonObject(filePath) {
|
|
169
226
|
try {
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
227
|
+
const raw = await fs3.readFile(filePath, "utf8");
|
|
228
|
+
const parsed = JSON.parse(raw);
|
|
229
|
+
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
|
|
230
|
+
} catch (error) {
|
|
231
|
+
if (error.code === "ENOENT") return {};
|
|
232
|
+
throw error;
|
|
174
233
|
}
|
|
175
234
|
}
|
|
235
|
+
async function writeHookScript(scriptPath) {
|
|
236
|
+
const script = `#!/usr/bin/env node
|
|
237
|
+
import { spawnSync } from "node:child_process";
|
|
238
|
+
import fs from "node:fs";
|
|
176
239
|
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
var DEFAULT_CONTEXTLINE_INSTRUCTIONS = `# ContextLine Instructions
|
|
184
|
-
|
|
185
|
-
CONTEXTLINE-INSTRUCTIONS-V1
|
|
186
|
-
This file contains generic operating instructions for ContextLine memory.
|
|
187
|
-
Use the single MCP tool named "contextline" for all ContextLine reads and writes.
|
|
188
|
-
Do not use shell commands to read or write .contextline files.
|
|
189
|
-
Do not mention memory saving unless the user asks.
|
|
190
|
-
|
|
191
|
-
## Operating Policy
|
|
192
|
-
|
|
193
|
-
Use the single ContextLine MCP tool "contextline" silently for durable memory. Do not use shell commands to read or write .contextline files. Do not mention memory saving unless asked.
|
|
194
|
-
|
|
195
|
-
Durable memory means stable facts likely to help future sessions: user/project facts, preferences, goals, constraints, decisions, relationships, recurring patterns, and important events. Do not save filler, acknowledgements, or one-off chatter.
|
|
196
|
-
|
|
197
|
-
Do not save every message. Periodically batch-save useful durable memory since the last ContextLine save in this session.
|
|
198
|
-
|
|
199
|
-
Use ContextLine as a human-like cache:
|
|
200
|
-
|
|
201
|
-
- L1_Quick.md stores compact facts, summaries, and active pointers.
|
|
202
|
-
- L2_Deep/ stores deeper facts per important object, person, event, project, preference area, or decision thread.
|
|
203
|
-
- L3_Details/ stores timestamped specifics, receipts, and supporting history.
|
|
204
|
-
|
|
205
|
-
For V1, use flat L2 filenames with semantic prefixes instead of folders. Prefer names like person_user.md, project_contextline.md, area_work.md, place_san_jose.md, preference_media.md, preference_language.md, decision_memory_architecture.md, event_launch.md, or org_meta.md.
|
|
206
|
-
|
|
207
|
-
When saving durable memory, complete the full chain. Do not stop after writing L3_Details:
|
|
208
|
-
|
|
209
|
-
1. Append one L3_Details receipt when supporting specifics are useful.
|
|
210
|
-
2. Cross-save extracted meaning into every relevant L2_Deep file.
|
|
211
|
-
3. Update L1_Quick only for compact high-level facts or active pointers.
|
|
212
|
-
4. Link every L1 line to relevant L2 files.
|
|
213
|
-
5. Link every L2 line to relevant L3 details when supporting detail exists.
|
|
214
|
-
|
|
215
|
-
Typical tool sequence:
|
|
216
|
-
|
|
217
|
-
Call "contextline" once with action "save_memory", including:
|
|
218
|
-
|
|
219
|
-
- detail.text for the L3_Details receipt
|
|
220
|
-
- deep[] entries for every relevant L2_Deep file, such as person_user.md, area_work.md, place_san_jose.md, or preference_media.md
|
|
221
|
-
- quick[] entries for compact L1_Quick facts linked to L2 files
|
|
222
|
-
|
|
223
|
-
If you write L3_Details but do not update L2_Deep and L1_Quick, the memory is incomplete. Prefer the bundled "save_memory" action so the full chain is saved together.
|
|
224
|
-
`;
|
|
225
|
-
|
|
226
|
-
// src/tools/init.ts
|
|
227
|
-
var DEFAULT_L1 = `# ContextLine L1 Index
|
|
228
|
-
|
|
229
|
-
This is the quick memory cache: compact facts, summaries, and active pointers.
|
|
230
|
-
|
|
231
|
-
## Quick Facts
|
|
240
|
+
const input = fs.readFileSync(0, "utf8");
|
|
241
|
+
const result = spawnSync("contextline", ["hook"], {
|
|
242
|
+
input,
|
|
243
|
+
encoding: "utf8",
|
|
244
|
+
shell: process.platform === "win32"
|
|
245
|
+
});
|
|
232
246
|
|
|
247
|
+
if (result.stderr) process.stderr.write(result.stderr);
|
|
248
|
+
if (result.stdout) process.stdout.write(result.stdout);
|
|
249
|
+
process.exit(result.status ?? 0);
|
|
233
250
|
`;
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
"
|
|
242
|
-
|
|
243
|
-
const
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
}
|
|
247
|
-
const files = [
|
|
248
|
-
[L1_FILE, DEFAULT_L1],
|
|
249
|
-
[CONTEXTLINE_INSTRUCTIONS_FILE, DEFAULT_CONTEXTLINE_INSTRUCTIONS],
|
|
250
|
-
["config.json", `${JSON.stringify({ version: 1, capture: { promotion: "selective" } }, null, 2)}
|
|
251
|
-
`]
|
|
252
|
-
];
|
|
253
|
-
for (const [file, content] of files) {
|
|
254
|
-
const target = safeJoin(root, file);
|
|
255
|
-
try {
|
|
256
|
-
await fs4.writeFile(target, content, { encoding: "utf8", flag: "wx" });
|
|
257
|
-
created.push(path5.relative(root, target));
|
|
258
|
-
} catch (error) {
|
|
259
|
-
if (error.code !== "EEXIST") {
|
|
260
|
-
throw error;
|
|
261
|
-
}
|
|
262
|
-
}
|
|
263
|
-
}
|
|
264
|
-
return { ok: true, root, created };
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
// src/tools/appendFragment.ts
|
|
268
|
-
async function appendFragment(text, date, rootInput) {
|
|
269
|
-
const root = resolveRoot(rootInput);
|
|
270
|
-
await initContextLine(root);
|
|
271
|
-
return withLock(root, async () => {
|
|
272
|
-
const detail_file = detailFilename(date);
|
|
273
|
-
const filePath = detailPath(root, detail_file);
|
|
274
|
-
let content;
|
|
275
|
-
try {
|
|
276
|
-
content = await fs5.readFile(filePath, "utf8");
|
|
277
|
-
} catch (error) {
|
|
278
|
-
if (error.code !== "ENOENT") {
|
|
279
|
-
throw error;
|
|
280
|
-
}
|
|
281
|
-
content = detailHeader(date);
|
|
282
|
-
}
|
|
283
|
-
const fragment = detailLine(text, date);
|
|
284
|
-
await atomicWriteFile(filePath, `${ensureTrailingNewline(content)}${fragment}
|
|
285
|
-
`);
|
|
286
|
-
return {
|
|
287
|
-
ok: true,
|
|
288
|
-
detail_file,
|
|
289
|
-
fragment,
|
|
290
|
-
next_required_steps: [
|
|
291
|
-
"Call contextline with action save_memory to save L3, L2, and L1 together.",
|
|
292
|
-
"If manually continuing, call contextline action update_deep for every relevant L2_Deep file and action update_index for compact L1_Quick facts."
|
|
293
|
-
],
|
|
294
|
-
display: `ContextLine L3 detail appended
|
|
295
|
-
- Detail file: ${detail_file}`
|
|
296
|
-
};
|
|
251
|
+
await fs3.writeFile(scriptPath, script, "utf8");
|
|
252
|
+
}
|
|
253
|
+
function containsContextlineHook(entry) {
|
|
254
|
+
if (!entry || typeof entry !== "object") return false;
|
|
255
|
+
const hooks = entry.hooks;
|
|
256
|
+
if (!Array.isArray(hooks)) return false;
|
|
257
|
+
return hooks.some((hook) => {
|
|
258
|
+
if (!hook || typeof hook !== "object") return false;
|
|
259
|
+
const h = hook;
|
|
260
|
+
const command = typeof h.command === "string" ? h.command : "";
|
|
261
|
+
const args = Array.isArray(h.args) ? h.args.filter((arg) => typeof arg === "string") : [];
|
|
262
|
+
return command === "node" && args.some((arg) => arg.replaceAll("\\", "/").endsWith("/.claude/hooks/contextline-hook.mjs"));
|
|
297
263
|
});
|
|
298
264
|
}
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
const root = resolveRoot(rootInput);
|
|
304
|
-
await initContextLine(root);
|
|
305
|
-
const l1_path = l1Path(root);
|
|
306
|
-
const instructions_path = safeJoin(root, CONTEXTLINE_INSTRUCTIONS_FILE);
|
|
307
|
-
const content = await fs6.readFile(l1_path, "utf8");
|
|
308
|
-
const instructions = await fs6.readFile(instructions_path, "utf8");
|
|
309
|
-
return {
|
|
310
|
-
ok: true,
|
|
311
|
-
root,
|
|
312
|
-
l1_path,
|
|
313
|
-
instructions_path,
|
|
314
|
-
content,
|
|
315
|
-
instructions,
|
|
316
|
-
instruction: "Use this L1 content as quick memory. Follow the included ContextLine instructions for reading and saving memory.",
|
|
317
|
-
display: `ContextLine hydrated
|
|
318
|
-
- Quick memory: ${l1_path}
|
|
319
|
-
- Instructions: ${instructions_path}`
|
|
320
|
-
};
|
|
321
|
-
}
|
|
322
|
-
|
|
323
|
-
// src/tools/inspect.ts
|
|
324
|
-
import fs7 from "fs/promises";
|
|
325
|
-
import path6 from "path";
|
|
326
|
-
async function countFiles(dir) {
|
|
327
|
-
try {
|
|
328
|
-
return (await fs7.readdir(dir)).length;
|
|
329
|
-
} catch {
|
|
330
|
-
return 0;
|
|
265
|
+
async function upsertClaudeSettings(settingsPath, projectRoot, contextlineRoot) {
|
|
266
|
+
const existing = await readJsonObject(settingsPath);
|
|
267
|
+
if (!existing.hooks || typeof existing.hooks !== "object" || Array.isArray(existing.hooks)) {
|
|
268
|
+
existing.hooks = {};
|
|
331
269
|
}
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
const
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
270
|
+
const hooks = existing.hooks;
|
|
271
|
+
const sessionStart = Array.isArray(hooks.SessionStart) ? hooks.SessionStart : [];
|
|
272
|
+
const userPromptSubmit = Array.isArray(hooks.UserPromptSubmit) ? hooks.UserPromptSubmit : [];
|
|
273
|
+
const filteredSessionStart = sessionStart.filter((entry) => !containsContextlineHook(entry));
|
|
274
|
+
const filteredUserPromptSubmit = userPromptSubmit.filter((entry) => !containsContextlineHook(entry));
|
|
275
|
+
const hook = {
|
|
276
|
+
type: "command",
|
|
277
|
+
command: "node",
|
|
278
|
+
args: ["${CLAUDE_PROJECT_DIR}/.claude/hooks/contextline-hook.mjs"]
|
|
340
279
|
};
|
|
280
|
+
const sessionStartEntry = {
|
|
281
|
+
matcher: "startup|resume|clear|compact",
|
|
282
|
+
hooks: [hook]
|
|
283
|
+
};
|
|
284
|
+
const userPromptSubmitEntry = {
|
|
285
|
+
hooks: [hook]
|
|
286
|
+
};
|
|
287
|
+
hooks.SessionStart = [sessionStartEntry, ...filteredSessionStart];
|
|
288
|
+
hooks.UserPromptSubmit = [userPromptSubmitEntry, ...filteredUserPromptSubmit];
|
|
289
|
+
const memPath = path4.relative(projectRoot, contextlineRoot).replaceAll("\\", "/");
|
|
290
|
+
const memPermissions = [`Read(${memPath}/**)`, `Write(${memPath}/**)`, `Edit(${memPath}/**)`];
|
|
291
|
+
if (!existing.permissions || typeof existing.permissions !== "object" || Array.isArray(existing.permissions)) {
|
|
292
|
+
existing.permissions = {};
|
|
293
|
+
}
|
|
294
|
+
const permissions = existing.permissions;
|
|
295
|
+
const existingAllow = Array.isArray(permissions.allow) ? permissions.allow : [];
|
|
296
|
+
const filteredAllow = existingAllow.filter((r) => typeof r !== "string" || !memPermissions.includes(r));
|
|
297
|
+
permissions.allow = [...memPermissions, ...filteredAllow];
|
|
298
|
+
await fs3.writeFile(settingsPath, `${JSON.stringify(existing, null, 2)}
|
|
299
|
+
`, "utf8");
|
|
341
300
|
}
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
}
|
|
355
|
-
function ensureWikiLink(line, filename) {
|
|
356
|
-
return hasWikiLink(line, filename) ? line : `${line.trim()} [[${filename}]]`;
|
|
357
|
-
}
|
|
358
|
-
|
|
359
|
-
// src/core/topology.ts
|
|
360
|
-
async function exists(target) {
|
|
361
|
-
try {
|
|
362
|
-
await fs8.access(target);
|
|
363
|
-
return true;
|
|
364
|
-
} catch {
|
|
365
|
-
return false;
|
|
366
|
-
}
|
|
367
|
-
}
|
|
368
|
-
async function listMarkdown(dir) {
|
|
369
|
-
const out = [];
|
|
370
|
-
try {
|
|
371
|
-
const entries = await fs8.readdir(dir, { withFileTypes: true });
|
|
372
|
-
for (const entry of entries) {
|
|
373
|
-
const full = path7.join(dir, entry.name);
|
|
374
|
-
if (entry.isDirectory()) {
|
|
375
|
-
const nested = await listMarkdown(full);
|
|
376
|
-
out.push(...nested.map((file) => path7.join(entry.name, file)));
|
|
377
|
-
} else if (entry.name.endsWith(".md")) {
|
|
378
|
-
out.push(entry.name);
|
|
379
|
-
}
|
|
380
|
-
}
|
|
381
|
-
return out;
|
|
382
|
-
} catch {
|
|
383
|
-
return [];
|
|
384
|
-
}
|
|
385
|
-
}
|
|
386
|
-
async function validateTopology(root) {
|
|
387
|
-
const errors = [];
|
|
388
|
-
const warnings = [];
|
|
389
|
-
const l1 = l1Path(root);
|
|
390
|
-
const l2 = safeJoin(root, L2_DIR);
|
|
391
|
-
const l3 = safeJoin(root, L3_DIR);
|
|
392
|
-
if (!await exists(l1)) errors.push("L1_Quick.md is missing.");
|
|
393
|
-
if (!await exists(l2)) errors.push("L2_Deep directory is missing.");
|
|
394
|
-
if (!await exists(l3)) errors.push("L3_Details directory is missing.");
|
|
395
|
-
if (errors.length) return { ok: false, errors, warnings };
|
|
396
|
-
const l1Content = await fs8.readFile(l1, "utf8");
|
|
397
|
-
for (const link of extractWikiLinks(l1Content)) {
|
|
398
|
-
if (link.startsWith("L3_")) {
|
|
399
|
-
errors.push(`L1 must not link directly to L3 detail file: ${link}`);
|
|
400
|
-
} else if (!await exists(path7.join(l2, link))) {
|
|
401
|
-
errors.push(`L1 links to missing L2 deep memory file: ${link}`);
|
|
402
|
-
}
|
|
403
|
-
}
|
|
404
|
-
for (const file of await listMarkdown(l2)) {
|
|
405
|
-
const content = await fs8.readFile(path7.join(l2, file), "utf8");
|
|
406
|
-
for (const link of extractWikiLinks(content)) {
|
|
407
|
-
if (link === L1_FILE) {
|
|
408
|
-
errors.push(`L2 deep memory file ${file} must not link upward to L1_Quick.md.`);
|
|
409
|
-
} else if (!link.startsWith("L3_")) {
|
|
410
|
-
warnings.push(`L2 deep memory file ${file} links to non-L3 file: ${link}`);
|
|
411
|
-
} else if (!await exists(path7.join(l3, link))) {
|
|
412
|
-
errors.push(`L2 deep memory file ${file} links to missing L3 detail file: ${link}`);
|
|
413
|
-
}
|
|
414
|
-
}
|
|
415
|
-
}
|
|
416
|
-
for (const file of await listMarkdown(l3)) {
|
|
417
|
-
const content = await fs8.readFile(path7.join(l3, file), "utf8");
|
|
418
|
-
const links = extractWikiLinks(content);
|
|
419
|
-
if (links.length) {
|
|
420
|
-
errors.push(`L3 detail file ${file} must not contain wiki links.`);
|
|
301
|
+
async function detectClaude(projectRoot) {
|
|
302
|
+
const details = [];
|
|
303
|
+
const candidates = [
|
|
304
|
+
path4.join(projectRoot, ".claude"),
|
|
305
|
+
path4.join(projectRoot, ".mcp.json"),
|
|
306
|
+
path4.join(os.homedir(), ".claude"),
|
|
307
|
+
path4.join(os.homedir(), ".claude.json")
|
|
308
|
+
];
|
|
309
|
+
for (const candidate of candidates) {
|
|
310
|
+
try {
|
|
311
|
+
await fs3.access(candidate);
|
|
312
|
+
details.push(`found ${candidate}`);
|
|
313
|
+
} catch {
|
|
421
314
|
}
|
|
422
315
|
}
|
|
423
|
-
return {
|
|
424
|
-
}
|
|
425
|
-
|
|
426
|
-
// src/tools/validateTopology.ts
|
|
427
|
-
async function validateTopology2(rootInput) {
|
|
428
|
-
const root = resolveRoot(rootInput);
|
|
429
|
-
await initContextLine(root);
|
|
430
|
-
const result = await validateTopology(root);
|
|
431
|
-
return {
|
|
432
|
-
...result,
|
|
433
|
-
display: result.ok ? "ContextLine topology valid" : `ContextLine topology invalid
|
|
434
|
-
- Errors: ${result.errors.length}
|
|
435
|
-
- Warnings: ${result.warnings.length}`
|
|
436
|
-
};
|
|
437
|
-
}
|
|
438
|
-
|
|
439
|
-
// src/hook.ts
|
|
440
|
-
import path8 from "path";
|
|
441
|
-
async function readStdin() {
|
|
442
|
-
const chunks = [];
|
|
443
|
-
for await (const chunk of process.stdin) chunks.push(Buffer.from(chunk));
|
|
444
|
-
return Buffer.concat(chunks).toString("utf8");
|
|
316
|
+
if (details.length) return { host: "claude", confidence: "high", details };
|
|
317
|
+
return { host: "none", confidence: "none", details: [] };
|
|
445
318
|
}
|
|
446
|
-
async function
|
|
447
|
-
|
|
448
|
-
const
|
|
449
|
-
const
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
additionalContext: `ContextLine memory is available. Call the "contextline" MCP tool with action "hydrate" and root ${JSON.stringify(root)} to load your memory index. This reads only files inside the .contextline folder at that path \u2014 it does not scan or ingest the parent directory. Use that same root for all ContextLine calls this session, then follow the returned instructions.`
|
|
457
|
-
}
|
|
458
|
-
})
|
|
459
|
-
);
|
|
460
|
-
return;
|
|
461
|
-
}
|
|
319
|
+
async function setupClaude(projectRoot, contextlineRoot) {
|
|
320
|
+
await createMemorySkeleton(contextlineRoot);
|
|
321
|
+
const claudeDir = path4.join(projectRoot, ".claude");
|
|
322
|
+
const hooksDir = path4.join(claudeDir, "hooks");
|
|
323
|
+
await fs3.mkdir(hooksDir, { recursive: true });
|
|
324
|
+
const hookScriptPath = path4.join(hooksDir, "contextline-hook.mjs");
|
|
325
|
+
await writeHookScript(hookScriptPath);
|
|
326
|
+
const settingsPath = path4.join(claudeDir, "settings.json");
|
|
327
|
+
await upsertClaudeSettings(settingsPath, projectRoot, contextlineRoot);
|
|
328
|
+
return { settingsPath, hookScriptPath };
|
|
462
329
|
}
|
|
463
330
|
|
|
464
|
-
// src/setup.ts
|
|
465
|
-
import { cwd } from "process";
|
|
466
|
-
|
|
467
331
|
// src/host/codex.ts
|
|
468
|
-
import
|
|
469
|
-
import
|
|
470
|
-
import
|
|
471
|
-
function
|
|
472
|
-
return `[mcp_servers.
|
|
332
|
+
import fs4 from "fs/promises";
|
|
333
|
+
import path5 from "path";
|
|
334
|
+
import os2 from "os";
|
|
335
|
+
function filesystemMcpBlock(contextlineRoot) {
|
|
336
|
+
return `[mcp_servers.filesystem]
|
|
473
337
|
type = "stdio"
|
|
474
|
-
command = "
|
|
475
|
-
args = []
|
|
476
|
-
env = { CONTEXTLINE_ROOT = ${JSON.stringify(contextlineRoot)} }
|
|
338
|
+
command = "npx"
|
|
339
|
+
args = ["-y", "@modelcontextprotocol/server-filesystem", ${JSON.stringify(contextlineRoot)}]
|
|
477
340
|
startup_timeout_sec = 10
|
|
478
341
|
tool_timeout_sec = 10
|
|
479
342
|
`;
|
|
480
343
|
}
|
|
481
|
-
async function
|
|
344
|
+
async function upsertFilesystemMcpConfig(configTomlPath, contextlineRoot) {
|
|
482
345
|
let existingConfig = "";
|
|
483
346
|
try {
|
|
484
|
-
existingConfig = await
|
|
347
|
+
existingConfig = await fs4.readFile(configTomlPath, "utf8");
|
|
485
348
|
} catch (error) {
|
|
486
349
|
if (error.code !== "ENOENT") throw error;
|
|
487
350
|
}
|
|
488
|
-
const
|
|
489
|
-
const mcpBlock =
|
|
490
|
-
await
|
|
351
|
+
const withoutFilesystem = existingConfig.replace(/\n?\[mcp_servers\.filesystem\][\s\S]*?(?=\n\[|$)/, "").trim();
|
|
352
|
+
const mcpBlock = filesystemMcpBlock(contextlineRoot);
|
|
353
|
+
await fs4.writeFile(configTomlPath, `${withoutFilesystem ? `${withoutFilesystem}
|
|
491
354
|
|
|
492
355
|
` : ""}${mcpBlock}
|
|
493
356
|
`, "utf8");
|
|
494
357
|
}
|
|
495
|
-
async function
|
|
358
|
+
async function writeHookScript2(scriptPath) {
|
|
496
359
|
const script = `#!/usr/bin/env node
|
|
497
360
|
import { spawnSync } from "node:child_process";
|
|
498
361
|
import fs from "node:fs";
|
|
499
362
|
|
|
500
363
|
const input = fs.readFileSync(0, "utf8");
|
|
501
|
-
const result = spawnSync("contextline", ["hook"
|
|
364
|
+
const result = spawnSync("contextline", ["hook"], {
|
|
502
365
|
input,
|
|
503
366
|
encoding: "utf8",
|
|
504
367
|
shell: process.platform === "win32"
|
|
@@ -508,12 +371,12 @@ if (result.stderr) process.stderr.write(result.stderr);
|
|
|
508
371
|
if (result.stdout) process.stdout.write(result.stdout);
|
|
509
372
|
process.exit(result.status ?? 0);
|
|
510
373
|
`;
|
|
511
|
-
await
|
|
374
|
+
await fs4.writeFile(scriptPath, script, "utf8");
|
|
512
375
|
}
|
|
513
376
|
async function upsertHooksJson(hooksJsonPath, hookScriptPath) {
|
|
514
377
|
let existing = {};
|
|
515
378
|
try {
|
|
516
|
-
const raw = await
|
|
379
|
+
const raw = await fs4.readFile(hooksJsonPath, "utf8");
|
|
517
380
|
existing = JSON.parse(raw);
|
|
518
381
|
} catch {
|
|
519
382
|
}
|
|
@@ -522,29 +385,36 @@ async function upsertHooksJson(hooksJsonPath, hookScriptPath) {
|
|
|
522
385
|
}
|
|
523
386
|
const hooks = existing.hooks;
|
|
524
387
|
const sessionStart = Array.isArray(hooks.SessionStart) ? hooks.SessionStart : [];
|
|
525
|
-
const
|
|
526
|
-
|
|
388
|
+
const userPromptSubmit = Array.isArray(hooks.UserPromptSubmit) ? hooks.UserPromptSubmit : [];
|
|
389
|
+
const containsContextlineHook2 = (entry) => {
|
|
390
|
+
if (!entry || typeof entry !== "object") return false;
|
|
527
391
|
const e = entry;
|
|
528
|
-
return
|
|
529
|
-
}
|
|
392
|
+
return e.hooks?.some((h) => h.command?.includes("contextline")) ?? false;
|
|
393
|
+
};
|
|
394
|
+
const filteredSessionStart = sessionStart.filter((entry) => !containsContextlineHook2(entry));
|
|
395
|
+
const filteredUserPromptSubmit = userPromptSubmit.filter((entry) => !containsContextlineHook2(entry));
|
|
530
396
|
const contextlineEntry = {
|
|
531
397
|
matcher: "startup|resume|clear|compact",
|
|
532
398
|
hooks: [{ type: "command", command: `node ${JSON.stringify(hookScriptPath)}` }]
|
|
533
399
|
};
|
|
534
|
-
|
|
535
|
-
|
|
400
|
+
const contextlinePromptEntry = {
|
|
401
|
+
hooks: [{ type: "command", command: `node ${JSON.stringify(hookScriptPath)}` }]
|
|
402
|
+
};
|
|
403
|
+
hooks.SessionStart = [contextlineEntry, ...filteredSessionStart];
|
|
404
|
+
hooks.UserPromptSubmit = [contextlinePromptEntry, ...filteredUserPromptSubmit];
|
|
405
|
+
await fs4.writeFile(hooksJsonPath, `${JSON.stringify(existing, null, 2)}
|
|
536
406
|
`, "utf8");
|
|
537
407
|
}
|
|
538
408
|
async function detectCodex(projectRoot) {
|
|
539
409
|
const details = [];
|
|
540
410
|
const candidates = [
|
|
541
|
-
|
|
542
|
-
|
|
411
|
+
path5.join(os2.homedir(), ".codex"),
|
|
412
|
+
path5.join(projectRoot, ".codex"),
|
|
543
413
|
process.env.CODEX_HOME
|
|
544
414
|
].filter(Boolean);
|
|
545
415
|
for (const candidate of candidates) {
|
|
546
416
|
try {
|
|
547
|
-
await
|
|
417
|
+
await fs4.access(candidate);
|
|
548
418
|
details.push(`found ${candidate}`);
|
|
549
419
|
} catch {
|
|
550
420
|
}
|
|
@@ -553,160 +423,69 @@ async function detectCodex(projectRoot) {
|
|
|
553
423
|
return { host: "none", confidence: "none", details: [] };
|
|
554
424
|
}
|
|
555
425
|
async function setupCodex(projectRoot, contextlineRoot) {
|
|
556
|
-
await
|
|
557
|
-
const codexDir =
|
|
558
|
-
const hooksDir =
|
|
559
|
-
await
|
|
560
|
-
const hookScriptPath =
|
|
561
|
-
await
|
|
562
|
-
const hooksJsonPath =
|
|
426
|
+
await createMemorySkeleton(contextlineRoot);
|
|
427
|
+
const codexDir = path5.join(projectRoot, ".codex");
|
|
428
|
+
const hooksDir = path5.join(codexDir, "hooks");
|
|
429
|
+
await fs4.mkdir(hooksDir, { recursive: true });
|
|
430
|
+
const hookScriptPath = path5.join(hooksDir, "contextline-hook.mjs");
|
|
431
|
+
await writeHookScript2(hookScriptPath);
|
|
432
|
+
const hooksJsonPath = path5.join(codexDir, "hooks.json");
|
|
563
433
|
await upsertHooksJson(hooksJsonPath, hookScriptPath);
|
|
564
|
-
const configTomlPath =
|
|
565
|
-
await
|
|
566
|
-
|
|
567
|
-
const globalHooksDir = path9.join(globalCodexDir, "hooks");
|
|
568
|
-
await fs9.mkdir(globalHooksDir, { recursive: true });
|
|
569
|
-
const globalHookScriptPath = path9.join(globalHooksDir, "contextline-hook.mjs");
|
|
570
|
-
await writeHookScript(globalHookScriptPath, contextlineRoot);
|
|
571
|
-
const globalHooksJsonPath = path9.join(globalCodexDir, "hooks.json");
|
|
572
|
-
await upsertHooksJson(globalHooksJsonPath, globalHookScriptPath);
|
|
573
|
-
const globalConfigTomlPath = path9.join(globalCodexDir, "config.toml");
|
|
574
|
-
if (path9.resolve(globalConfigTomlPath) !== path9.resolve(configTomlPath)) {
|
|
575
|
-
await upsertContextlineMcpConfig(globalConfigTomlPath, contextlineRoot);
|
|
576
|
-
}
|
|
577
|
-
const agentsPath = path9.join(projectRoot, "AGENTS.md");
|
|
578
|
-
try {
|
|
579
|
-
const existingAgents = await fs9.readFile(agentsPath, "utf8");
|
|
580
|
-
if (existingAgents.includes("# ContextLine Memory")) {
|
|
581
|
-
const cleanedAgents = existingAgents.slice(0, existingAgents.indexOf("# ContextLine Memory")).trim();
|
|
582
|
-
if (cleanedAgents) {
|
|
583
|
-
await fs9.writeFile(agentsPath, `${cleanedAgents}
|
|
584
|
-
`, "utf8");
|
|
585
|
-
} else {
|
|
586
|
-
await fs9.rm(agentsPath, { force: true });
|
|
587
|
-
}
|
|
588
|
-
}
|
|
589
|
-
} catch (error) {
|
|
590
|
-
if (error.code !== "ENOENT") throw error;
|
|
591
|
-
}
|
|
592
|
-
return { hooksJsonPath, hookScriptPath, configTomlPath, globalHooksJsonPath, globalHookScriptPath, globalConfigTomlPath };
|
|
434
|
+
const configTomlPath = path5.join(codexDir, "config.toml");
|
|
435
|
+
await upsertFilesystemMcpConfig(configTomlPath, contextlineRoot);
|
|
436
|
+
return { hooksJsonPath, hookScriptPath, configTomlPath };
|
|
593
437
|
}
|
|
594
438
|
|
|
595
439
|
// src/setup.ts
|
|
596
440
|
async function runSetup(options = {}) {
|
|
597
441
|
const projectRoot = cwd();
|
|
598
442
|
const root = getMemoryRoot();
|
|
599
|
-
const
|
|
600
|
-
|
|
443
|
+
const requestedHostInput = (options.host ?? "auto").toLowerCase();
|
|
444
|
+
const requestedHost = requestedHostInput === "claude-code" || requestedHostInput === "claudecode" ? "claude" : requestedHostInput;
|
|
445
|
+
let hosts = [];
|
|
601
446
|
let detectionDetails = [];
|
|
602
447
|
if (requestedHost === "auto") {
|
|
603
|
-
const
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
448
|
+
const [codex, claude] = await Promise.all([detectCodex(projectRoot), detectClaude(projectRoot)]);
|
|
449
|
+
if (codex.host === "codex") hosts.push("codex");
|
|
450
|
+
if (claude.host === "claude") hosts.push("claude");
|
|
451
|
+
detectionDetails = [
|
|
452
|
+
...codex.details.map((detail) => `codex: ${detail}`),
|
|
453
|
+
...claude.details.map((detail) => `claude: ${detail}`)
|
|
454
|
+
];
|
|
455
|
+
} else if (requestedHost === "codex" || requestedHost === "claude") {
|
|
456
|
+
hosts = [requestedHost];
|
|
457
|
+
} else if (requestedHost !== "none") {
|
|
458
|
+
throw new Error(`Unsupported host adapter: ${options.host}`);
|
|
459
|
+
}
|
|
460
|
+
const hostSetup = {};
|
|
461
|
+
for (const host of hosts) {
|
|
462
|
+
if (host === "codex") {
|
|
463
|
+
hostSetup.codex = await setupCodex(projectRoot, root);
|
|
464
|
+
} else if (host === "claude") {
|
|
465
|
+
hostSetup.claude = await setupClaude(projectRoot, root);
|
|
466
|
+
}
|
|
611
467
|
}
|
|
612
|
-
const validation = await validateTopology2(root);
|
|
613
468
|
return {
|
|
614
|
-
ok:
|
|
469
|
+
ok: true,
|
|
615
470
|
root,
|
|
616
|
-
host,
|
|
471
|
+
host: hosts.length ? hosts.join(",") : "none",
|
|
472
|
+
hosts,
|
|
617
473
|
detectionDetails,
|
|
618
|
-
hostSetup
|
|
619
|
-
validation,
|
|
620
|
-
mcpConfig: {
|
|
621
|
-
mcpServers: {
|
|
622
|
-
contextline: {
|
|
623
|
-
command: "contextline-mcp",
|
|
624
|
-
args: [],
|
|
625
|
-
env: {
|
|
626
|
-
CONTEXTLINE_ROOT: root
|
|
627
|
-
}
|
|
628
|
-
}
|
|
629
|
-
}
|
|
630
|
-
}
|
|
474
|
+
hostSetup
|
|
631
475
|
};
|
|
632
476
|
}
|
|
633
477
|
|
|
634
|
-
// src/tools/updateDeepPointer.ts
|
|
635
|
-
import fs10 from "fs/promises";
|
|
636
|
-
import path10 from "path";
|
|
637
|
-
async function updateDeepPointer(file, factText, detailFile, rootInput) {
|
|
638
|
-
const root = resolveRoot(rootInput);
|
|
639
|
-
await initContextLine(root);
|
|
640
|
-
detailPath(root, detailFile);
|
|
641
|
-
return withLock(root, async () => {
|
|
642
|
-
const filePath = deepPath(root, file);
|
|
643
|
-
let content = "";
|
|
644
|
-
try {
|
|
645
|
-
content = await fs10.readFile(filePath, "utf8");
|
|
646
|
-
} catch (error) {
|
|
647
|
-
if (error.code !== "ENOENT") {
|
|
648
|
-
throw error;
|
|
649
|
-
}
|
|
650
|
-
content = `# ${path10.basename(file, ".md")}
|
|
651
|
-
|
|
652
|
-
`;
|
|
653
|
-
}
|
|
654
|
-
const updated_line = ensureWikiLink(factText, detailFile);
|
|
655
|
-
const next = upsertLineByPrefix(ensureTrailingNewline(content), factText.trim(), updated_line);
|
|
656
|
-
await atomicWriteFile(filePath, next);
|
|
657
|
-
return { ok: true, file, updated_line, display: `ContextLine L2 updated
|
|
658
|
-
- File: ${file}` };
|
|
659
|
-
});
|
|
660
|
-
}
|
|
661
|
-
|
|
662
|
-
// src/tools/updateIndexPointer.ts
|
|
663
|
-
import fs11 from "fs/promises";
|
|
664
|
-
async function updateIndexPointer(indexLine, files, rootInput) {
|
|
665
|
-
if (files.some((file) => file.startsWith("L3_"))) {
|
|
666
|
-
throw new Error("L1 quick memory pointers may only reference L2 deep memory files.");
|
|
667
|
-
}
|
|
668
|
-
const root = resolveRoot(rootInput);
|
|
669
|
-
await initContextLine(root);
|
|
670
|
-
for (const file of files) {
|
|
671
|
-
deepPath(root, file);
|
|
672
|
-
}
|
|
673
|
-
return withLock(root, async () => {
|
|
674
|
-
const filePath = l1Path(root);
|
|
675
|
-
const content = await fs11.readFile(filePath, "utf8");
|
|
676
|
-
const updated_line = files.reduce((line, file) => ensureWikiLink(line, file), indexLine);
|
|
677
|
-
const next = upsertLineByPrefix(ensureTrailingNewline(content), indexLine.trim(), updated_line);
|
|
678
|
-
await atomicWriteFile(filePath, next);
|
|
679
|
-
return { ok: true, updated_line, display: "ContextLine L1 updated" };
|
|
680
|
-
});
|
|
681
|
-
}
|
|
682
|
-
|
|
683
478
|
// src/cli.ts
|
|
684
|
-
function print(data) {
|
|
685
|
-
console.log(JSON.stringify(data, null, 2));
|
|
686
|
-
}
|
|
687
479
|
var program = new Command();
|
|
688
|
-
program.name("contextline").description("
|
|
689
|
-
program.command("
|
|
690
|
-
program.command("hydrate").argument("[root]").description("Read L1_Quick.md as the bounded quick memory cache.").action(async (root) => print(await hydrate(root)));
|
|
691
|
-
program.command("validate").argument("[root]").description("Validate ContextLine topology.").action(async (root) => {
|
|
692
|
-
const result = await validateTopology2(root);
|
|
693
|
-
print(result);
|
|
694
|
-
if (!result.ok) process.exitCode = 1;
|
|
695
|
-
});
|
|
696
|
-
program.command("inspect").argument("[root]").description("Inspect ContextLine folder counts and paths.").action(async (root) => print(await inspect(root)));
|
|
697
|
-
program.command("append").argument("<text>").argument("[root]").option("--date <date>", "Date in YYYY-MM-DD format").description("Append an immutable L3 detail fragment.").action(async (text, root, options) => print(await appendFragment(text, options.date, root)));
|
|
698
|
-
program.command("update-deep").argument("<file>").argument("<fact-text>").argument("<detail-file>").argument("[root]").description("Update an L2 deep memory line with a pointer to an L3 detail file.").action(async (file, factText, detailFile, root) => print(await updateDeepPointer(file, factText, detailFile, root)));
|
|
699
|
-
program.command("update-index").argument("<index-line>").argument("<files...>").option("--root <root>", "Memory folder path").description("Update an L1 quick memory line with pointers to L2 deep memory files.").action(async (indexLine, files, options) => print(await updateIndexPointer(indexLine, files, options.root)));
|
|
700
|
-
program.command("setup").option("--host <host>", "Host adapter: auto, codex, none", "auto").option("--verbose", "Print full JSON result").description("Initialize memory and configure supported host integrations.").action(async (options) => {
|
|
480
|
+
program.name("contextline").description("Persistent memory for AI agents.").version("0.2.0");
|
|
481
|
+
program.command("setup").option("--host <host>", "Host adapter: auto, codex, claude, claude-code, none", "auto").option("--verbose", "Print full JSON result").description("Create memory folder and configure host integrations.").action(async (options) => {
|
|
701
482
|
const result = await runSetup(options);
|
|
702
483
|
if (options.verbose) {
|
|
703
|
-
|
|
484
|
+
console.log(JSON.stringify(result, null, 2));
|
|
704
485
|
} else {
|
|
705
|
-
process.stdout.write(
|
|
706
|
-
` : `Setup failed.
|
|
486
|
+
process.stdout.write(`ContextLine ready at ${result.root}
|
|
707
487
|
`);
|
|
708
488
|
}
|
|
709
|
-
if (!result.ok) process.exitCode = 1;
|
|
710
489
|
});
|
|
711
490
|
program.command("hook").option("--root <root>", "Memory folder path").description("Run ContextLine host hook. Reads hook JSON from stdin.").action(async (options) => runHook(options.root));
|
|
712
491
|
program.parseAsync(process.argv).catch((error) => {
|