openlore 2.0.9 → 2.0.11

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.
Files changed (32) hide show
  1. package/README.md +12 -0
  2. package/dist/cli/commands/mcp.d.ts.map +1 -1
  3. package/dist/cli/commands/mcp.js +77 -233
  4. package/dist/cli/commands/mcp.js.map +1 -1
  5. package/dist/cli/commands/orient.d.ts.map +1 -1
  6. package/dist/cli/commands/orient.js +17 -0
  7. package/dist/cli/commands/orient.js.map +1 -1
  8. package/dist/cli/commands/serve.d.ts +49 -0
  9. package/dist/cli/commands/serve.d.ts.map +1 -0
  10. package/dist/cli/commands/serve.js +362 -0
  11. package/dist/cli/commands/serve.js.map +1 -0
  12. package/dist/cli/commands/setup.d.ts.map +1 -1
  13. package/dist/cli/commands/setup.js +23 -7
  14. package/dist/cli/commands/setup.js.map +1 -1
  15. package/dist/cli/index.js +2 -0
  16. package/dist/cli/index.js.map +1 -1
  17. package/dist/cli/install/templates/agent-instructions.md +1 -3
  18. package/dist/core/services/mcp-watcher.d.ts +9 -0
  19. package/dist/core/services/mcp-watcher.d.ts.map +1 -1
  20. package/dist/core/services/mcp-watcher.js +10 -0
  21. package/dist/core/services/mcp-watcher.js.map +1 -1
  22. package/dist/core/services/serve-client.d.ts +40 -0
  23. package/dist/core/services/serve-client.d.ts.map +1 -0
  24. package/dist/core/services/serve-client.js +115 -0
  25. package/dist/core/services/serve-client.js.map +1 -0
  26. package/dist/core/services/tool-dispatch.d.ts +28 -0
  27. package/dist/core/services/tool-dispatch.d.ts.map +1 -0
  28. package/dist/core/services/tool-dispatch.js +246 -0
  29. package/dist/core/services/tool-dispatch.js.map +1 -0
  30. package/examples/pi/README.md +70 -0
  31. package/examples/pi/openlore.ts +358 -0
  32. package/package.json +1 -1
