@scitrera/memorylayer-opencode-plugin 0.1.22
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 +178 -0
- package/dist/src/hooks/event.d.ts +15 -0
- package/dist/src/hooks/event.d.ts.map +1 -0
- package/dist/src/hooks/event.js +53 -0
- package/dist/src/hooks/event.js.map +1 -0
- package/dist/src/hooks/message.d.ts +20 -0
- package/dist/src/hooks/message.d.ts.map +1 -0
- package/dist/src/hooks/message.js +128 -0
- package/dist/src/hooks/message.js.map +1 -0
- package/dist/src/hooks/session.d.ts +20 -0
- package/dist/src/hooks/session.d.ts.map +1 -0
- package/dist/src/hooks/session.js +121 -0
- package/dist/src/hooks/session.js.map +1 -0
- package/dist/src/hooks/tool.d.ts +23 -0
- package/dist/src/hooks/tool.d.ts.map +1 -0
- package/dist/src/hooks/tool.js +168 -0
- package/dist/src/hooks/tool.js.map +1 -0
- package/dist/src/index.d.ts +31 -0
- package/dist/src/index.d.ts.map +1 -0
- package/dist/src/index.js +176 -0
- package/dist/src/index.js.map +1 -0
- package/dist/src/shared/client.d.ts +27 -0
- package/dist/src/shared/client.d.ts.map +1 -0
- package/dist/src/shared/client.js +69 -0
- package/dist/src/shared/client.js.map +1 -0
- package/dist/src/shared/formatters.d.ts +33 -0
- package/dist/src/shared/formatters.d.ts.map +1 -0
- package/dist/src/shared/formatters.js +187 -0
- package/dist/src/shared/formatters.js.map +1 -0
- package/dist/src/shared/observation.d.ts +59 -0
- package/dist/src/shared/observation.d.ts.map +1 -0
- package/dist/src/shared/observation.js +406 -0
- package/dist/src/shared/observation.js.map +1 -0
- package/dist/src/shared/state.d.ts +66 -0
- package/dist/src/shared/state.d.ts.map +1 -0
- package/dist/src/shared/state.js +144 -0
- package/dist/src/shared/state.js.map +1 -0
- package/dist/src/shared/types.d.ts +141 -0
- package/dist/src/shared/types.d.ts.map +1 -0
- package/dist/src/shared/types.js +9 -0
- package/dist/src/shared/types.js.map +1 -0
- package/opencode.example.json +15 -0
- package/package.json +52 -0
- package/src/commands/recall.md +55 -0
- package/src/commands/remember.md +37 -0
- package/src/commands/setup.md +47 -0
- package/src/commands/status.md +59 -0
- package/src/hooks/event.ts +63 -0
- package/src/hooks/message.ts +147 -0
- package/src/hooks/session.ts +127 -0
- package/src/hooks/tool.ts +207 -0
- package/src/index.ts +197 -0
- package/src/shared/client.ts +73 -0
- package/src/shared/formatters.ts +237 -0
- package/src/shared/observation.ts +447 -0
- package/src/shared/state.ts +159 -0
- package/src/shared/types.ts +144 -0
- package/tsconfig.json +24 -0
|
@@ -0,0 +1,447 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
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
|
+
}
|