@scitrera/memorylayer-opencode-plugin 0.1.22 → 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.
@@ -1,447 +0,0 @@
1
- /**
2
- * Observation extraction for auto-capture hooks.
3
- * Extracts structured observation data from tool usage.
4
- *
5
- * Adapted from the CC plugin with OpenCode tool name mappings.
6
- * OpenCode uses lowercase tool names: bash, edit, write, read, glob, grep,
7
- * task, webfetch, websearch, multiedit, ls, apply_patch, codesearch, etc.
8
- */
9
-
10
- import { createHash } from "crypto";
11
-
12
- // Tools to skip (internal/noisy/self-referential)
13
- const SKIP_TOOLS = new Set([
14
- "todo",
15
- "question",
16
- "plan",
17
- "skill",
18
- "batch",
19
- ]);
20
-
21
- // Also skip any tool matching these prefixes (memory tools)
22
- const SKIP_PREFIXES = [
23
- "memorylayer",
24
- "memory_",
25
- "mcp__memorylayer",
26
- ];
27
-
28
- export function shouldSkipTool(toolName: string): boolean {
29
- if (SKIP_TOOLS.has(toolName)) return true;
30
- return SKIP_PREFIXES.some(prefix => toolName.startsWith(prefix));
31
- }
32
-
33
- // Observation types
34
- export type ObservationType = "read" | "write" | "execute" | "search" | "other";
35
-
36
- export interface ObservationData {
37
- type: ObservationType;
38
- title: string;
39
- toolName: string;
40
- filesRead: string[];
41
- filesModified: string[];
42
- facts: string[];
43
- concepts: string[];
44
- intent: string | null;
45
- contentHash: string;
46
- summary: string;
47
- }
48
-
49
- /**
50
- * Classify tool as read/write/execute/search/other.
51
- * Uses OpenCode's lowercase tool names.
52
- */
53
- export function getObservationType(toolName: string): ObservationType {
54
- const readTools = ["read", "glob", "grep", "ls", "codesearch"];
55
- const writeTools = ["write", "edit", "multiedit", "apply_patch"];
56
- const executeTools = ["bash", "task"];
57
- const searchTools = ["websearch", "webfetch"];
58
-
59
- if (readTools.includes(toolName)) return "read";
60
- if (writeTools.includes(toolName)) return "write";
61
- if (executeTools.includes(toolName)) return "execute";
62
- if (searchTools.includes(toolName)) return "search";
63
- return "other";
64
- }
65
-
66
- /**
67
- * Extract file paths from tool args, classified as read or modified
68
- */
69
- export function extractFilePaths(
70
- toolName: string,
71
- toolArgs: Record<string, unknown>
72
- ): { filesRead: string[]; filesModified: string[] } {
73
- const filesRead: string[] = [];
74
- const filesModified: string[] = [];
75
-
76
- try {
77
- const filePath = (toolArgs.file_path || toolArgs.path || toolArgs.file || "") as string;
78
-
79
- if (!filePath) {
80
- // Try to extract paths from bash commands
81
- if (toolName === "bash") {
82
- const command = (toolArgs.command || "") as string;
83
- const pathMatches = command.match(
84
- /(?:^|\s)([^\s]+\.(ts|js|py|json|yaml|yml|md|txt|go|rs|java|c|cpp|h))\b/gi
85
- );
86
- if (pathMatches) {
87
- filesRead.push(...pathMatches.map((p: string) => p.trim()));
88
- }
89
- }
90
- return { filesRead, filesModified };
91
- }
92
-
93
- const type = getObservationType(toolName);
94
- if (type === "write") {
95
- filesModified.push(filePath);
96
- } else if (type === "read") {
97
- filesRead.push(filePath);
98
- }
99
- } catch {
100
- // Ignore parse errors
101
- }
102
-
103
- return { filesRead, filesModified };
104
- }
105
-
106
- /**
107
- * Generate observation title from tool usage
108
- */
109
- export function generateTitle(toolName: string, toolArgs: Record<string, unknown>): string {
110
- try {
111
- switch (toolName) {
112
- case "read":
113
- return `Read ${toolArgs.file_path || toolArgs.path || "file"}`;
114
- case "write":
115
- return `Write ${toolArgs.file_path || toolArgs.path || "file"}`;
116
- case "edit":
117
- case "multiedit":
118
- return `Edit ${toolArgs.file_path || toolArgs.path || "file"}`;
119
- case "apply_patch":
120
- return `Patch ${toolArgs.file_path || toolArgs.path || "file"}`;
121
- case "bash": {
122
- const cmd = (toolArgs.command || "") as string;
123
- return `Run: ${cmd.substring(0, 50)}${cmd.length > 50 ? "..." : ""}`;
124
- }
125
- case "glob":
126
- return `Find ${toolArgs.pattern || "files"}`;
127
- case "grep":
128
- case "codesearch":
129
- return `Search "${toolArgs.pattern || ""}"`;
130
- case "task":
131
- return `Task: ${toolArgs.description || "agent"}`;
132
- case "websearch":
133
- return `Search: ${toolArgs.query || ""}`;
134
- case "webfetch":
135
- return `Fetch: ${toolArgs.url || ""}`;
136
- default:
137
- return toolName;
138
- }
139
- } catch {
140
- return toolName;
141
- }
142
- }
143
-
144
- /**
145
- * Extract facts from tool args/output
146
- */
147
- export function extractFacts(
148
- toolName: string,
149
- toolArgs: Record<string, unknown>,
150
- toolOutput?: string
151
- ): string[] {
152
- const facts: string[] = [];
153
-
154
- try {
155
- const filePath = (toolArgs.file_path || toolArgs.path || toolArgs.file || "") as string;
156
-
157
- switch (toolName) {
158
- case "read":
159
- if (filePath) facts.push(`File read: ${filePath}`);
160
- break;
161
- case "write":
162
- if (filePath) facts.push(`File created/updated: ${filePath}`);
163
- break;
164
- case "edit":
165
- case "multiedit":
166
- case "apply_patch":
167
- if (filePath) facts.push(`File modified: ${filePath}`);
168
- if (toolArgs.old_string) {
169
- facts.push(`Code replaced in ${filePath.split(/[/\\]/).pop() || "file"}`);
170
- }
171
- break;
172
- case "bash": {
173
- const cmd = (toolArgs.command || "") as string;
174
- facts.push(`Command executed: ${cmd.substring(0, 100)}`);
175
- if (toolOutput) {
176
- if (toolOutput.includes("passed") || toolOutput.includes("\u2713")) facts.push("Tests passed");
177
- if (toolOutput.includes("failed") || toolOutput.includes("\u2717")) facts.push("Tests failed");
178
- if (toolOutput.includes("error") || toolOutput.includes("Error")) facts.push("Errors encountered");
179
- }
180
- break;
181
- }
182
- case "glob":
183
- if (toolArgs.pattern) facts.push(`Pattern searched: ${toolArgs.pattern}`);
184
- break;
185
- case "grep":
186
- case "codesearch":
187
- if (toolArgs.pattern) facts.push(`Code pattern searched: ${toolArgs.pattern}`);
188
- if (toolArgs.path) facts.push(`Search scope: ${toolArgs.path}`);
189
- break;
190
- case "websearch":
191
- if (toolArgs.query) facts.push(`Web search: ${toolArgs.query}`);
192
- break;
193
- case "webfetch":
194
- if (toolArgs.url) facts.push(`URL fetched: ${toolArgs.url}`);
195
- break;
196
- case "task":
197
- if (toolArgs.description) facts.push(`Sub-task: ${toolArgs.description}`);
198
- break;
199
- }
200
- } catch {
201
- // Ignore parse errors
202
- }
203
-
204
- return facts;
205
- }
206
-
207
- /**
208
- * Extract concepts/topics from tool usage
209
- */
210
- export function extractConcepts(toolName: string, toolArgs: Record<string, unknown>): string[] {
211
- const concepts: Set<string> = new Set();
212
-
213
- try {
214
- const filePath = (toolArgs.file_path || toolArgs.path || toolArgs.file || "") as string;
215
-
216
- // Extract concepts from file paths
217
- if (filePath) {
218
- const parts = filePath.split(/[/\\]/);
219
- for (const part of parts) {
220
- if (["src", "lib", "dist", "node_modules", ".", ".."].includes(part)) continue;
221
- if (part.includes(".")) {
222
- const ext = part.split(".").pop();
223
- const extMap: Record<string, string> = {
224
- ts: "typescript", tsx: "react", js: "javascript", jsx: "react",
225
- py: "python", rs: "rust", go: "golang", css: "styling", scss: "styling",
226
- html: "html", json: "configuration", yaml: "configuration", yml: "configuration",
227
- md: "documentation", test: "testing", spec: "testing", sql: "database",
228
- };
229
- if (ext && extMap[ext]) concepts.add(extMap[ext]);
230
- }
231
- const dirMap: Record<string, string> = {
232
- tests: "testing", __tests__: "testing", test: "testing", spec: "testing",
233
- hooks: "hooks", api: "api", auth: "authentication", db: "database",
234
- components: "components", pages: "pages", routes: "routing", utils: "utilities",
235
- services: "services", middleware: "middleware", models: "models", types: "types",
236
- cli: "cli", config: "configuration", migrations: "database", schemas: "schemas",
237
- };
238
- if (dirMap[part]) concepts.add(dirMap[part]);
239
- }
240
- }
241
-
242
- // Extract function/class names from edit tools
243
- if (toolName === "edit" || toolName === "multiedit") {
244
- const oldStr = (toolArgs.old_string || "") as string;
245
- const newStr = (toolArgs.new_string || "") as string;
246
- const combined = oldStr + "\n" + newStr;
247
-
248
- const funcMatches = combined.match(/(?:function|async function|const|let|var)\s+(\w{3,})/g);
249
- if (funcMatches) {
250
- for (const m of funcMatches.slice(0, 3)) {
251
- const name = m.replace(/(?:function|async function|const|let|var)\s+/, "");
252
- concepts.add(`fn:${name}`);
253
- }
254
- }
255
-
256
- const classMatches = combined.match(/class\s+(\w{3,})/g);
257
- if (classMatches) {
258
- for (const m of classMatches.slice(0, 2)) {
259
- concepts.add(`class:${m.replace("class ", "")}`);
260
- }
261
- }
262
-
263
- if (/\bimport\b/.test(combined)) concepts.add("pattern:import");
264
- if (/\bexport\b/.test(combined)) concepts.add("pattern:export");
265
- if (/\binterface\b/.test(combined)) concepts.add("pattern:interface");
266
- if (/\benum\b/.test(combined)) concepts.add("pattern:enum");
267
- if (/\btry\s*\{/.test(combined)) concepts.add("pattern:error-handling");
268
- if (/\basync\b/.test(combined)) concepts.add("pattern:async");
269
- }
270
-
271
- // Tool-based concepts
272
- switch (toolName) {
273
- case "bash": {
274
- const cmd = (toolArgs.command || "") as string;
275
- if (cmd.includes("test") || cmd.includes("vitest") || cmd.includes("jest")) concepts.add("testing");
276
- if (cmd.includes("build") || cmd.includes("tsc")) concepts.add("build");
277
- if (cmd.includes("git")) concepts.add("version-control");
278
- if (cmd.includes("npm") || cmd.includes("yarn") || cmd.includes("pnpm") || cmd.includes("bun")) concepts.add("package-management");
279
- if (cmd.includes("docker")) concepts.add("containerization");
280
- if (cmd.includes("lint") || cmd.includes("eslint") || cmd.includes("biome")) concepts.add("linting");
281
- break;
282
- }
283
- case "websearch":
284
- concepts.add("research");
285
- break;
286
- case "webfetch":
287
- concepts.add("web-content");
288
- break;
289
- case "task":
290
- concepts.add("delegation");
291
- break;
292
- }
293
- } catch {
294
- // Ignore parse errors
295
- }
296
-
297
- return Array.from(concepts);
298
- }
299
-
300
- /**
301
- * Detect intent from tool usage and user prompt
302
- */
303
- export function detectIntent(
304
- toolName: string,
305
- toolArgs: Record<string, unknown>,
306
- prompt?: string
307
- ): string | null {
308
- const intentPatterns = {
309
- bugfix: /fix|bug|error|issue|broken|crash|repair/i,
310
- feature: /add|feature|implement|create|new|build/i,
311
- refactor: /refactor|clean|rename|reorganize|restructure/i,
312
- testing: /test|spec|coverage|verify/i,
313
- investigation: /find|search|investigate|debug|analyze|explore/i,
314
- documentation: /document|comment|readme|doc|explain/i,
315
- };
316
-
317
- if (prompt) {
318
- for (const [intent, pattern] of Object.entries(intentPatterns)) {
319
- if (pattern.test(prompt)) {
320
- return intent;
321
- }
322
- }
323
- }
324
-
325
- try {
326
- const inputStr = JSON.stringify(toolArgs);
327
- for (const [intent, pattern] of Object.entries(intentPatterns)) {
328
- if (pattern.test(inputStr)) {
329
- return intent;
330
- }
331
- }
332
- } catch {
333
- // Ignore parse errors
334
- }
335
-
336
- switch (toolName) {
337
- case "read":
338
- case "glob":
339
- case "grep":
340
- case "codesearch":
341
- return "investigation";
342
- case "write":
343
- return "feature";
344
- case "edit":
345
- return null; // Too ambiguous without context
346
- default:
347
- return null;
348
- }
349
- }
350
-
351
- /**
352
- * Compute content hash for deduplication
353
- */
354
- export function computeContentHash(...parts: string[]): string {
355
- return createHash("sha256")
356
- .update(parts.join("|"))
357
- .digest("hex")
358
- .slice(0, 16);
359
- }
360
-
361
- /**
362
- * Truncate string to max length
363
- */
364
- function truncate(str: string, maxLen: number = 500): string {
365
- if (str.length <= maxLen) return str;
366
- return str.substring(0, maxLen) + "...";
367
- }
368
-
369
- /**
370
- * Build observation from tool execution data
371
- */
372
- export function buildObservation(
373
- toolName: string,
374
- toolArgs: Record<string, unknown>,
375
- toolOutput?: string,
376
- currentPrompt?: string
377
- ): ObservationData | null {
378
- const type = getObservationType(toolName);
379
- const title = generateTitle(toolName, toolArgs);
380
- const { filesRead, filesModified } = extractFilePaths(toolName, toolArgs);
381
- const facts = extractFacts(toolName, toolArgs, toolOutput);
382
- const concepts = extractConcepts(toolName, toolArgs);
383
- const intent = detectIntent(toolName, toolArgs, currentPrompt);
384
-
385
- const summary = buildSummary(toolName, toolArgs, toolOutput, filesRead, filesModified);
386
-
387
- const contentHash = computeContentHash(
388
- toolName,
389
- JSON.stringify(toolArgs),
390
- summary
391
- );
392
-
393
- // Skip if empty observation (no meaningful data extracted)
394
- if (filesRead.length === 0 && filesModified.length === 0 && facts.length === 0 && concepts.length === 0) {
395
- return null;
396
- }
397
-
398
- return {
399
- type,
400
- title,
401
- toolName,
402
- filesRead,
403
- filesModified,
404
- facts,
405
- concepts,
406
- intent,
407
- contentHash,
408
- summary,
409
- };
410
- }
411
-
412
- /**
413
- * Build human-readable summary
414
- */
415
- function buildSummary(
416
- toolName: string,
417
- toolArgs: Record<string, unknown>,
418
- _toolOutput: string | undefined,
419
- filesRead: string[],
420
- filesModified: string[]
421
- ): string {
422
- const parts: string[] = [];
423
-
424
- if (filesRead.length > 0) {
425
- parts.push(`Read: ${filesRead.join(", ")}`);
426
- }
427
- if (filesModified.length > 0) {
428
- parts.push(`Modified: ${filesModified.join(", ")}`);
429
- }
430
-
431
- try {
432
- if (toolName === "bash") {
433
- const cmd = (toolArgs.command || "") as string;
434
- parts.push(`Command: ${truncate(cmd, 100)}`);
435
- } else if (toolName === "grep" || toolName === "codesearch") {
436
- const pattern = (toolArgs.pattern || "") as string;
437
- parts.push(`Pattern: ${pattern}`);
438
- } else if (toolName === "task") {
439
- const desc = (toolArgs.description || "") as string;
440
- parts.push(`Task: ${truncate(desc, 100)}`);
441
- }
442
- } catch {
443
- // Ignore
444
- }
445
-
446
- return parts.length > 0 ? parts.join("; ") : `Used ${toolName}`;
447
- }
@@ -1,159 +0,0 @@
1
- /**
2
- * Hook state management - persists state between hook invocations.
3
- *
4
- * Adapted from the CC plugin's state module. Removes CLAUDE_ENV_FILE
5
- * logic since OpenCode uses a different environment mechanism.
6
- */
7
-
8
- import { readFileSync, writeFileSync, mkdirSync, existsSync } from "fs";
9
- import { homedir } from "os";
10
- import { join } from "path";
11
- import type { HookState } from "./types.js";
12
-
13
- const STATE_DIR = join(homedir(), ".memorylayer");
14
- const STATE_FILE = join(STATE_DIR, "hook-state.json");
15
-
16
- /** Default empty state */
17
- const DEFAULT_STATE: HookState = {
18
- recallDoneThisTurn: false,
19
- };
20
-
21
- /**
22
- * Read current hook state from disk
23
- */
24
- export function readHookState(): HookState {
25
- try {
26
- if (!existsSync(STATE_FILE)) {
27
- return { ...DEFAULT_STATE };
28
- }
29
- const data = readFileSync(STATE_FILE, "utf-8");
30
- return JSON.parse(data) as HookState;
31
- } catch {
32
- return { ...DEFAULT_STATE };
33
- }
34
- }
35
-
36
- /**
37
- * Write hook state to disk
38
- */
39
- export function writeHookState(state: HookState): void {
40
- try {
41
- if (!existsSync(STATE_DIR)) {
42
- mkdirSync(STATE_DIR, { recursive: true });
43
- }
44
- writeFileSync(STATE_FILE, JSON.stringify(state, null, 2));
45
- } catch (error) {
46
- console.error("Failed to write hook state:", error);
47
- }
48
- }
49
-
50
- /**
51
- * Mark that recall has been done this turn for a specific query
52
- */
53
- export function markRecallDone(query: string): void {
54
- const state = readHookState();
55
- state.recallDoneThisTurn = true;
56
- state.lastRecallAt = new Date().toISOString();
57
- state.lastRecallQuery = query;
58
- const queries = state.recallQueriesThisTurn || [];
59
- queries.push(query);
60
- state.recallQueriesThisTurn = queries;
61
- writeHookState(state);
62
- }
63
-
64
- /**
65
- * Check if recall was already done this turn (any query)
66
- */
67
- export function wasRecallDoneThisTurn(): boolean {
68
- const state = readHookState();
69
- return state.recallDoneThisTurn;
70
- }
71
-
72
- /**
73
- * Check if a specific query (or similar) was already recalled this turn.
74
- * Allows different queries to proceed even if a recall was already done.
75
- */
76
- export function wasQueryRecalledThisTurn(query: string): boolean {
77
- const state = readHookState();
78
- const queries = state.recallQueriesThisTurn || [];
79
- const normalized = query.toLowerCase().trim().replace(/\s+/g, " ");
80
- return queries.some(q => q.toLowerCase().trim().replace(/\s+/g, " ") === normalized);
81
- }
82
-
83
- /**
84
- * Reset recall status for new turn
85
- */
86
- export function resetRecallStatus(): void {
87
- const state = readHookState();
88
- state.recallDoneThisTurn = false;
89
- state.recallQueriesThisTurn = [];
90
- writeHookState(state);
91
- }
92
-
93
- /**
94
- * Store the user's current topic for cross-hook context
95
- */
96
- export function setCurrentTopic(topic: string): void {
97
- const state = readHookState();
98
- state.currentTopic = topic;
99
- writeHookState(state);
100
- }
101
-
102
- /**
103
- * Get the user's current topic (set by chat.message hook)
104
- */
105
- export function getCurrentTopic(): string | undefined {
106
- return readHookState().currentTopic;
107
- }
108
-
109
- /**
110
- * Store the user's current prompt for cross-hook context
111
- */
112
- export function setCurrentPrompt(prompt: string): void {
113
- const state = readHookState();
114
- state.currentPrompt = prompt;
115
- writeHookState(state);
116
- }
117
-
118
- /**
119
- * Get the user's current prompt
120
- */
121
- export function getCurrentPrompt(): string | undefined {
122
- return readHookState().currentPrompt;
123
- }
124
-
125
- /**
126
- * Update workspace/session info
127
- */
128
- export function updateSessionInfo(workspaceId: string, sessionId?: string): void {
129
- const state = readHookState();
130
- state.workspaceId = workspaceId;
131
- state.sessionId = sessionId;
132
- writeHookState(state);
133
- }
134
-
135
- /**
136
- * Get current workspace ID from state
137
- */
138
- export function getWorkspaceId(): string | undefined {
139
- return readHookState().workspaceId;
140
- }
141
-
142
- /**
143
- * Resolve session ID from hook state.
144
- *
145
- * Unlike the CC plugin which checks CLAUDE_ENV_FILE first, OpenCode
146
- * plugins receive session IDs directly via hook input parameters.
147
- * This function serves as a fallback for hooks that need the session
148
- * ID but don't receive it directly.
149
- */
150
- export function resolveSessionId(_caller: string): string | undefined {
151
- // Check environment variable first (may be set via shell.env hook)
152
- const envSessionId = process.env.MEMORYLAYER_SESSION_ID;
153
- if (envSessionId) {
154
- return envSessionId;
155
- }
156
-
157
- // Fall back to persisted state
158
- return readHookState().sessionId;
159
- }