@@ -0,0 +1,358 @@
1
+ /**
2
+ * openlore.ts — Pi extension (pi.dev)
3
+ *
4
+ * Brings openlore's deterministic structural context into Pi for local models
5
+ * (Qwen, Gemma, …) without MCP. It talks to a warm `openlore serve` HTTP daemon
6
+ * over loopback, so:
7
+ * • tool calls hit warm caches (orient ~8ms vs ~100ms cold),
8
+ * • the daemon's watcher keeps analysis continuously fresh between commits.
9
+ *
10
+ * Two halves:
11
+ * C — context injection (before_agent_start): the model starts grounded with
12
+ * the architecture digest + spec index + a task-specific orient, so weak
13
+ * tool-callers benefit even if they never call a tool.
14
+ * B — native tools (registerTool): the navigation surface for on-demand
15
+ * "how does X reach Y" — each shells to the daemon via fetch.
16
+ *
17
+ * Install: copy to ~/.pi/agent/extensions/openlore.ts (global) or
18
+ * <project>/.pi/extensions/openlore.ts, or run `openlore setup --tools pi`.
19
+ *
20
+ * Requires `openlore` on PATH and `openlore analyze` to have been run once.
21
+ *
22
+ * Imports verified against pi 0.78 (`Type` from typebox, `StringEnum` from
23
+ * @earendil-works/pi-ai, extension types from @earendil-works/pi-coding-agent).
24
+ */
25
+
26
+ import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent';
27
+ import { StringEnum } from '@earendil-works/pi-ai';
28
+ import { Type } from 'typebox';
29
+
30
+ import { spawn } from 'node:child_process';
31
+ import { readFile } from 'node:fs/promises';
32
+ import { join } from 'node:path';
33
+
34
+ /** Trim text to a max length with a marker — keeps small-model context lean. */
35
+ function truncate(s: string, max: number): string {
36
+ return s.length <= max ? s : s.slice(0, max) + `\n… (truncated, ${s.length - max} more chars)`;
37
+ }
38
+
39
+ // ── Daemon discovery + lifecycle ─────────────────────────────────────────────
40
+
41
+ interface ServeDescriptor {
42
+ port: number;
43
+ pid: number;
44
+ host: string;
45
+ token?: string;
46
+ version: string;
47
+ }
48
+
49
+ interface Daemon {
50
+ baseUrl: string;
51
+ token?: string;
52
+ }
53
+
54
+ const HEALTH_TIMEOUT_MS = 8000;
55
+ const HEALTH_POLL_MS = 150;
56
+ const RESULT_MAX = 50_000; // truncate tool output to keep small-model context lean
57
+
58
+ const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));
59
+
60
+ /** Read <cwd>/.openlore/serve.json if a daemon previously announced itself. */
61
+ async function readDescriptor(cwd: string): Promise<ServeDescriptor | null> {
62
+ try {
63
+ const raw = await readFile(join(cwd, '.openlore', 'serve.json'), 'utf-8');
64
+ return JSON.parse(raw) as ServeDescriptor;
65
+ } catch {
66
+ return null;
67
+ }
68
+ }
69
+
70
+ /**
71
+ * GET /health — confirms a descriptor points at a live openlore daemon, not a
72
+ * stale serve.json or a recycled port now owned by an unrelated server. Checks
73
+ * the `ok: true` response shape, not just a 200.
74
+ */
75
+ async function health(desc: ServeDescriptor): Promise<boolean> {
76
+ try {
77
+ const res = await fetch(`http://${desc.host}:${desc.port}/health`, {
78
+ signal: AbortSignal.timeout(1000),
79
+ });
80
+ if (!res.ok) return false;
81
+ const body = (await res.json().catch(() => null)) as { ok?: boolean } | null;
82
+ return body?.ok === true;
83
+ } catch {
84
+ return false;
85
+ }
86
+ }
87
+
88
+ /**
89
+ * Return a live daemon for `cwd`: reuse an announced one if healthy, otherwise
90
+ * spawn `openlore serve` detached and poll until /health is ready.
91
+ * Returns null if no daemon could be brought up (caller degrades gracefully).
92
+ * Never kills a daemon it didn't start — it may serve other clients.
93
+ */
94
+ async function ensureDaemon(cwd: string): Promise<Daemon | null> {
95
+ const existing = await readDescriptor(cwd);
96
+ if (existing && (await health(existing))) {
97
+ return { baseUrl: `http://${existing.host}:${existing.port}`, token: existing.token };
98
+ }
99
+
100
+ // Spawn detached so the daemon outlives this pi session. No --watch flag:
101
+ // watch is on by default (only --no-watch disables it).
102
+ try {
103
+ const child = spawn('openlore', ['serve', '--directory', cwd], {
104
+ detached: true,
105
+ stdio: 'ignore',
106
+ });
107
+ child.unref();
108
+ } catch {
109
+ return null; // openlore not on PATH
110
+ }
111
+
112
+ const deadline = Date.now() + HEALTH_TIMEOUT_MS;
113
+ while (Date.now() < deadline) {
114
+ await sleep(HEALTH_POLL_MS);
115
+ const desc = await readDescriptor(cwd);
116
+ if (desc && (await health(desc))) {
117
+ return { baseUrl: `http://${desc.host}:${desc.port}`, token: desc.token };
118
+ }
119
+ }
120
+ return null;
121
+ }
122
+
123
+ /** POST /tool/:name → parsed JSON, or { error } on any transport failure. */
124
+ async function callTool(
125
+ daemon: Daemon,
126
+ name: string,
127
+ args: Record<string, unknown>,
128
+ cwd: string,
129
+ signal?: AbortSignal,
130
+ ): Promise<unknown> {
131
+ const headers: Record<string, string> = { 'content-type': 'application/json' };
132
+ if (daemon.token) headers['x-openlore-token'] = daemon.token;
133
+ try {
134
+ const res = await fetch(`${daemon.baseUrl}/tool/${encodeURIComponent(name)}`, {
135
+ method: 'POST',
136
+ headers,
137
+ body: JSON.stringify({ directory: cwd, args }),
138
+ signal,
139
+ });
140
+ const body = await res.json().catch(() => ({ error: `non-JSON response (${res.status})` }));
141
+ if (!res.ok) return { error: (body as { error?: string }).error ?? `HTTP ${res.status}` };
142
+ return body;
143
+ } catch (err) {
144
+ return { error: err instanceof Error ? err.message : String(err) };
145
+ }
146
+ }
147
+
148
+ // ── Context injection helpers (file-based, no subprocess) ─────────────────────
149
+
150
+ /** Architecture digest written by `openlore analyze`. */
151
+ async function readDigest(cwd: string): Promise<string> {
152
+ try {
153
+ return await readFile(join(cwd, '.openlore', 'analysis', 'CODEBASE.md'), 'utf-8');
154
+ } catch {
155
+ return '';
156
+ }
157
+ }
158
+
159
+ /** Compact one-line-per-domain spec index from openspec/specs/. */
160
+ async function readSpecIndex(cwd: string): Promise<string> {
161
+ try {
162
+ const { readdir } = await import('node:fs/promises');
163
+ const specsDir = join(cwd, 'openspec', 'specs');
164
+ const dirs = (await readdir(specsDir, { withFileTypes: true }))
165
+ .filter((d) => d.isDirectory())
166
+ .map((d) => d.name);
167
+ if (dirs.length === 0) return '';
168
+ return ['## openlore spec domains', ...dirs.map((d) => `- ${d}`)].join('\n');
169
+ } catch {
170
+ return '';
171
+ }
172
+ }
173
+
174
+ // ── Tool surface (navigation preset, tuned terse for small models) ────────────
175
+
176
+ interface ToolSpec {
177
+ name: string;
178
+ label: string;
179
+ description: string;
180
+ guideline: string;
181
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
182
+ parameters: any;
183
+ }
184
+
185
+ const TOOLS: ToolSpec[] = [
186
+ {
187
+ name: 'orient',
188
+ label: 'openlore orient',
189
+ description:
190
+ 'START HERE on any new task. Returns relevant functions, files, spec domains, ' +
191
+ 'call neighbours, and insertion points in one call.',
192
+ guideline: 'Use openlore_orient FIRST on any new task before reading files.',
193
+ parameters: Type.Object({
194
+ task: Type.String({ description: 'Natural-language task description' }),
195
+ limit: Type.Optional(Type.Number({ description: 'Max relevant functions (default 5)' })),
196
+ }),
197
+ },
198
+ {
199
+ name: 'search_code',
200
+ label: 'openlore search_code',
201
+ description: 'Semantic + keyword search for functions by meaning or name.',
202
+ guideline: 'Use openlore_search_code to find where a concept lives instead of grepping.',
203
+ parameters: Type.Object({
204
+ query: Type.String({ description: 'What to find' }),
205
+ limit: Type.Optional(Type.Number()),
206
+ language: Type.Optional(Type.String()),
207
+ }),
208
+ },
209
+ {
210
+ name: 'get_subgraph',
211
+ label: 'openlore get_subgraph',
212
+ description: 'Call topology around a function (callers/callees) to a given depth.',
213
+ guideline: 'Use openlore_get_subgraph to see blast radius before changing a function.',
214
+ parameters: Type.Object({
215
+ functionName: Type.String(),
216
+ direction: Type.Optional(StringEnum(['downstream', 'upstream', 'both'] as const)),
217
+ maxDepth: Type.Optional(Type.Number()),
218
+ }),
219
+ },
220
+ {
221
+ name: 'trace_execution_path',
222
+ label: 'openlore trace_execution_path',
223
+ description: 'Find call paths from an entry function to a target function.',
224
+ guideline: 'Use openlore_trace_execution_path to answer "how does X reach Y".',
225
+ parameters: Type.Object({
226
+ entryFunction: Type.String(),
227
+ targetFunction: Type.String(),
228
+ maxDepth: Type.Optional(Type.Number()),
229
+ }),
230
+ },
231
+ {
232
+ name: 'analyze_impact',
233
+ label: 'openlore analyze_impact',
234
+ description: 'Blast radius of changing a symbol (transitive dependents).',
235
+ guideline: 'Use openlore_analyze_impact before editing a shared/hub symbol.',
236
+ parameters: Type.Object({
237
+ symbol: Type.String(),
238
+ depth: Type.Optional(Type.Number()),
239
+ }),
240
+ },
241
+ {
242
+ name: 'suggest_insertion_points',
243
+ label: 'openlore suggest_insertion_points',
244
+ description: 'Where to add a feature — ranked file/function insertion candidates.',
245
+ guideline: 'Use openlore_suggest_insertion_points when planning where new code goes.',
246
+ parameters: Type.Object({
247
+ description: Type.String(),
248
+ limit: Type.Optional(Type.Number()),
249
+ }),
250
+ },
251
+ {
252
+ name: 'get_function_skeleton',
253
+ label: 'openlore get_function_skeleton',
254
+ description: 'Compact skeleton of a file: signatures + control flow, noise stripped.',
255
+ guideline: 'Use openlore_get_function_skeleton to read a file cheaply before opening it.',
256
+ parameters: Type.Object({
257
+ filePath: Type.String(),
258
+ }),
259
+ },
260
+ ];
261
+
262
+ // ── Extension entry point ────────────────────────────────────────────────────
263
+
264
+ export default function openlore(pi: ExtensionAPI): void {
265
+ // One daemon per project cwd for this pi process.
266
+ const daemons = new Map<string, Daemon | null>();
267
+ // Inject the heavy session primer only once per session.
268
+ const primed = new Set<string>();
269
+ // before_agent_start receives only `event` (no ctx in pi's API), so capture
270
+ // the working directory from session_start and reuse it there.
271
+ let sessionCwd = process.cwd();
272
+
273
+ async function getDaemon(cwd: string): Promise<Daemon | null> {
274
+ if (!daemons.has(cwd)) daemons.set(cwd, await ensureDaemon(cwd));
275
+ return daemons.get(cwd) ?? null;
276
+ }
277
+
278
+ // ── B: native tools ──
279
+ for (const tool of TOOLS) {
280
+ pi.registerTool({
281
+ name: `openlore_${tool.name}`,
282
+ label: tool.label,
283
+ description: tool.description,
284
+ promptSnippet: tool.description,
285
+ promptGuidelines: [tool.guideline],
286
+ parameters: tool.parameters,
287
+ async execute(_id: string, params: Record<string, unknown>, signal: AbortSignal, _onUpdate: unknown, ctx: ExtensionContext) {
288
+ const cwd = ctx.cwd;
289
+ const daemon = await getDaemon(cwd);
290
+ if (!daemon) {
291
+ return {
292
+ content: [{ type: 'text', text: 'openlore daemon unavailable — run `openlore analyze` then retry, or check `openlore` is on PATH.' }],
293
+ };
294
+ }
295
+ const result = await callTool(daemon, tool.name, params, cwd, signal);
296
+ const text = typeof result === 'string' ? result : JSON.stringify(result, null, 2);
297
+ return { content: [{ type: 'text', text: truncate(text, RESULT_MAX) }], details: result };
298
+ },
299
+ });
300
+ }
301
+
302
+ // ── Lifecycle: warm the daemon at session start (best-effort) ──
303
+ pi.on('session_start', async (_event: unknown, ctx: ExtensionContext) => {
304
+ sessionCwd = ctx.cwd;
305
+ await getDaemon(ctx.cwd);
306
+ });
307
+
308
+ // ── C: context injection on the first turn ──
309
+ pi.on('before_agent_start', async (event: { systemPrompt: string }) => {
310
+ const cwd = sessionCwd;
311
+ if (primed.has(cwd)) return undefined;
312
+ primed.add(cwd);
313
+
314
+ const blocks: string[] = [];
315
+
316
+ const digest = await readDigest(cwd);
317
+ if (digest) blocks.push('# Codebase architecture (openlore)\n\n' + truncate(digest, 8000));
318
+
319
+ const specIndex = await readSpecIndex(cwd);
320
+ if (specIndex) blocks.push(specIndex);
321
+
322
+ // Task-grounded primer: orient on the user's first message.
323
+ const firstUserMsg = extractFirstUserText(event);
324
+ const daemon = await getDaemon(cwd);
325
+ if (daemon && firstUserMsg) {
326
+ const oriented = await callTool(daemon, 'orient', { task: firstUserMsg }, cwd);
327
+ if (oriented && typeof oriented === 'object' && !('error' in (oriented as object))) {
328
+ blocks.push('# openlore orientation for this task\n\n' + truncate(JSON.stringify(oriented, null, 2), 6000));
329
+ }
330
+ }
331
+
332
+ if (blocks.length === 0) {
333
+ // No analysis yet — nudge once, never block the turn.
334
+ return {
335
+ systemPrompt:
336
+ event.systemPrompt +
337
+ '\n\n[openlore: no analysis found — run `openlore analyze` to enable structural context + tools.]',
338
+ };
339
+ }
340
+
341
+ return { systemPrompt: event.systemPrompt + '\n\n' + blocks.join('\n\n') };
342
+ });
343
+ }
344
+
345
+ /** Best-effort pull of the first user message text from the before_agent_start event. */
346
+ function extractFirstUserText(event: unknown): string {
347
+ const e = event as { messages?: Array<{ role?: string; content?: unknown }> };
348
+ const msg = e.messages?.find((m) => m.role === 'user');
349
+ if (!msg) return '';
350
+ if (typeof msg.content === 'string') return msg.content;
351
+ if (Array.isArray(msg.content)) {
352
+ return msg.content
353
+ .map((p: unknown) => (typeof p === 'object' && p && 'text' in p ? String((p as { text: unknown }).text) : ''))
354
+ .join(' ')
355
+ .trim();
356
+ }
357
+ return '';
358
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openlore",
3
- "version": "2.0.9",
3
+ "version": "2.0.11",
4
4
  "description": "Reverse-engineer OpenSpec specifications from existing codebases",
5
5
  "type": "module",
6
6
  "main": "dist/api/index.js",