opencode-diane 0.0.5
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/CHANGELOG.md +180 -0
- package/LICENSE +21 -0
- package/README.md +206 -0
- package/WIKI.md +1430 -0
- package/dist/index.d.ts +28 -0
- package/dist/index.js +1632 -0
- package/dist/ingest/adaptive.d.ts +47 -0
- package/dist/ingest/adaptive.js +182 -0
- package/dist/ingest/code-health.d.ts +58 -0
- package/dist/ingest/code-health.js +202 -0
- package/dist/ingest/code-map.d.ts +71 -0
- package/dist/ingest/code-map.js +670 -0
- package/dist/ingest/cross-refs.d.ts +59 -0
- package/dist/ingest/cross-refs.js +1207 -0
- package/dist/ingest/docs.d.ts +49 -0
- package/dist/ingest/docs.js +325 -0
- package/dist/ingest/git.d.ts +77 -0
- package/dist/ingest/git.js +390 -0
- package/dist/ingest/live-session.d.ts +101 -0
- package/dist/ingest/live-session.js +173 -0
- package/dist/ingest/project-notes.d.ts +28 -0
- package/dist/ingest/project-notes.js +102 -0
- package/dist/ingest/project.d.ts +35 -0
- package/dist/ingest/project.js +430 -0
- package/dist/ingest/session-snapshot.d.ts +63 -0
- package/dist/ingest/session-snapshot.js +94 -0
- package/dist/ingest/sessions.d.ts +29 -0
- package/dist/ingest/sessions.js +164 -0
- package/dist/ingest/tables.d.ts +52 -0
- package/dist/ingest/tables.js +360 -0
- package/dist/mining/skill-miner.d.ts +53 -0
- package/dist/mining/skill-miner.js +234 -0
- package/dist/search/bm25.d.ts +81 -0
- package/dist/search/bm25.js +334 -0
- package/dist/search/e5-embedder.d.ts +30 -0
- package/dist/search/e5-embedder.js +91 -0
- package/dist/search/embed-pass.d.ts +26 -0
- package/dist/search/embed-pass.js +43 -0
- package/dist/search/embedder.d.ts +58 -0
- package/dist/search/embedder.js +85 -0
- package/dist/search/inverted-index.d.ts +51 -0
- package/dist/search/inverted-index.js +139 -0
- package/dist/search/ppr.d.ts +44 -0
- package/dist/search/ppr.js +118 -0
- package/dist/search/tokenize.d.ts +26 -0
- package/dist/search/tokenize.js +98 -0
- package/dist/store/eviction.d.ts +16 -0
- package/dist/store/eviction.js +37 -0
- package/dist/store/repository.d.ts +222 -0
- package/dist/store/repository.js +420 -0
- package/dist/store/sqlite-store.d.ts +89 -0
- package/dist/store/sqlite-store.js +252 -0
- package/dist/store/vector-store.d.ts +66 -0
- package/dist/store/vector-store.js +160 -0
- package/dist/types.d.ts +385 -0
- package/dist/types.js +9 -0
- package/dist/utils/file-log.d.ts +87 -0
- package/dist/utils/file-log.js +215 -0
- package/dist/utils/peer-detection.d.ts +45 -0
- package/dist/utils/peer-detection.js +90 -0
- package/dist/utils/shell.d.ts +43 -0
- package/dist/utils/shell.js +110 -0
- package/dist/utils/usage-skill.d.ts +42 -0
- package/dist/utils/usage-skill.js +129 -0
- package/dist/utils/xlsx.d.ts +36 -0
- package/dist/utils/xlsx.js +270 -0
- package/grammars/tree-sitter-c.wasm +0 -0
- package/grammars/tree-sitter-c_sharp.wasm +0 -0
- package/grammars/tree-sitter-cpp.wasm +0 -0
- package/grammars/tree-sitter-css.wasm +0 -0
- package/grammars/tree-sitter-go.wasm +0 -0
- package/grammars/tree-sitter-html.wasm +0 -0
- package/grammars/tree-sitter-java.wasm +0 -0
- package/grammars/tree-sitter-javascript.wasm +0 -0
- package/grammars/tree-sitter-json.wasm +0 -0
- package/grammars/tree-sitter-php.wasm +0 -0
- package/grammars/tree-sitter-python.wasm +0 -0
- package/grammars/tree-sitter-rust.wasm +0 -0
- package/grammars/tree-sitter-typescript.wasm +0 -0
- package/package.json +80 -0
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rich, structured logging to disk. A second log sink alongside
|
|
3
|
+
* OpenCode's own session log channel — the existing `log()` callback
|
|
4
|
+
* keeps piping human-readable lines into OpenCode's UI, and this
|
|
5
|
+
* module mirrors them (plus structured events) to a JSONL file under
|
|
6
|
+
* `os.tmpdir()/opencode-diane/` by default, or `$OPENCODE_DIANE_LOG_DIR`
|
|
7
|
+
* if set (use the env var when running inside Docker — point it at a
|
|
8
|
+
* mounted volume and your logs survive the container). The file is
|
|
9
|
+
* per-session and per-PID, so parallel OpenCode sessions never
|
|
10
|
+
* interleave; each line is a standalone JSON object so the whole file
|
|
11
|
+
* is greppable AND `jq`-able.
|
|
12
|
+
*
|
|
13
|
+
* Why not into OpenCode's log alone:
|
|
14
|
+
* - OpenCode's log is human-oriented (single-line strings) and is
|
|
15
|
+
* scoped to a session's UI panel — easy to lose between turns.
|
|
16
|
+
* - For debugging the plugin itself (a flush that took 200ms, an
|
|
17
|
+
* ingester that skipped half its commits, an eviction that fired
|
|
18
|
+
* under budget) you want timestamped, machine-readable, persistent
|
|
19
|
+
* records you can keep across sessions and diff.
|
|
20
|
+
*
|
|
21
|
+
* Why JSONL not a text log:
|
|
22
|
+
* - One line per record, never multi-line, so `tail -f` works.
|
|
23
|
+
* - Each line is valid JSON, so `jq '.event == "ingest.git"'` works.
|
|
24
|
+
* - Streams append cleanly even from multiple writers (each line is
|
|
25
|
+
* atomic on POSIX up to PIPE_BUF, and our records are well under).
|
|
26
|
+
*
|
|
27
|
+
* Failure model: every disk operation can fail (full disk, permission
|
|
28
|
+
* lost, tmpdir on a flaky volume). A failure HERE must never propagate
|
|
29
|
+
* to the host plugin — the file logger is a debugging aid, not a
|
|
30
|
+
* correctness dependency. We try once, drop the stream on any error,
|
|
31
|
+
* and go silent. The OpenCode log channel is unaffected.
|
|
32
|
+
*
|
|
33
|
+
* Retention is the user's problem: we never delete; files accumulate
|
|
34
|
+
* in `os.tmpdir()/opencode-diane/` until the OS clears tmp. On Linux
|
|
35
|
+
* that's typically at reboot or via systemd-tmpfiles; on macOS every
|
|
36
|
+
* few days. Documented in WIKI.
|
|
37
|
+
*/
|
|
38
|
+
import { mkdirSync, openSync, writeSync, closeSync } from "node:fs";
|
|
39
|
+
import { tmpdir } from "node:os";
|
|
40
|
+
import { join } from "node:path";
|
|
41
|
+
/**
|
|
42
|
+
* Directory the logger writes into.
|
|
43
|
+
*
|
|
44
|
+
* Honours `OPENCODE_DIANE_LOG_DIR` if set — point it at a mounted
|
|
45
|
+
* volume when running under Docker (`-e OPENCODE_DIANE_LOG_DIR=/logs
|
|
46
|
+
* -v $PWD/logs:/logs`) so logs survive the container and can be read
|
|
47
|
+
* with `analyze-logs.py --dir /logs` from outside. Falls back to
|
|
48
|
+
* `os.tmpdir()/opencode-diane/` everywhere else; on a fresh host that
|
|
49
|
+
* is `/tmp/opencode-diane/` on Linux and a per-user temp folder on
|
|
50
|
+
* macOS / Windows. Exported for tests + docs.
|
|
51
|
+
*/
|
|
52
|
+
export function richLogsDir() {
|
|
53
|
+
const override = process.env.OPENCODE_DIANE_LOG_DIR;
|
|
54
|
+
if (override && override.length > 0)
|
|
55
|
+
return override;
|
|
56
|
+
return join(tmpdir(), "opencode-diane");
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Best-effort sanitiser for values put on a structured event payload —
|
|
60
|
+
* trims long strings to keep the log file manageable and caps arrays
|
|
61
|
+
* at a sensible length. Used to shape the `args` field of a
|
|
62
|
+
* `tool.call` event before it lands on disk: a tool's free-form
|
|
63
|
+
* `query` or `content` field could in principle be many KB, and we
|
|
64
|
+
* don't want a single log line to dominate the file. The marker
|
|
65
|
+
* `…(+N chars)` / `…(+N items)` is preserved so a reader knows the
|
|
66
|
+
* truncation happened. Returns the trimmed value; doesn't mutate the
|
|
67
|
+
* input. Shallow-recurses through plain objects and arrays — there's
|
|
68
|
+
* no cycle protection because tool args are flat data, but a try
|
|
69
|
+
* around the caller's `JSON.stringify` still catches anything weird.
|
|
70
|
+
*/
|
|
71
|
+
export function truncateForLog(value, maxStringLength = 500) {
|
|
72
|
+
if (typeof value === "string") {
|
|
73
|
+
if (value.length <= maxStringLength)
|
|
74
|
+
return value;
|
|
75
|
+
return value.slice(0, maxStringLength) + `…(+${value.length - maxStringLength} chars)`;
|
|
76
|
+
}
|
|
77
|
+
if (Array.isArray(value)) {
|
|
78
|
+
const MAX_ITEMS = 20;
|
|
79
|
+
const head = value.slice(0, MAX_ITEMS).map((v) => truncateForLog(v, maxStringLength));
|
|
80
|
+
return value.length > MAX_ITEMS
|
|
81
|
+
? [...head, `…(+${value.length - MAX_ITEMS} items)`]
|
|
82
|
+
: head;
|
|
83
|
+
}
|
|
84
|
+
if (value && typeof value === "object") {
|
|
85
|
+
const out = {};
|
|
86
|
+
for (const [k, v] of Object.entries(value)) {
|
|
87
|
+
out[k] = truncateForLog(v, maxStringLength);
|
|
88
|
+
}
|
|
89
|
+
return out;
|
|
90
|
+
}
|
|
91
|
+
return value;
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Build the per-session filename. Public so tests can predict the
|
|
95
|
+
* shape (the timestamp is "now" and the pid is `process.pid`, so the
|
|
96
|
+
* test doesn't actually call this — it reads `logger.path()`). The
|
|
97
|
+
* ISO timestamp has its colons and dots replaced with dashes so the
|
|
98
|
+
* filename is portable across filesystems.
|
|
99
|
+
*/
|
|
100
|
+
function buildFilename(service, when, pid) {
|
|
101
|
+
const ts = when.toISOString().replace(/[:.]/g, "-");
|
|
102
|
+
return `${service}-${ts}-pid${pid}.jsonl`;
|
|
103
|
+
}
|
|
104
|
+
class FileLoggerImpl {
|
|
105
|
+
/** Open file descriptor, or null once a write fails / after close. */
|
|
106
|
+
fd;
|
|
107
|
+
filePath;
|
|
108
|
+
base;
|
|
109
|
+
constructor(opts) {
|
|
110
|
+
// `service` is part of the filename, so a stray slash or NUL would
|
|
111
|
+
// be a path-injection foot-gun. Tolerate weird values by sanitising.
|
|
112
|
+
const safeService = opts.service.replace(/[^A-Za-z0-9._-]/g, "_") || "service";
|
|
113
|
+
const dir = richLogsDir();
|
|
114
|
+
try {
|
|
115
|
+
mkdirSync(dir, { recursive: true });
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
/* tolerated — openSync below will throw, we'll go silent */
|
|
119
|
+
}
|
|
120
|
+
this.filePath = join(dir, buildFilename(safeService, new Date(), process.pid));
|
|
121
|
+
this.base = { service: opts.service, ...(opts.base ?? {}) };
|
|
122
|
+
// Synchronous open + write semantics. Why not createWriteStream:
|
|
123
|
+
// WriteStream buffers writes (16KB highWaterMark by default) and
|
|
124
|
+
// flushes asynchronously; an event "logged" right before a crash
|
|
125
|
+
// can be lost, and the very test below this code initially flaked
|
|
126
|
+
// on that. For debug logs reliability beats per-write speed —
|
|
127
|
+
// log lines are at human pace, not microseconds. A single openSync
|
|
128
|
+
// at construction + writeSync per record gives us atomic appends
|
|
129
|
+
// (POSIX guarantees this for writes ≤ PIPE_BUF, our records are
|
|
130
|
+
// ~200 bytes) and durability on syscall return.
|
|
131
|
+
//
|
|
132
|
+
// "a" flag = O_APPEND | O_CREAT | O_WRONLY. Append is the right
|
|
133
|
+
// semantics here: multiple loggers (parallel sessions) can target
|
|
134
|
+
// the same path without clobbering, and the kernel serialises
|
|
135
|
+
// appends so lines never interleave mid-record.
|
|
136
|
+
let fd = null;
|
|
137
|
+
try {
|
|
138
|
+
fd = openSync(this.filePath, "a");
|
|
139
|
+
}
|
|
140
|
+
catch {
|
|
141
|
+
fd = null;
|
|
142
|
+
}
|
|
143
|
+
this.fd = fd;
|
|
144
|
+
// Header record — gives any reader of the file immediate context
|
|
145
|
+
// (which plugin, which process, which node version, which cwd).
|
|
146
|
+
this.event("session.start", {
|
|
147
|
+
pid: process.pid,
|
|
148
|
+
node: process.version,
|
|
149
|
+
platform: process.platform,
|
|
150
|
+
cwd: process.cwd(),
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
log(level, message) {
|
|
154
|
+
this.write({ level, message });
|
|
155
|
+
}
|
|
156
|
+
event(name, data) {
|
|
157
|
+
this.write({ event: name, ...(data ?? {}) });
|
|
158
|
+
}
|
|
159
|
+
write(fields) {
|
|
160
|
+
if (this.fd === null)
|
|
161
|
+
return;
|
|
162
|
+
// Order matters for readability: ts and service first, then base
|
|
163
|
+
// fields (root, etc.), then the event-specific payload last. Use
|
|
164
|
+
// ISO time with ms — coarser timestamps make ordering ambiguous
|
|
165
|
+
// when multiple events fire in the same loop tick.
|
|
166
|
+
let line;
|
|
167
|
+
try {
|
|
168
|
+
const rec = { ts: new Date().toISOString(), ...this.base, ...fields };
|
|
169
|
+
line = JSON.stringify(rec) + "\n";
|
|
170
|
+
}
|
|
171
|
+
catch {
|
|
172
|
+
// Unserialisable payload (e.g. a value containing a BigInt or a
|
|
173
|
+
// circular reference). Fall back to a safe placeholder rather
|
|
174
|
+
// than swallowing the event entirely.
|
|
175
|
+
line =
|
|
176
|
+
JSON.stringify({
|
|
177
|
+
ts: new Date().toISOString(),
|
|
178
|
+
...this.base,
|
|
179
|
+
event: "log.write_failed",
|
|
180
|
+
attempted: String(fields.event ?? fields.level ?? "?"),
|
|
181
|
+
}) + "\n";
|
|
182
|
+
}
|
|
183
|
+
try {
|
|
184
|
+
writeSync(this.fd, line);
|
|
185
|
+
}
|
|
186
|
+
catch {
|
|
187
|
+
// Disk full, fd closed underneath us, etc. — drop the fd and go
|
|
188
|
+
// silent for the rest of the session. Never propagate.
|
|
189
|
+
try {
|
|
190
|
+
closeSync(this.fd);
|
|
191
|
+
}
|
|
192
|
+
catch {
|
|
193
|
+
/* ignore */
|
|
194
|
+
}
|
|
195
|
+
this.fd = null;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
path() {
|
|
199
|
+
return this.filePath;
|
|
200
|
+
}
|
|
201
|
+
close() {
|
|
202
|
+
if (this.fd === null)
|
|
203
|
+
return;
|
|
204
|
+
try {
|
|
205
|
+
closeSync(this.fd);
|
|
206
|
+
}
|
|
207
|
+
catch {
|
|
208
|
+
/* ignore — we're shutting down anyway */
|
|
209
|
+
}
|
|
210
|
+
this.fd = null;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
export function createFileLogger(opts) {
|
|
214
|
+
return new FileLoggerImpl(opts);
|
|
215
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* peer-detection.ts — detect known coexisting OpenCode plugins by
|
|
3
|
+
* reading the user's `opencode.json` config(s). Pure result: a record
|
|
4
|
+
* naming which peers are listed alongside us, used to apply
|
|
5
|
+
* compatibility defaults at startup.
|
|
6
|
+
*
|
|
7
|
+
* **Conservative by design.** This file knows only about plugins we
|
|
8
|
+
* have actually validated against (oh-my-opencode and caveman today)
|
|
9
|
+
* and only triggers when the user has listed them explicitly. It
|
|
10
|
+
* never sniffs running processes, never imports peer packages, and
|
|
11
|
+
* never modifies anything on disk. Standalone — when no peer is
|
|
12
|
+
* found — behaviour is byte-for-byte the documented default.
|
|
13
|
+
*
|
|
14
|
+
* Why detect at all: two compatibility decisions need to be made at
|
|
15
|
+
* startup, not at recall-time:
|
|
16
|
+
* - whether to install the `tool.execute.after` nudge (oh-my-opencode
|
|
17
|
+
* also rewrites tool output and two plugins both touching
|
|
18
|
+
* `output.output` interleave unpredictably);
|
|
19
|
+
* - whether to namespace mined skill subdirectories so we don't
|
|
20
|
+
* write into the same slugs caveman creates (`caveman`,
|
|
21
|
+
* `caveman-commit`, etc.) under the shared `.opencode/skills/`
|
|
22
|
+
* directory OpenCode discovers from.
|
|
23
|
+
*
|
|
24
|
+
* Both are also user-overrideable via explicit config — auto-detection
|
|
25
|
+
* fills the option ONLY when the user didn't.
|
|
26
|
+
*/
|
|
27
|
+
export interface PeerPlugins {
|
|
28
|
+
/** oh-my-opencode (or its newer rename oh-my-openagent, or the slim
|
|
29
|
+
* fork) is listed in an opencode config we can see. */
|
|
30
|
+
ohMyOpencode: boolean;
|
|
31
|
+
/** A caveman variant is listed. Multiple npm packages exist
|
|
32
|
+
* (`caveman-opencode-plugin`, `caveman-opencode`, `opencode-caveman`,
|
|
33
|
+
* and the in-repo `caveman` plugin from JuliusBrussee/caveman) so
|
|
34
|
+
* we match any of them. */
|
|
35
|
+
caveman: boolean;
|
|
36
|
+
/** Raw list of plugin names we read, for the startup log line. */
|
|
37
|
+
found: string[];
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Read project-local and user-global opencode config files and return
|
|
41
|
+
* which known peers appear in the `plugin` array. Plugin entries can
|
|
42
|
+
* be either a string `"name"` or an array `["name", options]`; we
|
|
43
|
+
* pick out the name in either shape.
|
|
44
|
+
*/
|
|
45
|
+
export declare function detectPeerPlugins(projectRoot: string): PeerPlugins;
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* peer-detection.ts — detect known coexisting OpenCode plugins by
|
|
3
|
+
* reading the user's `opencode.json` config(s). Pure result: a record
|
|
4
|
+
* naming which peers are listed alongside us, used to apply
|
|
5
|
+
* compatibility defaults at startup.
|
|
6
|
+
*
|
|
7
|
+
* **Conservative by design.** This file knows only about plugins we
|
|
8
|
+
* have actually validated against (oh-my-opencode and caveman today)
|
|
9
|
+
* and only triggers when the user has listed them explicitly. It
|
|
10
|
+
* never sniffs running processes, never imports peer packages, and
|
|
11
|
+
* never modifies anything on disk. Standalone — when no peer is
|
|
12
|
+
* found — behaviour is byte-for-byte the documented default.
|
|
13
|
+
*
|
|
14
|
+
* Why detect at all: two compatibility decisions need to be made at
|
|
15
|
+
* startup, not at recall-time:
|
|
16
|
+
* - whether to install the `tool.execute.after` nudge (oh-my-opencode
|
|
17
|
+
* also rewrites tool output and two plugins both touching
|
|
18
|
+
* `output.output` interleave unpredictably);
|
|
19
|
+
* - whether to namespace mined skill subdirectories so we don't
|
|
20
|
+
* write into the same slugs caveman creates (`caveman`,
|
|
21
|
+
* `caveman-commit`, etc.) under the shared `.opencode/skills/`
|
|
22
|
+
* directory OpenCode discovers from.
|
|
23
|
+
*
|
|
24
|
+
* Both are also user-overrideable via explicit config — auto-detection
|
|
25
|
+
* fills the option ONLY when the user didn't.
|
|
26
|
+
*/
|
|
27
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
28
|
+
import { homedir } from "node:os";
|
|
29
|
+
import { join } from "node:path";
|
|
30
|
+
const OH_MY_OPENCODE = /^(oh-my-opencode(-slim)?|oh-my-openagent)$/i;
|
|
31
|
+
const CAVEMAN = /(^|\/|@)(caveman-opencode(-plugin)?|opencode-caveman|caveman)$/i;
|
|
32
|
+
/**
|
|
33
|
+
* Read project-local and user-global opencode config files and return
|
|
34
|
+
* which known peers appear in the `plugin` array. Plugin entries can
|
|
35
|
+
* be either a string `"name"` or an array `["name", options]`; we
|
|
36
|
+
* pick out the name in either shape.
|
|
37
|
+
*/
|
|
38
|
+
export function detectPeerPlugins(projectRoot) {
|
|
39
|
+
// Same search path OpenCode itself uses: project first, then global.
|
|
40
|
+
// We take the UNION — if a plugin is listed in either, it counts.
|
|
41
|
+
const candidates = [
|
|
42
|
+
join(projectRoot, "opencode.json"),
|
|
43
|
+
join(projectRoot, "opencode.jsonc"),
|
|
44
|
+
join(homedir(), ".config", "opencode", "opencode.json"),
|
|
45
|
+
join(homedir(), ".config", "opencode", "opencode.jsonc"),
|
|
46
|
+
];
|
|
47
|
+
const names = [];
|
|
48
|
+
for (const path of candidates) {
|
|
49
|
+
if (!existsSync(path))
|
|
50
|
+
continue;
|
|
51
|
+
try {
|
|
52
|
+
const text = readFileSync(path, "utf-8");
|
|
53
|
+
const cfg = JSON.parse(stripJsoncComments(text));
|
|
54
|
+
const arr = Array.isArray(cfg.plugin) ? cfg.plugin : [];
|
|
55
|
+
for (const entry of arr) {
|
|
56
|
+
if (typeof entry === "string") {
|
|
57
|
+
names.push(entry);
|
|
58
|
+
}
|
|
59
|
+
else if (Array.isArray(entry) && typeof entry[0] === "string") {
|
|
60
|
+
names.push(entry[0]);
|
|
61
|
+
}
|
|
62
|
+
else if (entry && typeof entry === "object" && typeof entry.name === "string") {
|
|
63
|
+
names.push(entry.name);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
// Unreadable or non-JSON config — move on; this is best-effort
|
|
69
|
+
// detection, not a validation pass.
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
const unique = Array.from(new Set(names));
|
|
73
|
+
return {
|
|
74
|
+
ohMyOpencode: unique.some((n) => OH_MY_OPENCODE.test(n)),
|
|
75
|
+
caveman: unique.some((n) => CAVEMAN.test(n)),
|
|
76
|
+
found: unique,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Strip `/* ... *\/` and `//` line comments from a JSONC-style string.
|
|
81
|
+
* Conservative — doesn't handle `//` inside string literals, which is
|
|
82
|
+
* effectively never the case in an `opencode.json` plugin array. If
|
|
83
|
+
* JSON.parse still fails after stripping, the caller treats the file
|
|
84
|
+
* as unreadable and moves on.
|
|
85
|
+
*/
|
|
86
|
+
function stripJsoncComments(text) {
|
|
87
|
+
return text
|
|
88
|
+
.replace(/\/\*[\s\S]*?\*\//g, "")
|
|
89
|
+
.replace(/^\s*\/\/.*$/gm, "");
|
|
90
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tiny exec wrapper for synchronous git calls during ingestion.
|
|
3
|
+
*
|
|
4
|
+
* - returns stdout as a string when the command exits 0
|
|
5
|
+
* - returns null if git isn't installed or the command failed
|
|
6
|
+
* (never throws — ingestion is best-effort)
|
|
7
|
+
*/
|
|
8
|
+
export declare function runGit(args: string[], cwd: string, maxBufferMB?: number): Promise<string | null>;
|
|
9
|
+
/** True if `git` is on PATH and `cwd` is inside a git work tree. */
|
|
10
|
+
export declare function isGitRepo(cwd: string): Promise<boolean>;
|
|
11
|
+
/**
|
|
12
|
+
* The current HEAD commit SHA, or null if the repo has no commits yet
|
|
13
|
+
* or git isn't available. Cheap (microseconds for git itself, dominated
|
|
14
|
+
* by the process-spawn overhead). Suitable for post-bash polling to
|
|
15
|
+
* detect pull/merge/rebase/reset/checkout side effects.
|
|
16
|
+
*/
|
|
17
|
+
export declare function currentHead(cwd: string): Promise<string | null>;
|
|
18
|
+
/**
|
|
19
|
+
* Files modified or newly created in the working tree, parsed from
|
|
20
|
+
* `git status --porcelain=v1`. Returns an empty array if not a git
|
|
21
|
+
* repo or if the call fails — never throws.
|
|
22
|
+
*
|
|
23
|
+
* Selection rules:
|
|
24
|
+
* - Modified, added, copied, untracked files → included (returned path
|
|
25
|
+
* is the on-disk one).
|
|
26
|
+
* - Renames (`R`) → the destination path is returned (the old name has
|
|
27
|
+
* nothing on disk to re-index).
|
|
28
|
+
* - Deletions in EITHER staging column → skipped (no file on disk).
|
|
29
|
+
*
|
|
30
|
+
* Path handling:
|
|
31
|
+
* - C-quoted paths (git's escape for spaces / control chars in
|
|
32
|
+
* filenames) are unquoted with `JSON.parse`. Unparseable quoting
|
|
33
|
+
* falls back to the raw form.
|
|
34
|
+
* - Trailing `\r` from CRLF line endings is stripped (defensive — git
|
|
35
|
+
* normally outputs LF even on Windows, but third-party tools that
|
|
36
|
+
* pipe through `cmd.exe` can introduce it).
|
|
37
|
+
*
|
|
38
|
+
* Intended use: poll right after a `bash` tool call to find files the
|
|
39
|
+
* shell command touched that the code-map index does not know about.
|
|
40
|
+
* Cap callers' usage with their own per-call file limit — `bash`
|
|
41
|
+
* commands like `git checkout other-branch` can return thousands.
|
|
42
|
+
*/
|
|
43
|
+
export declare function changedFilesInWorktree(cwd: string): Promise<string[]>;
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tiny exec wrapper for synchronous git calls during ingestion.
|
|
3
|
+
*
|
|
4
|
+
* - returns stdout as a string when the command exits 0
|
|
5
|
+
* - returns null if git isn't installed or the command failed
|
|
6
|
+
* (never throws — ingestion is best-effort)
|
|
7
|
+
*/
|
|
8
|
+
import { execFile } from "node:child_process";
|
|
9
|
+
import { promisify } from "node:util";
|
|
10
|
+
const execFileP = promisify(execFile);
|
|
11
|
+
export async function runGit(args, cwd, maxBufferMB = 16) {
|
|
12
|
+
try {
|
|
13
|
+
const { stdout } = await execFileP("git", args, {
|
|
14
|
+
cwd,
|
|
15
|
+
maxBuffer: maxBufferMB * 1024 * 1024,
|
|
16
|
+
timeout: 30_000,
|
|
17
|
+
});
|
|
18
|
+
return stdout;
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
/** True if `git` is on PATH and `cwd` is inside a git work tree. */
|
|
25
|
+
export async function isGitRepo(cwd) {
|
|
26
|
+
const out = await runGit(["rev-parse", "--is-inside-work-tree"], cwd);
|
|
27
|
+
return out !== null && out.trim() === "true";
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* The current HEAD commit SHA, or null if the repo has no commits yet
|
|
31
|
+
* or git isn't available. Cheap (microseconds for git itself, dominated
|
|
32
|
+
* by the process-spawn overhead). Suitable for post-bash polling to
|
|
33
|
+
* detect pull/merge/rebase/reset/checkout side effects.
|
|
34
|
+
*/
|
|
35
|
+
export async function currentHead(cwd) {
|
|
36
|
+
const out = await runGit(["rev-parse", "HEAD"], cwd);
|
|
37
|
+
if (out === null)
|
|
38
|
+
return null;
|
|
39
|
+
const trimmed = out.trim();
|
|
40
|
+
// `rev-parse HEAD` on an empty repo emits the literal string "HEAD"
|
|
41
|
+
// to stderr and exits non-zero, so a non-null result here is already
|
|
42
|
+
// a real SHA. Belt-and-braces sanity check on length.
|
|
43
|
+
return trimmed.length >= 7 && /^[0-9a-f]+$/i.test(trimmed) ? trimmed : null;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Files modified or newly created in the working tree, parsed from
|
|
47
|
+
* `git status --porcelain=v1`. Returns an empty array if not a git
|
|
48
|
+
* repo or if the call fails — never throws.
|
|
49
|
+
*
|
|
50
|
+
* Selection rules:
|
|
51
|
+
* - Modified, added, copied, untracked files → included (returned path
|
|
52
|
+
* is the on-disk one).
|
|
53
|
+
* - Renames (`R`) → the destination path is returned (the old name has
|
|
54
|
+
* nothing on disk to re-index).
|
|
55
|
+
* - Deletions in EITHER staging column → skipped (no file on disk).
|
|
56
|
+
*
|
|
57
|
+
* Path handling:
|
|
58
|
+
* - C-quoted paths (git's escape for spaces / control chars in
|
|
59
|
+
* filenames) are unquoted with `JSON.parse`. Unparseable quoting
|
|
60
|
+
* falls back to the raw form.
|
|
61
|
+
* - Trailing `\r` from CRLF line endings is stripped (defensive — git
|
|
62
|
+
* normally outputs LF even on Windows, but third-party tools that
|
|
63
|
+
* pipe through `cmd.exe` can introduce it).
|
|
64
|
+
*
|
|
65
|
+
* Intended use: poll right after a `bash` tool call to find files the
|
|
66
|
+
* shell command touched that the code-map index does not know about.
|
|
67
|
+
* Cap callers' usage with their own per-call file limit — `bash`
|
|
68
|
+
* commands like `git checkout other-branch` can return thousands.
|
|
69
|
+
*/
|
|
70
|
+
export async function changedFilesInWorktree(cwd) {
|
|
71
|
+
const out = await runGit(["status", "--porcelain=v1", "--untracked-files=all"], cwd);
|
|
72
|
+
if (out === null)
|
|
73
|
+
return [];
|
|
74
|
+
const files = [];
|
|
75
|
+
for (const rawLine of out.split("\n")) {
|
|
76
|
+
// Normalise CRLF defensively.
|
|
77
|
+
const line = rawLine.replace(/\r$/, "");
|
|
78
|
+
// Porcelain v1 format: XY<space>path[ -> renamed_path]
|
|
79
|
+
// Minimum well-formed line is `XY p` (4 chars).
|
|
80
|
+
if (line.length < 4)
|
|
81
|
+
continue;
|
|
82
|
+
const xy = line.slice(0, 2);
|
|
83
|
+
let path = line.slice(3);
|
|
84
|
+
// Skip any line where EITHER column indicates a deletion — that file
|
|
85
|
+
// is gone from disk (or about to be) and there's nothing to refresh.
|
|
86
|
+
// Covers `D `, ` D`, `DD`, `MD`, `AD`, `RD`, `CD`, …
|
|
87
|
+
if (xy[0] === "D" || xy[1] === "D")
|
|
88
|
+
continue;
|
|
89
|
+
// Renames / copies: `R` or `C` in column 0. The path looks like
|
|
90
|
+
// `src/old.ts -> src/new.ts`; keep the destination (the file that
|
|
91
|
+
// exists on disk).
|
|
92
|
+
if (xy[0] === "R" || xy[0] === "C") {
|
|
93
|
+
const arrow = path.indexOf(" -> ");
|
|
94
|
+
if (arrow >= 0)
|
|
95
|
+
path = path.slice(arrow + 4);
|
|
96
|
+
}
|
|
97
|
+
// Unquote git's C-quoted paths (when path contains special chars).
|
|
98
|
+
if (path.startsWith('"') && path.endsWith('"')) {
|
|
99
|
+
try {
|
|
100
|
+
path = JSON.parse(path);
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
/* unparseable quoting — keep the raw form */
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
if (path.length > 0)
|
|
107
|
+
files.push(path);
|
|
108
|
+
}
|
|
109
|
+
return files;
|
|
110
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* usage-skill.ts — soft-forces the agent to actually USE Diane.
|
|
3
|
+
*
|
|
4
|
+
* OpenCode discovers any `.opencode/skills/<name>/SKILL.md` in the
|
|
5
|
+
* project and surfaces its content to the agent at session start, so
|
|
6
|
+
* a skill file is the highest-signal place to put "here's how to use
|
|
7
|
+
* this plugin, here's when to call which tool" instructions. This
|
|
8
|
+
* module writes that file at plugin startup.
|
|
9
|
+
*
|
|
10
|
+
* **Soft, not hard.** The skill is installed ONLY when the file does
|
|
11
|
+
* not already exist, so a user can:
|
|
12
|
+
* - delete the file to remove the nudge (it won't come back unless
|
|
13
|
+
* they re-enable installation explicitly with a fresh repo),
|
|
14
|
+
* - edit the file to customise the wording (their edits survive
|
|
15
|
+
* every subsequent startup),
|
|
16
|
+
* - set `installUsageSkill: false` to never write it at all.
|
|
17
|
+
*
|
|
18
|
+
* The skill content is fixed text (no codegen, no templating) so the
|
|
19
|
+
* agent's instructions don't churn version-over-version.
|
|
20
|
+
*/
|
|
21
|
+
/** Subdirectory name relative to `skillsOutputDir`. The prefix
|
|
22
|
+
* (`""` standalone, `"diane-"` when a peer plugin is detected) is
|
|
23
|
+
* applied at the call site so this stays one name in one place. */
|
|
24
|
+
export declare const USAGE_SKILL_SLUG = "using-memory";
|
|
25
|
+
/**
|
|
26
|
+
* Write the SKILL.md if (and only if) it does not already exist.
|
|
27
|
+
* Returns one of three outcomes for the caller to log:
|
|
28
|
+
*
|
|
29
|
+
* - "installed" — file did not exist; we wrote it.
|
|
30
|
+
* - "preserved" — file already exists; we left it alone (user
|
|
31
|
+
* customisation, or already installed).
|
|
32
|
+
* - "failed" — write threw (read-only project root, etc.);
|
|
33
|
+
* the error is returned so the caller logs it.
|
|
34
|
+
* Never propagates: a failed soft-force is a
|
|
35
|
+
* quality-of-life regression, not a reason to
|
|
36
|
+
* crash the plugin.
|
|
37
|
+
*/
|
|
38
|
+
export declare function installUsageSkill(root: string, skillsOutputDir: string, slugPrefix: string): {
|
|
39
|
+
outcome: "installed" | "preserved" | "failed";
|
|
40
|
+
path: string;
|
|
41
|
+
error?: unknown;
|
|
42
|
+
};
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* usage-skill.ts — soft-forces the agent to actually USE Diane.
|
|
3
|
+
*
|
|
4
|
+
* OpenCode discovers any `.opencode/skills/<name>/SKILL.md` in the
|
|
5
|
+
* project and surfaces its content to the agent at session start, so
|
|
6
|
+
* a skill file is the highest-signal place to put "here's how to use
|
|
7
|
+
* this plugin, here's when to call which tool" instructions. This
|
|
8
|
+
* module writes that file at plugin startup.
|
|
9
|
+
*
|
|
10
|
+
* **Soft, not hard.** The skill is installed ONLY when the file does
|
|
11
|
+
* not already exist, so a user can:
|
|
12
|
+
* - delete the file to remove the nudge (it won't come back unless
|
|
13
|
+
* they re-enable installation explicitly with a fresh repo),
|
|
14
|
+
* - edit the file to customise the wording (their edits survive
|
|
15
|
+
* every subsequent startup),
|
|
16
|
+
* - set `installUsageSkill: false` to never write it at all.
|
|
17
|
+
*
|
|
18
|
+
* The skill content is fixed text (no codegen, no templating) so the
|
|
19
|
+
* agent's instructions don't churn version-over-version.
|
|
20
|
+
*/
|
|
21
|
+
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
|
22
|
+
import { dirname, join } from "node:path";
|
|
23
|
+
/** Subdirectory name relative to `skillsOutputDir`. The prefix
|
|
24
|
+
* (`""` standalone, `"diane-"` when a peer plugin is detected) is
|
|
25
|
+
* applied at the call site so this stays one name in one place. */
|
|
26
|
+
export const USAGE_SKILL_SLUG = "using-memory";
|
|
27
|
+
/** Filename inside the slug directory. OpenCode's skill discovery
|
|
28
|
+
* expects this name; do not rename. */
|
|
29
|
+
const SKILL_FILENAME = "SKILL.md";
|
|
30
|
+
/**
|
|
31
|
+
* Skill content surfaced to the agent. Hand-tuned wording — short,
|
|
32
|
+
* directive, ordered by what the agent does first.
|
|
33
|
+
*
|
|
34
|
+
* Three design notes for future editors:
|
|
35
|
+
* 1. Lead with the workflow, not the architecture. The agent needs
|
|
36
|
+
* to know *what to do first*, not *how the recall is scored*.
|
|
37
|
+
* 2. Quantify the savings ("typically replaces 3-8 raw calls") so
|
|
38
|
+
* the agent has a concrete reason to choose recall over grep.
|
|
39
|
+
* 3. Keep the tool list flat. Every line is one tool, one purpose;
|
|
40
|
+
* do not group or nest, the agent reads this fast.
|
|
41
|
+
*/
|
|
42
|
+
function skillContent() {
|
|
43
|
+
return `---
|
|
44
|
+
name: using-memory
|
|
45
|
+
description: ALWAYS call memory_recall before raw code discovery (grep, glob, read). The opencode-diane plugin keeps a persistent searchable store of this repo's structure, git history, project facts, and past sessions — a single recall typically replaces 3-8 raw discovery calls.
|
|
46
|
+
---
|
|
47
|
+
|
|
48
|
+
# Using opencode-diane's memory
|
|
49
|
+
|
|
50
|
+
This project has the \`opencode-diane\` plugin loaded. It keeps a
|
|
51
|
+
persistent, searchable store of structural facts about this repo so
|
|
52
|
+
the same things don't have to be re-discovered every session.
|
|
53
|
+
|
|
54
|
+
## Workflow — do this in order
|
|
55
|
+
|
|
56
|
+
For ANY task that touches existing code, your first step is **always**:
|
|
57
|
+
|
|
58
|
+
1. **\`memory_recall { query: "<what you're looking for>" }\`** — a
|
|
59
|
+
single recall typically replaces 3-8 raw \`grep\`/\`glob\`/\`read\`
|
|
60
|
+
calls. The store already knows the code map, git history, project
|
|
61
|
+
facts, and lessons from past sessions in this repo. Try it first;
|
|
62
|
+
only fall back to raw discovery if it returns nothing relevant.
|
|
63
|
+
|
|
64
|
+
2. **Targeted file reads** — after recall, \`read\` only the specific
|
|
65
|
+
files the recall pointed at. Skip directory-wide grepping unless
|
|
66
|
+
recall came up dry.
|
|
67
|
+
|
|
68
|
+
3. **\`memory_remember { content: "..." }\`** — when you discover
|
|
69
|
+
something worth keeping (an invariant, a non-obvious connection,
|
|
70
|
+
a file you'll touch again), save it so the next session inherits
|
|
71
|
+
the finding instead of re-deriving it.
|
|
72
|
+
|
|
73
|
+
## Tools available
|
|
74
|
+
|
|
75
|
+
- \`memory_recall\` — query the store. **Call this first.**
|
|
76
|
+
- \`memory_status\` — store size, last-ingest times, plugin version.
|
|
77
|
+
Useful to confirm the plugin is actually loaded.
|
|
78
|
+
- \`memory_remember\` — save a fact for future sessions.
|
|
79
|
+
- \`memory_code_map\` — tree-sitter signatures for any file or
|
|
80
|
+
directory; the structural shape of the codebase.
|
|
81
|
+
- \`memory_outline\` — compact outline of one file.
|
|
82
|
+
- \`memory_ingest_sessions\` — pull lessons from past OpenCode sessions.
|
|
83
|
+
- \`memory_ingest_code_health\` — lint/typecheck/test signal as
|
|
84
|
+
memories.
|
|
85
|
+
- \`memory_mine_skills\` — distill recurring task patterns into
|
|
86
|
+
\`SKILL.md\` files.
|
|
87
|
+
- \`memory_skill\` — read one mined skill.
|
|
88
|
+
|
|
89
|
+
## When NOT to use memory
|
|
90
|
+
|
|
91
|
+
- Trivial one-off file reads (\`read package.json\`) — skip recall.
|
|
92
|
+
- Writing brand-new code with no existing context — recall first
|
|
93
|
+
anyway, but the answer may be empty; that's fine.
|
|
94
|
+
|
|
95
|
+
This skill file was written by the plugin on first install. Delete it
|
|
96
|
+
to remove the nudge; edit it to customise; set
|
|
97
|
+
\`installUsageSkill: false\` in your \`opencode.json\` to never write
|
|
98
|
+
it at all.
|
|
99
|
+
`;
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Write the SKILL.md if (and only if) it does not already exist.
|
|
103
|
+
* Returns one of three outcomes for the caller to log:
|
|
104
|
+
*
|
|
105
|
+
* - "installed" — file did not exist; we wrote it.
|
|
106
|
+
* - "preserved" — file already exists; we left it alone (user
|
|
107
|
+
* customisation, or already installed).
|
|
108
|
+
* - "failed" — write threw (read-only project root, etc.);
|
|
109
|
+
* the error is returned so the caller logs it.
|
|
110
|
+
* Never propagates: a failed soft-force is a
|
|
111
|
+
* quality-of-life regression, not a reason to
|
|
112
|
+
* crash the plugin.
|
|
113
|
+
*/
|
|
114
|
+
export function installUsageSkill(root, skillsOutputDir, slugPrefix) {
|
|
115
|
+
const slug = `${slugPrefix}${USAGE_SKILL_SLUG}`;
|
|
116
|
+
const dir = join(root, skillsOutputDir, slug);
|
|
117
|
+
const path = join(dir, SKILL_FILENAME);
|
|
118
|
+
if (existsSync(path)) {
|
|
119
|
+
return { outcome: "preserved", path };
|
|
120
|
+
}
|
|
121
|
+
try {
|
|
122
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
123
|
+
writeFileSync(path, skillContent(), "utf-8");
|
|
124
|
+
return { outcome: "installed", path };
|
|
125
|
+
}
|
|
126
|
+
catch (error) {
|
|
127
|
+
return { outcome: "failed", path, error };
|
|
128
|
+
}
|
|
129
|
+
}
|