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.
Files changed (80) hide show
  1. package/CHANGELOG.md +180 -0
  2. package/LICENSE +21 -0
  3. package/README.md +206 -0
  4. package/WIKI.md +1430 -0
  5. package/dist/index.d.ts +28 -0
  6. package/dist/index.js +1632 -0
  7. package/dist/ingest/adaptive.d.ts +47 -0
  8. package/dist/ingest/adaptive.js +182 -0
  9. package/dist/ingest/code-health.d.ts +58 -0
  10. package/dist/ingest/code-health.js +202 -0
  11. package/dist/ingest/code-map.d.ts +71 -0
  12. package/dist/ingest/code-map.js +670 -0
  13. package/dist/ingest/cross-refs.d.ts +59 -0
  14. package/dist/ingest/cross-refs.js +1207 -0
  15. package/dist/ingest/docs.d.ts +49 -0
  16. package/dist/ingest/docs.js +325 -0
  17. package/dist/ingest/git.d.ts +77 -0
  18. package/dist/ingest/git.js +390 -0
  19. package/dist/ingest/live-session.d.ts +101 -0
  20. package/dist/ingest/live-session.js +173 -0
  21. package/dist/ingest/project-notes.d.ts +28 -0
  22. package/dist/ingest/project-notes.js +102 -0
  23. package/dist/ingest/project.d.ts +35 -0
  24. package/dist/ingest/project.js +430 -0
  25. package/dist/ingest/session-snapshot.d.ts +63 -0
  26. package/dist/ingest/session-snapshot.js +94 -0
  27. package/dist/ingest/sessions.d.ts +29 -0
  28. package/dist/ingest/sessions.js +164 -0
  29. package/dist/ingest/tables.d.ts +52 -0
  30. package/dist/ingest/tables.js +360 -0
  31. package/dist/mining/skill-miner.d.ts +53 -0
  32. package/dist/mining/skill-miner.js +234 -0
  33. package/dist/search/bm25.d.ts +81 -0
  34. package/dist/search/bm25.js +334 -0
  35. package/dist/search/e5-embedder.d.ts +30 -0
  36. package/dist/search/e5-embedder.js +91 -0
  37. package/dist/search/embed-pass.d.ts +26 -0
  38. package/dist/search/embed-pass.js +43 -0
  39. package/dist/search/embedder.d.ts +58 -0
  40. package/dist/search/embedder.js +85 -0
  41. package/dist/search/inverted-index.d.ts +51 -0
  42. package/dist/search/inverted-index.js +139 -0
  43. package/dist/search/ppr.d.ts +44 -0
  44. package/dist/search/ppr.js +118 -0
  45. package/dist/search/tokenize.d.ts +26 -0
  46. package/dist/search/tokenize.js +98 -0
  47. package/dist/store/eviction.d.ts +16 -0
  48. package/dist/store/eviction.js +37 -0
  49. package/dist/store/repository.d.ts +222 -0
  50. package/dist/store/repository.js +420 -0
  51. package/dist/store/sqlite-store.d.ts +89 -0
  52. package/dist/store/sqlite-store.js +252 -0
  53. package/dist/store/vector-store.d.ts +66 -0
  54. package/dist/store/vector-store.js +160 -0
  55. package/dist/types.d.ts +385 -0
  56. package/dist/types.js +9 -0
  57. package/dist/utils/file-log.d.ts +87 -0
  58. package/dist/utils/file-log.js +215 -0
  59. package/dist/utils/peer-detection.d.ts +45 -0
  60. package/dist/utils/peer-detection.js +90 -0
  61. package/dist/utils/shell.d.ts +43 -0
  62. package/dist/utils/shell.js +110 -0
  63. package/dist/utils/usage-skill.d.ts +42 -0
  64. package/dist/utils/usage-skill.js +129 -0
  65. package/dist/utils/xlsx.d.ts +36 -0
  66. package/dist/utils/xlsx.js +270 -0
  67. package/grammars/tree-sitter-c.wasm +0 -0
  68. package/grammars/tree-sitter-c_sharp.wasm +0 -0
  69. package/grammars/tree-sitter-cpp.wasm +0 -0
  70. package/grammars/tree-sitter-css.wasm +0 -0
  71. package/grammars/tree-sitter-go.wasm +0 -0
  72. package/grammars/tree-sitter-html.wasm +0 -0
  73. package/grammars/tree-sitter-java.wasm +0 -0
  74. package/grammars/tree-sitter-javascript.wasm +0 -0
  75. package/grammars/tree-sitter-json.wasm +0 -0
  76. package/grammars/tree-sitter-php.wasm +0 -0
  77. package/grammars/tree-sitter-python.wasm +0 -0
  78. package/grammars/tree-sitter-rust.wasm +0 -0
  79. package/grammars/tree-sitter-typescript.wasm +0 -0
  80. 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
+ }