codehere 0.6.0-alpha.1 → 0.6.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/dist/index.js CHANGED
@@ -480,10 +480,12 @@ program
480
480
  .option('-p, --port <number>', 'Port to listen on', '3773')
481
481
  .option('--host <host>', 'Host to bind to', '127.0.0.1')
482
482
  .option('--no-open', "Don't auto-open the browser")
483
+ .option('--cloud', 'Enable cloud sync — push session state to codehere.uk/app for remote monitoring')
483
484
  .action(async (options) => {
484
485
  const port = parseInt(options?.port ?? '3773', 10);
485
486
  const host = options?.host ?? '127.0.0.1';
486
487
  const shouldOpen = options?.open !== false;
488
+ const cloud = options?.cloud === true;
487
489
  const url = `http://${host}:${port}/`;
488
490
  const openBrowser = async () => {
489
491
  if (!shouldOpen)
@@ -551,11 +553,23 @@ program
551
553
  console.log(colors.bold('Tasks:') + ' ' + url);
552
554
  console.log(colors.bold('History:') + ' ' + `http://${host}:${port}/history`);
553
555
  console.log(colors.bold('MCP:') + ' ' + `http://${host}:${port}/mcp`);
556
+ if (cloud) {
557
+ console.log(colors.bold('Cloud:') + ' ' + 'https://codehere.uk/app');
558
+ }
554
559
  newline();
555
560
  console.log(colors.dim(' Press Ctrl+C to stop'));
556
561
  newline();
562
+ // Start cloud sync if enabled
563
+ if (cloud) {
564
+ const { startCloudSync } = await import('./infrastructure/protocols/cloud-sync.js');
565
+ startCloudSync();
566
+ }
557
567
  await openBrowser();
558
568
  process.on('SIGINT', () => {
569
+ if (cloud) {
570
+ const { stopCloudSync } = require('./infrastructure/protocols/cloud-sync.js');
571
+ stopCloudSync();
572
+ }
559
573
  server.close();
560
574
  console.log(colors.dim('\ncodehere stopped'));
561
575
  process.exit(0);
@@ -0,0 +1,143 @@
1
+ /**
2
+ * context-handoff — structured cross-session context handoff protocol (P2).
3
+ *
4
+ * Bridges P0's auto-failover trigger with P1's graphify-ts context packs.
5
+ * When a session fails over (or a new session spawns), the handoff loader
6
+ * finds the most relevant context pack from recent session logs and injects
7
+ * it as a system hint so the replacement agent inherits continuity.
8
+ *
9
+ * Protocol:
10
+ * 1. loadHandoffContext(cwd, { fromSessionId?, sessionId?, maxAgeMs? })
11
+ * — scans .codehere/context-packs.jsonl for the most relevant pack
12
+ * 2. enrichPromptWithHandoff(prompt, cwd, handoff)
13
+ * — merges the handoff context into a prompt string
14
+ * 3. session handoff is automatic during failover (wired in session-manager)
15
+ *
16
+ * Handoff format (injected as system hint):
17
+ * [handoff: previous agent=<agent>, completed=<status>, pack=<packId>]
18
+ * Previous work summary: <pack.payloadPreview or compiled summary>
19
+ * ---
20
+ * <original prompt>
21
+ */
22
+ import { existsSync, readFileSync } from 'fs';
23
+ import { join } from 'path';
24
+ // ── Constants ──────────────────────────────────────────────────────────────────
25
+ const CONTEXT_PACKS_DIR = '.codehere';
26
+ const CONTEXT_PACKS_FILE = 'context-packs.jsonl';
27
+ const DEFAULT_MAX_AGE_MS = 24 * 60 * 60 * 1000; // 24 hours
28
+ const DEFAULT_MIN_NODES = 5;
29
+ // ── Session log scanning ───────────────────────────────────────────────────────
30
+ /**
31
+ * Load all recent context packs from disk, sorted by timestamp (newest first).
32
+ */
33
+ function loadAllPacks(cwd) {
34
+ const packsFile = join(cwd, CONTEXT_PACKS_DIR, CONTEXT_PACKS_FILE);
35
+ if (!existsSync(packsFile)) {
36
+ return [];
37
+ }
38
+ try {
39
+ const content = readFileSync(packsFile, 'utf-8');
40
+ const lines = content.split('\n').filter(Boolean);
41
+ return lines.map((line) => JSON.parse(line)).reverse();
42
+ }
43
+ catch {
44
+ return [];
45
+ }
46
+ }
47
+ /**
48
+ * Find the most relevant handoff context from previous sessions.
49
+ *
50
+ * Priority:
51
+ * 1. Same workspace, same prompt keywords (most recent pack)
52
+ * 2. Same workspace, any pack (most recent)
53
+ * 3. Fallback: no handoff
54
+ */
55
+ export function loadHandoffContext(cwd, { fromSessionId, maxAgeMs = DEFAULT_MAX_AGE_MS, minNodes = DEFAULT_MIN_NODES } = {}) {
56
+ const now = Date.now();
57
+ const packs = loadAllPacks(cwd);
58
+ // Filter by age
59
+ const recent = packs.filter((p) => {
60
+ const ts = p.timestamp;
61
+ return ts && (now - ts) < maxAgeMs;
62
+ });
63
+ // Prefer packs with enough graph structure to be useful
64
+ const structured = recent.filter((p) => {
65
+ const nodes = p.nodeCount ?? 0;
66
+ return nodes >= minNodes;
67
+ });
68
+ if (structured.length === 0)
69
+ return null;
70
+ const last = structured[0];
71
+ return {
72
+ packId: String(last.packId ?? ''),
73
+ agent: String(last.agent ?? 'unknown'),
74
+ prompt: String(last.prompt ?? ''),
75
+ status: String(last.status ?? 'unknown'),
76
+ timestamp: last.timestamp,
77
+ nodeCount: last.nodeCount,
78
+ edgeCount: last.edgeCount,
79
+ task: String(last.task ?? 'explain'),
80
+ payloadPreview: String(last.payloadPreview ?? ''),
81
+ };
82
+ }
83
+ // ── Prompt enrichment ──────────────────────────────────────────────────────────
84
+ /**
85
+ * Inject handoff context into a prompt.
86
+ *
87
+ * Returns the enriched prompt string with the handoff as a system hint
88
+ * prepended before the original prompt. Returns the original prompt
89
+ * if no handoff is available.
90
+ */
91
+ export function enrichPromptWithHandoff(prompt, handoff) {
92
+ if (!handoff)
93
+ return prompt;
94
+ const handoffLine = `[handoff: agent=${handoff.agent}, pack=${handoff.packId}, ${handoff.nodeCount} nodes, ${handoff.edgeCount} edges]`;
95
+ const preview = handoff.payloadPreview ? ` ${handoff.payloadPreview.slice(0, 800)}` : '';
96
+ return [
97
+ `[Previous session context — ${handoffLine}]`,
98
+ `This workspace has recent structural context from a prior session.`,
99
+ `For details, refer to the knowledge graph at the workspace root.`,
100
+ `---`,
101
+ prompt,
102
+ ].join('\n');
103
+ }
104
+ // ── Failover handoff (P2 integration point) ────────────────────────────────────
105
+ /**
106
+ * Build a handoff context for a failover target.
107
+ *
108
+ * When session A fails and session B auto-spawns as replacement,
109
+ * this loads the most recent pack from the same workspace and
110
+ * merges it into the prompt so B has continuity with A's work.
111
+ */
112
+ export function buildFailoverHandoff(cwd, { failedAgent, failedPrompt }) {
113
+ // Load most recent pack from workspace
114
+ const pack = loadHandoffContext(cwd, { maxAgeMs: DEFAULT_MAX_AGE_MS, minNodes: 3 });
115
+ if (!pack)
116
+ return null;
117
+ // Enrich the prompt with the handoff
118
+ const enrichedPrompt = enrichPromptWithHandoff(failedPrompt, {
119
+ ...pack,
120
+ agent: failedAgent,
121
+ status: 'failed',
122
+ });
123
+ return {
124
+ ...pack,
125
+ agent: failedAgent,
126
+ prompt: enrichedPrompt,
127
+ status: 'failed',
128
+ };
129
+ }
130
+ // ── Handoff summary (for UI / API) ─────────────────────────────────────────────
131
+ /**
132
+ * Generate a human-readable handoff summary for the dashboard.
133
+ */
134
+ export function formatHandoffSummary(handoff) {
135
+ const ageMs = Date.now() - handoff.timestamp;
136
+ const ageStr = ageMs < 60_000
137
+ ? `${Math.round(ageMs / 1000)}s ago`
138
+ : ageMs < 3600_000
139
+ ? `${Math.round(ageMs / 60_000)}m ago`
140
+ : `${Math.round(ageMs / 3600_000)}h ago`;
141
+ return `Previous session (${handoff.agent}) completed ${ageStr}. ${handoff.nodeCount} nodes, ${handoff.edgeCount} edges in workspace graph.`;
142
+ }
143
+ //# sourceMappingURL=context-handoff.js.map
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Failover config loader — v0.6.x rate-limit fail-over wedge (Slice C).
3
+ *
4
+ * Reads `failoverChain` from `~/.codehere/config.json` and validates it
5
+ * against the known agent ids. Returns the validated chain or undefined
6
+ * if the user has not configured one (caller falls back to the default).
7
+ *
8
+ * Config shape (~/.codehere/config.json):
9
+ * {
10
+ * "version": "0.6.0",
11
+ * "failover": {
12
+ * "chain": ["codex", "claude-code", "aider"]
13
+ * }
14
+ * }
15
+ *
16
+ * If `failover.chain` is missing, empty, or invalid, returns undefined
17
+ * so the caller uses DEFAULT_FAILOVER_CHAIN from failover-policy.ts.
18
+ *
19
+ * Slice C scope: config read + validation. Slice D wires it into
20
+ * SessionManager so the resolved chain flows into the resolver.
21
+ */
22
+ import { readFileSync, existsSync } from 'fs';
23
+ import { homedir } from 'os';
24
+ import { join } from 'path';
25
+ import { validateFailoverChain } from './failover-policy.js';
26
+ const GLOBAL_CONFIG_PATH = join(homedir(), '.codehere', 'config.json');
27
+ /**
28
+ * Load failover configuration from ~/.codehere/config.json.
29
+ * Returns undefined if the config file does not exist or has no failover section.
30
+ *
31
+ * Safe to call at any time — no side effects, no caching, no I/O errors
32
+ * propagated (returns undefined on any read failure).
33
+ */
34
+ export function loadFailoverConfig() {
35
+ if (!existsSync(GLOBAL_CONFIG_PATH))
36
+ return undefined;
37
+ try {
38
+ const raw = JSON.parse(readFileSync(GLOBAL_CONFIG_PATH, 'utf-8'));
39
+ if (!raw || typeof raw !== 'object')
40
+ return undefined;
41
+ const failover = raw.failover;
42
+ if (!failover || typeof failover !== 'object')
43
+ return undefined;
44
+ const config = {};
45
+ // Validate chain if present
46
+ if (Array.isArray(failover.chain)) {
47
+ try {
48
+ config.chain = validateFailoverChain(failover.chain);
49
+ }
50
+ catch {
51
+ // Invalid chain — skip it, caller uses default
52
+ config.chain = undefined;
53
+ }
54
+ }
55
+ // autoFailover — boolean, defaults to false
56
+ if (typeof failover.autoFailover === 'boolean') {
57
+ config.autoFailover = failover.autoFailover;
58
+ }
59
+ return config;
60
+ }
61
+ catch {
62
+ // Config file exists but is malformed — return undefined so caller
63
+ // falls back to defaults without crashing.
64
+ return undefined;
65
+ }
66
+ }
67
+ /**
68
+ * Get the resolved failover chain: user-configured if present, else default.
69
+ * Pure function — no I/O. Caller loads config and passes it in.
70
+ */
71
+ export function resolveChain(userChain) {
72
+ if (userChain && userChain.length > 0)
73
+ return userChain;
74
+ // Re-export the default from failover-policy
75
+ return ['claude-code', 'codex', 'aider', 'opencode', 'gemini'];
76
+ }
77
+ //# sourceMappingURL=failover-config.js.map
@@ -0,0 +1,256 @@
1
+ /**
2
+ * graphify-context — shared structural memory layer (P1).
3
+ *
4
+ * Wraps `graphify-ts` CLI to compile compact context packs for agent prompts.
5
+ * Every spawn can optionally get a graphify context pack that replaces
6
+ * raw file reads + unstructured JSONL scans.
7
+ *
8
+ * All functions are synchronous (execFileSync) to slot into the sync
9
+ * spawn() call chain without breaking the API.
10
+ *
11
+ * P1 integration points:
12
+ * - Session spawn: inject context pack into prompt prefix
13
+ * - Failover handoff (P2): replacement agent inherits the same pack
14
+ * - S7 compaction (future): use graph to prune stale session logs
15
+ * - PM-brain routing (future): community_overview for agent selection
16
+ */
17
+ import { execFileSync } from 'child_process';
18
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
19
+ import { join } from 'path';
20
+ import { randomUUID } from 'crypto';
21
+ // ── Constants ──────────────────────────────────────────────────────────────────
22
+ const GRAPHIFY_OUT = 'graphify-out';
23
+ const GRAPH_JSON = 'graph.json';
24
+ const CONTEXT_PACKS_DIR = '.codehere';
25
+ const CONTEXT_PACKS_FILE = 'context-packs.jsonl';
26
+ // ── Helpers ────────────────────────────────────────────────────────────────────
27
+ function getGraphPath(cwd) {
28
+ return join(cwd, GRAPHIFY_OUT, GRAPH_JSON);
29
+ }
30
+ function getGraphStats(cwd) {
31
+ const graphPath = getGraphPath(cwd);
32
+ try {
33
+ if (!existsSync(graphPath)) {
34
+ return { nodeCount: 0, edgeCount: 0 };
35
+ }
36
+ const graphJson = JSON.parse(readFileSync(graphPath, 'utf-8'));
37
+ return {
38
+ nodeCount: graphJson.nodes?.length ?? 0,
39
+ edgeCount: graphJson.edges?.length ?? 0,
40
+ };
41
+ }
42
+ catch {
43
+ return { nodeCount: 0, edgeCount: 0 };
44
+ }
45
+ }
46
+ // ── Graph management (sync) ────────────────────────────────────────────────────
47
+ /**
48
+ * Build or update the knowledge graph for a workspace directory.
49
+ * Writes to <cwd>/graphify-out/graph.json (graphify-ts default).
50
+ * Synchronous — runs synchronously so it can be called from spawn().
51
+ */
52
+ export function buildGraphSync(cwd, { incremental = true } = {}) {
53
+ const startTime = Date.now();
54
+ const graphPath = getGraphPath(cwd);
55
+ const args = ['generate', cwd];
56
+ if (incremental && existsSync(graphPath)) {
57
+ args.push('--update');
58
+ }
59
+ try {
60
+ execFileSync('npx', ['graphify-ts', ...args], {
61
+ cwd,
62
+ stdio: 'ignore',
63
+ timeout: 120_000,
64
+ });
65
+ }
66
+ catch {
67
+ // Non-zero exit is fine — graphify-ts returns 1 when no files changed.
68
+ // Also swallow ENOENT (graphify-ts not installed) — degrade gracefully.
69
+ }
70
+ return { elapsedMs: Date.now() - startTime };
71
+ }
72
+ // ── Context pack compilation (sync) ────────────────────────────────────────────
73
+ /**
74
+ * Compile a compact context pack for a prompt + workspace.
75
+ * Synchronous — calls graphify-ts pack and returns the payload.
76
+ */
77
+ export function compileContextPackSync(prompt, cwd, { task = 'explain', budget = 3000, } = {}) {
78
+ const startTime = Date.now();
79
+ const graphPath = getGraphPath(cwd);
80
+ // Build graph if it doesn't exist yet
81
+ if (!existsSync(graphPath)) {
82
+ buildGraphSync(cwd, { incremental: false });
83
+ }
84
+ // Degrade gracefully if graph still doesn't exist
85
+ if (!existsSync(graphPath)) {
86
+ return null;
87
+ }
88
+ try {
89
+ const output = execFileSync('npx', [
90
+ 'graphify-ts', 'pack', prompt,
91
+ '--graph', graphPath,
92
+ '--task', task,
93
+ '--budget', String(budget),
94
+ ], {
95
+ cwd,
96
+ encoding: 'utf-8',
97
+ maxBuffer: 512 * 1024,
98
+ timeout: 30_000,
99
+ });
100
+ const { nodeCount, edgeCount } = getGraphStats(cwd);
101
+ return {
102
+ graphPath,
103
+ payload: output.trim(),
104
+ budget,
105
+ task,
106
+ nodeCount,
107
+ edgeCount,
108
+ elapsedMs: Date.now() - startTime,
109
+ packId: randomUUID(),
110
+ };
111
+ }
112
+ catch {
113
+ // graphify-ts may return non-zero or hit timeout — degrade gracefully.
114
+ return null;
115
+ }
116
+ }
117
+ /**
118
+ * Compile a context pack from a session's final state (prompt + artifacts + steps).
119
+ * Used for P1 session-exit persistence and P2 handoff enrichment.
120
+ */
121
+ export function compileSessionPackSync(cwd, { prompt, artifacts, steps, agent }) {
122
+ // Combine prompt + artifacts + steps into a rich prompt for the pack
123
+ const sessionContext = [
124
+ prompt,
125
+ agent ? `Agent: ${agent}` : undefined,
126
+ artifacts ? `Artifacts:\n${artifacts}` : undefined,
127
+ steps ? `Steps taken:\n${steps}` : undefined,
128
+ ].filter(Boolean).join('\n\n');
129
+ return compileContextPackSync(sessionContext, cwd, { task: 'impact', budget: 2500 });
130
+ }
131
+ // ── Context pack persistence ───────────────────────────────────────────────────
132
+ /**
133
+ * Persist a context pack to disk for cross-session handoff (P2).
134
+ * Returns the path to the packs file.
135
+ */
136
+ export function persistContextPack(pack, cwd) {
137
+ const packsDir = join(cwd, CONTEXT_PACKS_DIR);
138
+ if (!existsSync(packsDir)) {
139
+ mkdirSync(packsDir, { recursive: true });
140
+ }
141
+ const packsFile = join(packsDir, CONTEXT_PACKS_FILE);
142
+ const entry = {
143
+ packId: pack.packId,
144
+ timestamp: Date.now(),
145
+ task: pack.task,
146
+ nodeCount: pack.nodeCount,
147
+ edgeCount: pack.edgeCount,
148
+ graphPath: pack.graphPath,
149
+ payloadPreview: pack.payload.slice(0, 500), // truncate for log
150
+ payloadLength: pack.payload.length,
151
+ };
152
+ writeFileSync(packsFile, JSON.stringify(entry) + '\n', { flag: 'a' });
153
+ return packsFile;
154
+ }
155
+ /**
156
+ * Load the most recent context pack from disk (for P2 handoff).
157
+ */
158
+ export function loadRecentContextPack(cwd) {
159
+ const packsFile = join(cwd, CONTEXT_PACKS_DIR, CONTEXT_PACKS_FILE);
160
+ if (!existsSync(packsFile)) {
161
+ return null;
162
+ }
163
+ try {
164
+ const lines = readFileSync(packsFile, 'utf-8').split('\n').filter(Boolean);
165
+ if (lines.length === 0)
166
+ return null;
167
+ const lastEntry = JSON.parse(lines[lines.length - 1]);
168
+ return {
169
+ packId: lastEntry.packId,
170
+ graphPath: lastEntry.graphPath,
171
+ payload: '', // payload is truncated in disk storage
172
+ budget: 3000,
173
+ task: lastEntry.task,
174
+ nodeCount: lastEntry.nodeCount,
175
+ edgeCount: lastEntry.edgeCount,
176
+ elapsedMs: 0,
177
+ };
178
+ }
179
+ catch {
180
+ return null;
181
+ }
182
+ }
183
+ // ── Prompt enrichment (sync) ───────────────────────────────────────────────────
184
+ /**
185
+ * Prepend a context pack to a prompt if graphify-ts is available.
186
+ *
187
+ * Synchronous — can be called directly from spawn().
188
+ * Returns the original prompt if graphify-ts fails or is unavailable.
189
+ *
190
+ * Respects CODEHERE_GRAPHIFY env var:
191
+ * - unset or "auto" (default): compile on spawn if graph doesn't exist
192
+ * - "force": always compile, even if pack exists
193
+ * - "off": skip entirely
194
+ */
195
+ export function enrichPromptWithPackSync(prompt, cwd, { force = false } = {}) {
196
+ const mode = process.env.CODEHERE_GRAPHIFY ?? 'auto';
197
+ if (mode === 'off')
198
+ return prompt;
199
+ // Check for existing pack to avoid recompiling on every spawn
200
+ const existingPack = loadRecentContextPack(cwd);
201
+ const graphPath = getGraphPath(cwd);
202
+ const graphExists = existsSync(graphPath);
203
+ if (!force && existingPack && graphExists) {
204
+ // Reuse existing pack — just enrich the prompt with a compact reference
205
+ const reference = `[graphify-ts context pack: ${existingPack.nodeCount} nodes, ${existingPack.edgeCount} edges, pack=${existingPack.packId}]`;
206
+ return `[graphify context: ${reference} — agent should call retrieve() for full pack]\n\n${prompt}`;
207
+ }
208
+ // Compile a fresh pack
209
+ const pack = compileContextPackSync(prompt, cwd, { task: 'explain', budget: 2500 });
210
+ if (!pack) {
211
+ return prompt;
212
+ }
213
+ // Persist for P2 handoff
214
+ persistContextPack(pack, cwd);
215
+ // Prepend context pack as a system hint so the agent sees it first
216
+ return [
217
+ `[graphify-ts context pack — ${pack.nodeCount} nodes, ${pack.edgeCount} edges, ${pack.elapsedMs}ms]`,
218
+ pack.payload,
219
+ '---',
220
+ prompt,
221
+ ].join('\n\n');
222
+ }
223
+ // ── Graph querying (sync) ──────────────────────────────────────────────────────
224
+ /**
225
+ * Query the knowledge graph for a question.
226
+ * Useful for PM-brain routing and structured context handoff (P2).
227
+ */
228
+ export function queryGraphSync(question, cwd, { maxTokens = 2000 } = {}) {
229
+ const startTime = Date.now();
230
+ const graphPath = getGraphPath(cwd);
231
+ if (!existsSync(graphPath)) {
232
+ return null;
233
+ }
234
+ try {
235
+ const output = execFileSync('npx', [
236
+ 'graphify-ts', 'query', question,
237
+ '--graph', graphPath,
238
+ '--budget', String(maxTokens),
239
+ ], {
240
+ cwd,
241
+ encoding: 'utf-8',
242
+ maxBuffer: 256 * 1024,
243
+ timeout: 15_000,
244
+ });
245
+ return {
246
+ question,
247
+ answer: output.trim(),
248
+ elapsedMs: Date.now() - startTime,
249
+ graphPath,
250
+ };
251
+ }
252
+ catch {
253
+ return null;
254
+ }
255
+ }
256
+ //# sourceMappingURL=graphify-context.js.map