@goondocks/myco 0.16.2 → 0.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CONTRIBUTING.md +3 -3
- package/README.md +4 -3
- package/dist/{agent-run-MNU2QWHR.js → agent-run-ZDHXORE2.js} +7 -7
- package/dist/{agent-tasks-NCRKUU6E.js → agent-tasks-IPUWMMDZ.js} +7 -7
- package/dist/{chunk-P6C6ADBU.js → chunk-2IJ6C63F.js} +2 -2
- package/dist/{chunk-V2ZBYKDU.js → chunk-2WRXLYG6.js} +3 -3
- package/dist/{chunk-TIAYBVSI.js → chunk-4JPUC6RL.js} +170 -56
- package/dist/chunk-4JPUC6RL.js.map +1 -0
- package/dist/{chunk-34NHDRWI.js → chunk-6SDC6V3N.js} +2 -2
- package/dist/{chunk-34NHDRWI.js.map → chunk-6SDC6V3N.js.map} +1 -1
- package/dist/{chunk-6JZEAOLG.js → chunk-CPL76CYD.js} +3 -3
- package/dist/{chunk-VWXDSDJU.js → chunk-DKSQMH5X.js} +2 -2
- package/dist/{chunk-GSKXOCFG.js → chunk-EBIYONNZ.js} +21 -1
- package/dist/chunk-EBIYONNZ.js.map +1 -0
- package/dist/{chunk-XAXQ72L3.js → chunk-FEX6ALLH.js} +2 -2
- package/dist/{chunk-UILSK6DK.js → chunk-GBYLHPML.js} +2 -2
- package/dist/{chunk-DZWSHCAC.js → chunk-KGL5QSDN.js} +2 -2
- package/dist/{chunk-4JVHWBZF.js → chunk-KKEMVH6D.js} +2 -2
- package/dist/{chunk-BNAYBGPH.js → chunk-MSM775XQ.js} +6 -5
- package/dist/{chunk-BNAYBGPH.js.map → chunk-MSM775XQ.js.map} +1 -1
- package/dist/{chunk-CJ2KTRWI.js → chunk-OGNEW5CN.js} +2 -2
- package/dist/{chunk-2E7YGLLN.js → chunk-PFF4KB4O.js} +2 -2
- package/dist/{chunk-4U6X35TH.js → chunk-QBB6LX57.js} +3 -3
- package/dist/{chunk-C3GNF7RJ.js → chunk-QQ7CXA7Q.js} +5 -5
- package/dist/{chunk-I3S6L7QC.js → chunk-U3SSOSIR.js} +2 -2
- package/dist/{chunk-RJRRHTAA.js → chunk-U63GDHYJ.js} +3 -3
- package/dist/{chunk-IRSNOBGD.js → chunk-UDBCAFXS.js} +2 -2
- package/dist/{chunk-SGYYOTNM.js → chunk-VGVRBSLC.js} +2 -2
- package/dist/{chunk-W7ZOOZMK.js → chunk-VQQ57UPG.js} +3 -3
- package/dist/{chunk-ZMODJWI5.js → chunk-XVWJ273L.js} +4 -4
- package/dist/{chunk-RPILIIYT.js → chunk-Y7QCKCEJ.js} +2 -2
- package/dist/{chunk-D63XTGBV.js → chunk-YTOD6L6N.js} +6 -6
- package/dist/{chunk-D2NTFSVO.js → chunk-ZUSTCXHT.js} +3 -3
- package/dist/{cli-RYYABF2X.js → cli-BRDOPJW3.js} +40 -40
- package/dist/{client-5VXKGNN2.js → client-AQZMD3LB.js} +4 -4
- package/dist/{config-VHHCGE4F.js → config-K3CJEFFO.js} +3 -3
- package/dist/{detect-6FNYONJF.js → detect-NJ2OREDP.js} +2 -2
- package/dist/{detect-providers-R7QOB3H6.js → detect-providers-OE6HWW3M.js} +4 -4
- package/dist/{doctor-M4Q7VCDO.js → doctor-YOUCIJJY.js} +14 -13
- package/dist/doctor-YOUCIJJY.js.map +1 -0
- package/dist/{executor-ULRFWJCH.js → executor-UYNV4EOQ.js} +17 -17
- package/dist/{init-AEHAQFPK.js → init-MTMEFG4E.js} +17 -17
- package/dist/{init-wizard-SVKDS3LR.js → init-wizard-ZS3CV6CM.js} +7 -7
- package/dist/{installer-AARSFXI6.js → installer-CTWQB4RS.js} +2 -2
- package/dist/{llm-LS7U7BHC.js → llm-PGETQHZ2.js} +7 -7
- package/dist/{loader-QDWQTBX4.js → loader-AVWL7PNO.js} +3 -3
- package/dist/{loader-YQDG5GI5.js → loader-J56KP27U.js} +3 -3
- package/dist/{main-GAGOE6XB.js → main-ZIMYW2AB.js} +105 -60
- package/dist/main-ZIMYW2AB.js.map +1 -0
- package/dist/{open-4QMAL32X.js → open-77QXL7VY.js} +7 -7
- package/dist/{openai-embeddings-FUW6CSN2.js → openai-embeddings-LZKY6RV5.js} +4 -4
- package/dist/{openrouter-YSIUSUQL.js → openrouter-UTOZG6Z5.js} +4 -4
- package/dist/{post-compact-OAWEBEDK.js → post-compact-CA7O7QPL.js} +7 -7
- package/dist/{post-tool-use-B3KOEOIM.js → post-tool-use-JNXJIGNK.js} +6 -6
- package/dist/{post-tool-use-failure-2I5ELTTN.js → post-tool-use-failure-V52CZWVZ.js} +7 -7
- package/dist/{pre-compact-NOXNJ5EV.js → pre-compact-J2GSDQCJ.js} +7 -7
- package/dist/{provider-check-VEYONGNU.js → provider-check-CESRPIY5.js} +4 -4
- package/dist/{registry-5R3DLJQH.js → registry-SPKP2WLI.js} +4 -4
- package/dist/{remove-LX4G6KP7.js → remove-ODPLWK7O.js} +9 -9
- package/dist/{resolution-events-CHOKR35X.js → resolution-events-CLDXZF67.js} +4 -4
- package/dist/{restart-WSNBSALP.js → restart-6KKZODOI.js} +8 -8
- package/dist/{search-Q6N3SHKP.js → search-U6N2DSOI.js} +8 -8
- package/dist/{server-OFRKA6N7.js → server-SP4G7MY7.js} +5 -5
- package/dist/{server-OFRKA6N7.js.map → server-SP4G7MY7.js.map} +1 -1
- package/dist/{session-SKXJLJYH.js → session-6R3RNVKD.js} +9 -9
- package/dist/{session-end-5EIVRCPS.js → session-end-TCFCYDBL.js} +6 -6
- package/dist/{session-start-OL2ICLED.js → session-start-XXN2TVNI.js} +11 -11
- package/dist/{setup-llm-BRNQW7K2.js → setup-llm-GISFI73Z.js} +8 -8
- package/dist/src/cli.js +1 -1
- package/dist/src/daemon/main.js +1 -1
- package/dist/src/hooks/post-tool-use.js +1 -1
- package/dist/src/hooks/session-end.js +1 -1
- package/dist/src/hooks/session-start.js +1 -1
- package/dist/src/hooks/stop.js +1 -1
- package/dist/src/hooks/user-prompt-submit.js +1 -1
- package/dist/src/mcp/server.js +1 -1
- package/dist/src/symbionts/manifests/claude-code.yaml +4 -0
- package/dist/src/symbionts/manifests/opencode.yaml +26 -0
- package/dist/src/symbionts/templates/opencode/mcp.json +6 -0
- package/dist/src/symbionts/templates/opencode/package.json +5 -0
- package/dist/src/symbionts/templates/opencode/plugin.ts +690 -0
- package/dist/src/symbionts/templates/opencode/settings.json +8 -0
- package/dist/{stats-U5FHDIR7.js → stats-MYQD4OVD.js} +9 -9
- package/dist/{stop-YUZNQBRQ.js → stop-K6TDJORY.js} +6 -6
- package/dist/{stop-failure-6WFAKH2U.js → stop-failure-U635O5A3.js} +7 -7
- package/dist/{subagent-start-GWJXAAH3.js → subagent-start-LLGQBC5Q.js} +7 -7
- package/dist/{subagent-stop-B44SMV2R.js → subagent-stop-BO3GZWXD.js} +7 -7
- package/dist/{task-completed-GIUFSRTP.js → task-completed-3ZJHSI2E.js} +7 -7
- package/dist/{team-3YI3UWB3.js → team-OJLQCVE3.js} +5 -5
- package/dist/ui/assets/{index-RYHXSJv1.js → index-2UyTdjlV.js} +12 -12
- package/dist/ui/assets/index-Cts1wLEW.css +1 -0
- package/dist/ui/index.html +2 -2
- package/dist/{update-QPRTLGYU.js → update-HR7WMESA.js} +9 -9
- package/dist/{user-prompt-submit-FSYEPW7W.js → user-prompt-submit-NM73R4I3.js} +6 -6
- package/dist/{verify-ITBMLK67.js → verify-PSERIZPF.js} +8 -8
- package/dist/{version-VS2EDHBG.js → version-YCODQZSX.js} +2 -2
- package/package.json +1 -1
- package/skills/rules/SKILL.md +1 -1
- package/dist/chunk-GSKXOCFG.js.map +0 -1
- package/dist/chunk-TIAYBVSI.js.map +0 -1
- package/dist/doctor-M4Q7VCDO.js.map +0 -1
- package/dist/main-GAGOE6XB.js.map +0 -1
- package/dist/ui/assets/index-Bjv_ck3c.css +0 -1
- /package/dist/{agent-run-MNU2QWHR.js.map → agent-run-ZDHXORE2.js.map} +0 -0
- /package/dist/{agent-tasks-NCRKUU6E.js.map → agent-tasks-IPUWMMDZ.js.map} +0 -0
- /package/dist/{chunk-P6C6ADBU.js.map → chunk-2IJ6C63F.js.map} +0 -0
- /package/dist/{chunk-V2ZBYKDU.js.map → chunk-2WRXLYG6.js.map} +0 -0
- /package/dist/{chunk-6JZEAOLG.js.map → chunk-CPL76CYD.js.map} +0 -0
- /package/dist/{chunk-VWXDSDJU.js.map → chunk-DKSQMH5X.js.map} +0 -0
- /package/dist/{chunk-XAXQ72L3.js.map → chunk-FEX6ALLH.js.map} +0 -0
- /package/dist/{chunk-UILSK6DK.js.map → chunk-GBYLHPML.js.map} +0 -0
- /package/dist/{chunk-DZWSHCAC.js.map → chunk-KGL5QSDN.js.map} +0 -0
- /package/dist/{chunk-4JVHWBZF.js.map → chunk-KKEMVH6D.js.map} +0 -0
- /package/dist/{chunk-CJ2KTRWI.js.map → chunk-OGNEW5CN.js.map} +0 -0
- /package/dist/{chunk-2E7YGLLN.js.map → chunk-PFF4KB4O.js.map} +0 -0
- /package/dist/{chunk-4U6X35TH.js.map → chunk-QBB6LX57.js.map} +0 -0
- /package/dist/{chunk-C3GNF7RJ.js.map → chunk-QQ7CXA7Q.js.map} +0 -0
- /package/dist/{chunk-I3S6L7QC.js.map → chunk-U3SSOSIR.js.map} +0 -0
- /package/dist/{chunk-RJRRHTAA.js.map → chunk-U63GDHYJ.js.map} +0 -0
- /package/dist/{chunk-IRSNOBGD.js.map → chunk-UDBCAFXS.js.map} +0 -0
- /package/dist/{chunk-SGYYOTNM.js.map → chunk-VGVRBSLC.js.map} +0 -0
- /package/dist/{chunk-W7ZOOZMK.js.map → chunk-VQQ57UPG.js.map} +0 -0
- /package/dist/{chunk-ZMODJWI5.js.map → chunk-XVWJ273L.js.map} +0 -0
- /package/dist/{chunk-RPILIIYT.js.map → chunk-Y7QCKCEJ.js.map} +0 -0
- /package/dist/{chunk-D63XTGBV.js.map → chunk-YTOD6L6N.js.map} +0 -0
- /package/dist/{chunk-D2NTFSVO.js.map → chunk-ZUSTCXHT.js.map} +0 -0
- /package/dist/{cli-RYYABF2X.js.map → cli-BRDOPJW3.js.map} +0 -0
- /package/dist/{client-5VXKGNN2.js.map → client-AQZMD3LB.js.map} +0 -0
- /package/dist/{config-VHHCGE4F.js.map → config-K3CJEFFO.js.map} +0 -0
- /package/dist/{detect-6FNYONJF.js.map → detect-NJ2OREDP.js.map} +0 -0
- /package/dist/{detect-providers-R7QOB3H6.js.map → detect-providers-OE6HWW3M.js.map} +0 -0
- /package/dist/{executor-ULRFWJCH.js.map → executor-UYNV4EOQ.js.map} +0 -0
- /package/dist/{init-AEHAQFPK.js.map → init-MTMEFG4E.js.map} +0 -0
- /package/dist/{init-wizard-SVKDS3LR.js.map → init-wizard-ZS3CV6CM.js.map} +0 -0
- /package/dist/{installer-AARSFXI6.js.map → installer-CTWQB4RS.js.map} +0 -0
- /package/dist/{llm-LS7U7BHC.js.map → llm-PGETQHZ2.js.map} +0 -0
- /package/dist/{loader-QDWQTBX4.js.map → loader-AVWL7PNO.js.map} +0 -0
- /package/dist/{loader-YQDG5GI5.js.map → loader-J56KP27U.js.map} +0 -0
- /package/dist/{open-4QMAL32X.js.map → open-77QXL7VY.js.map} +0 -0
- /package/dist/{openai-embeddings-FUW6CSN2.js.map → openai-embeddings-LZKY6RV5.js.map} +0 -0
- /package/dist/{openrouter-YSIUSUQL.js.map → openrouter-UTOZG6Z5.js.map} +0 -0
- /package/dist/{post-compact-OAWEBEDK.js.map → post-compact-CA7O7QPL.js.map} +0 -0
- /package/dist/{post-tool-use-B3KOEOIM.js.map → post-tool-use-JNXJIGNK.js.map} +0 -0
- /package/dist/{post-tool-use-failure-2I5ELTTN.js.map → post-tool-use-failure-V52CZWVZ.js.map} +0 -0
- /package/dist/{pre-compact-NOXNJ5EV.js.map → pre-compact-J2GSDQCJ.js.map} +0 -0
- /package/dist/{provider-check-VEYONGNU.js.map → provider-check-CESRPIY5.js.map} +0 -0
- /package/dist/{registry-5R3DLJQH.js.map → registry-SPKP2WLI.js.map} +0 -0
- /package/dist/{remove-LX4G6KP7.js.map → remove-ODPLWK7O.js.map} +0 -0
- /package/dist/{resolution-events-CHOKR35X.js.map → resolution-events-CLDXZF67.js.map} +0 -0
- /package/dist/{restart-WSNBSALP.js.map → restart-6KKZODOI.js.map} +0 -0
- /package/dist/{search-Q6N3SHKP.js.map → search-U6N2DSOI.js.map} +0 -0
- /package/dist/{session-SKXJLJYH.js.map → session-6R3RNVKD.js.map} +0 -0
- /package/dist/{session-end-5EIVRCPS.js.map → session-end-TCFCYDBL.js.map} +0 -0
- /package/dist/{session-start-OL2ICLED.js.map → session-start-XXN2TVNI.js.map} +0 -0
- /package/dist/{setup-llm-BRNQW7K2.js.map → setup-llm-GISFI73Z.js.map} +0 -0
- /package/dist/{stats-U5FHDIR7.js.map → stats-MYQD4OVD.js.map} +0 -0
- /package/dist/{stop-YUZNQBRQ.js.map → stop-K6TDJORY.js.map} +0 -0
- /package/dist/{stop-failure-6WFAKH2U.js.map → stop-failure-U635O5A3.js.map} +0 -0
- /package/dist/{subagent-start-GWJXAAH3.js.map → subagent-start-LLGQBC5Q.js.map} +0 -0
- /package/dist/{subagent-stop-B44SMV2R.js.map → subagent-stop-BO3GZWXD.js.map} +0 -0
- /package/dist/{task-completed-GIUFSRTP.js.map → task-completed-3ZJHSI2E.js.map} +0 -0
- /package/dist/{team-3YI3UWB3.js.map → team-OJLQCVE3.js.map} +0 -0
- /package/dist/{update-QPRTLGYU.js.map → update-HR7WMESA.js.map} +0 -0
- /package/dist/{user-prompt-submit-FSYEPW7W.js.map → user-prompt-submit-NM73R4I3.js.map} +0 -0
- /package/dist/{verify-ITBMLK67.js.map → verify-PSERIZPF.js.map} +0 -0
- /package/dist/{version-VS2EDHBG.js.map → version-YCODQZSX.js.map} +0 -0
|
@@ -0,0 +1,690 @@
|
|
|
1
|
+
// Managed by Myco. Regenerated on `myco update`. Edit src/symbionts/templates/opencode/plugin.ts in the Myco repo instead.
|
|
2
|
+
// myco:plugin-marker:opencode
|
|
3
|
+
//
|
|
4
|
+
// Myco Codebase Intelligence Plugin for OpenCode.
|
|
5
|
+
//
|
|
6
|
+
// This plugin runs inside opencode's Bun runtime and communicates with the local
|
|
7
|
+
// Myco daemon over HTTP — no subprocess spawns, no hook CLI, no stdin piping.
|
|
8
|
+
//
|
|
9
|
+
// Capture: POST /sessions/register, /sessions/unregister, /events, /events/stop
|
|
10
|
+
// Context: GET /api/digest
|
|
11
|
+
// Inject: client.session.prompt({ noReply: true, parts: [{ synthetic: true }] })
|
|
12
|
+
//
|
|
13
|
+
// See https://opencode.ai/docs/plugins/
|
|
14
|
+
//
|
|
15
|
+
// Degraded-mode safety: this plugin ships committed inside any project that has
|
|
16
|
+
// run `myco init` — the file lives at .opencode/plugins/myco.ts in that project's
|
|
17
|
+
// repo. When a teammate clones such a project WITHOUT having Myco installed
|
|
18
|
+
// locally, opencode will still load this plugin (the file is right there in the
|
|
19
|
+
// cloned repo). To stay invisible in that case, the plugin has NO external
|
|
20
|
+
// runtime imports — only node:fs and node:path, which are always available in
|
|
21
|
+
// Bun's runtime. Every path that would contact the Myco daemon gracefully no-ops
|
|
22
|
+
// when `.myco/daemon.json` is absent or the daemon is unreachable, so the plugin
|
|
23
|
+
// becomes invisible rather than throwing. Do NOT add runtime imports from
|
|
24
|
+
// @opencode-ai/plugin or any other package — that would break this guarantee.
|
|
25
|
+
|
|
26
|
+
import { readFileSync, appendFileSync, mkdirSync } from "node:fs";
|
|
27
|
+
import { join } from "node:path";
|
|
28
|
+
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
// Constants
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Keep in sync with `TOOL_OUTPUT_PREVIEW_CHARS` in src/constants.ts (currently 200).
|
|
35
|
+
* The plugin file is standalone and cannot import from Myco — this value is copied
|
|
36
|
+
* so every symbiont records tool_output previews at the same length.
|
|
37
|
+
*/
|
|
38
|
+
const TOOL_OUTPUT_PREVIEW_CHARS = 200;
|
|
39
|
+
|
|
40
|
+
/** Timeout for daemon HTTP calls — must be short so we never block opencode. */
|
|
41
|
+
const MYCO_FETCH_TIMEOUT_MS = 3000;
|
|
42
|
+
|
|
43
|
+
/** Tail window read from opencode when building the end-of-turn assistant summary. */
|
|
44
|
+
const SESSION_IDLE_TAIL_LIMIT = 12;
|
|
45
|
+
|
|
46
|
+
/** Heading prefix for compaction context — makes Myco's contribution recognizable in the compacted summary. */
|
|
47
|
+
const COMPACTION_HEADING = "## Myco — Project Context (preserved across compaction)\n\n";
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Marker set on the `metadata` field of every synthetic TextPartInput this
|
|
51
|
+
* plugin injects via `client.session.prompt({ noReply: true, ... })`. The
|
|
52
|
+
* `chat.message` handler checks for this marker and skips matching messages
|
|
53
|
+
* so the injection doesn't re-enter as if it were a new user prompt.
|
|
54
|
+
*
|
|
55
|
+
* Why not the `synthetic` flag? opencode's own prompt.ts uses `synthetic: true`
|
|
56
|
+
* for ~20 distinct internal purposes (plan-mode prompts, build-switch
|
|
57
|
+
* transitions, subagent task summaries, shell-impl wrappers). Filtering on
|
|
58
|
+
* the synthetic flag rejects legitimate user messages whenever opencode has
|
|
59
|
+
* appended one of its own synthetic parts — which caused real user prompts
|
|
60
|
+
* to silently drop in live testing.
|
|
61
|
+
*/
|
|
62
|
+
const MYCO_METADATA_MARKER = "myco";
|
|
63
|
+
|
|
64
|
+
// ---------------------------------------------------------------------------
|
|
65
|
+
// Types
|
|
66
|
+
// ---------------------------------------------------------------------------
|
|
67
|
+
|
|
68
|
+
type MessagePart = { type?: string; text?: string };
|
|
69
|
+
type SessionMessage = { info?: { role?: string }; parts?: MessagePart[] };
|
|
70
|
+
|
|
71
|
+
// ---------------------------------------------------------------------------
|
|
72
|
+
// Small helpers
|
|
73
|
+
// ---------------------------------------------------------------------------
|
|
74
|
+
|
|
75
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
76
|
+
return typeof value === "object" && value !== null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function pickString(
|
|
80
|
+
record: Record<string, unknown>,
|
|
81
|
+
keys: readonly string[],
|
|
82
|
+
): string | undefined {
|
|
83
|
+
for (const key of keys) {
|
|
84
|
+
const value = record[key];
|
|
85
|
+
if (typeof value === "string" && value.length > 0) return value;
|
|
86
|
+
}
|
|
87
|
+
return undefined;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function normalizeToolInput(toolInput: unknown): unknown {
|
|
91
|
+
if (!isRecord(toolInput)) return toolInput;
|
|
92
|
+
|
|
93
|
+
const filePath = pickString(toolInput, ["file_path", "filePath", "path"]);
|
|
94
|
+
const workdir = pickString(toolInput, ["workdir", "cwd"]);
|
|
95
|
+
const command = pickString(toolInput, ["command", "cmd"]);
|
|
96
|
+
|
|
97
|
+
return {
|
|
98
|
+
...toolInput,
|
|
99
|
+
...(filePath ? { file_path: filePath } : {}),
|
|
100
|
+
...(workdir ? { workdir } : {}),
|
|
101
|
+
...(command ? { command } : {}),
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function collectAssistantSummaryFromMessages(messages: SessionMessage[]): string {
|
|
106
|
+
const summaryParts: string[] = [];
|
|
107
|
+
let foundAssistantBlock = false;
|
|
108
|
+
|
|
109
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
110
|
+
const message = messages[i];
|
|
111
|
+
if (message?.info?.role !== "assistant") {
|
|
112
|
+
if (foundAssistantBlock) break;
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
foundAssistantBlock = true;
|
|
117
|
+
const text = (message.parts ?? [])
|
|
118
|
+
.filter((part) => part.type === "text" && part.text)
|
|
119
|
+
.map((part) => part.text as string)
|
|
120
|
+
.join("\n")
|
|
121
|
+
.trim();
|
|
122
|
+
if (text) summaryParts.unshift(text);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return summaryParts.join("\n").trim();
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// ---------------------------------------------------------------------------
|
|
129
|
+
// Daemon HTTP transport — all communication with the local Myco daemon.
|
|
130
|
+
// Every function is best-effort: failures are swallowed so the plugin cannot
|
|
131
|
+
// interfere with opencode when Myco is absent or the daemon is unreachable.
|
|
132
|
+
// ---------------------------------------------------------------------------
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Port cache for `.myco/daemon.json`. Read once on first access; refreshed on
|
|
136
|
+
* the next call that follows a failed HTTP request (handles daemon restarts
|
|
137
|
+
* mid-session). `undefined` = never loaded, `null` = loaded but absent.
|
|
138
|
+
*/
|
|
139
|
+
let cachedDaemonPort: number | null | undefined = undefined;
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Active opencode sessions tracked by this plugin instance. Populated on
|
|
143
|
+
* `session.created` and drained on `session.deleted` / `server.instance.disposed`.
|
|
144
|
+
*
|
|
145
|
+
* Opencode has no `session.end` event — when the TUI exits normally (Ctrl+C,
|
|
146
|
+
* close terminal), the session stays "active" from the daemon's perspective
|
|
147
|
+
* until the session-maintenance job sweeps it (1-hour threshold). To close
|
|
148
|
+
* sessions cleanly on TUI exit, we track them locally and call unregister
|
|
149
|
+
* for each one when `server.instance.disposed` fires.
|
|
150
|
+
*/
|
|
151
|
+
const activeOpencodeSessions = new Set<string>();
|
|
152
|
+
|
|
153
|
+
/** Read the Myco daemon port from .myco/daemon.json in the project directory. */
|
|
154
|
+
function readDaemonPortFromDisk(directory: string): number | null {
|
|
155
|
+
try {
|
|
156
|
+
const raw = readFileSync(join(directory, ".myco", "daemon.json"), "utf-8");
|
|
157
|
+
const info = JSON.parse(raw) as { port?: number };
|
|
158
|
+
return typeof info.port === "number" ? info.port : null;
|
|
159
|
+
} catch {
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Get the cached daemon port, loading from disk on first access. */
|
|
165
|
+
function getDaemonPort(directory: string): number | null {
|
|
166
|
+
if (cachedDaemonPort === undefined) cachedDaemonPort = readDaemonPortFromDisk(directory);
|
|
167
|
+
return cachedDaemonPort;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Force-refresh the daemon port from disk — used after a fetch failure in case the daemon restarted. */
|
|
171
|
+
function refreshDaemonPort(directory: string): number | null {
|
|
172
|
+
cachedDaemonPort = readDaemonPortFromDisk(directory);
|
|
173
|
+
return cachedDaemonPort;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Fetch with a short timeout. Returns the Response on success, null on failure. */
|
|
177
|
+
async function fetchWithTimeout(url: string, init?: RequestInit): Promise<Response | null> {
|
|
178
|
+
const controller = new AbortController();
|
|
179
|
+
const timer = setTimeout(() => controller.abort(), MYCO_FETCH_TIMEOUT_MS);
|
|
180
|
+
try {
|
|
181
|
+
const res = await fetch(url, { ...init, signal: controller.signal });
|
|
182
|
+
return res.ok ? res : null;
|
|
183
|
+
} catch {
|
|
184
|
+
return null;
|
|
185
|
+
} finally {
|
|
186
|
+
clearTimeout(timer);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Fetch from a daemon endpoint with a single retry after refreshing the port.
|
|
192
|
+
* The retry handles the case where the daemon restarted on a different port
|
|
193
|
+
* mid-session; the cache hot-path avoids a sync disk read on every HTTP call.
|
|
194
|
+
*/
|
|
195
|
+
async function fetchFromDaemon(
|
|
196
|
+
directory: string,
|
|
197
|
+
path: string,
|
|
198
|
+
init?: RequestInit,
|
|
199
|
+
): Promise<Response | null> {
|
|
200
|
+
const port = getDaemonPort(directory);
|
|
201
|
+
if (!port) return null;
|
|
202
|
+
|
|
203
|
+
const first = await fetchWithTimeout(`http://localhost:${port}${path}`, init);
|
|
204
|
+
if (first) return first;
|
|
205
|
+
|
|
206
|
+
// Retry once with a refreshed port — the daemon may have restarted.
|
|
207
|
+
const freshPort = refreshDaemonPort(directory);
|
|
208
|
+
if (!freshPort || freshPort === port) return null;
|
|
209
|
+
return fetchWithTimeout(`http://localhost:${freshPort}${path}`, init);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* POST JSON to a daemon endpoint.
|
|
214
|
+
* Returns `{ ok, data }` — `ok` is true when the HTTP call succeeded, `data`
|
|
215
|
+
* is the parsed response body (may be absent if the body was empty or not JSON).
|
|
216
|
+
*/
|
|
217
|
+
async function postJson(
|
|
218
|
+
directory: string,
|
|
219
|
+
path: string,
|
|
220
|
+
body: Record<string, unknown>,
|
|
221
|
+
): Promise<{ ok: boolean; data?: unknown }> {
|
|
222
|
+
const res = await fetchFromDaemon(directory, path, {
|
|
223
|
+
method: "POST",
|
|
224
|
+
headers: { "Content-Type": "application/json" },
|
|
225
|
+
body: JSON.stringify(body),
|
|
226
|
+
});
|
|
227
|
+
if (!res) return { ok: false };
|
|
228
|
+
try {
|
|
229
|
+
return { ok: true, data: await res.json() };
|
|
230
|
+
} catch {
|
|
231
|
+
return { ok: true };
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Append an event to the local buffer at .myco/buffer/<session-id>.jsonl.
|
|
237
|
+
*
|
|
238
|
+
* Used as a fallback when the daemon HTTP POST fails (daemon down, network
|
|
239
|
+
* error, timeout). The daemon replays buffered events via reconcileBufferBatches
|
|
240
|
+
* at startup, so events captured here are NOT lost — they land in the vault
|
|
241
|
+
* the next time the daemon comes back up. Mirrors the fallback pattern in
|
|
242
|
+
* src/hooks/send-event.ts + src/capture/buffer.ts that every other symbiont
|
|
243
|
+
* uses (claude-code, cursor, codex, etc.) via the hook CLI path.
|
|
244
|
+
*
|
|
245
|
+
* Without this fallback, opencode would have a significant parity gap — all
|
|
246
|
+
* other symbionts preserve events across daemon downtime, but opencode events
|
|
247
|
+
* would be silently dropped the moment the daemon was unreachable.
|
|
248
|
+
*
|
|
249
|
+
* The entry shape matches EventBuffer.append(): event payload with session_id
|
|
250
|
+
* stripped (it's in the filename) and an auto-injected ISO timestamp.
|
|
251
|
+
*/
|
|
252
|
+
function bufferEvent(
|
|
253
|
+
directory: string,
|
|
254
|
+
sessionId: string,
|
|
255
|
+
event: Record<string, unknown>,
|
|
256
|
+
): void {
|
|
257
|
+
try {
|
|
258
|
+
const bufferDir = join(directory, ".myco", "buffer");
|
|
259
|
+
mkdirSync(bufferDir, { recursive: true });
|
|
260
|
+
const filePath = join(bufferDir, `${sessionId}.jsonl`);
|
|
261
|
+
// Strip session_id from the entry — it's encoded in the filename
|
|
262
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
263
|
+
const { session_id: _sid, ...payload } = event;
|
|
264
|
+
const line = JSON.stringify({
|
|
265
|
+
...payload,
|
|
266
|
+
timestamp: payload.timestamp ?? new Date().toISOString(),
|
|
267
|
+
});
|
|
268
|
+
appendFileSync(filePath, line + "\n");
|
|
269
|
+
} catch (err) {
|
|
270
|
+
// eslint-disable-next-line no-console
|
|
271
|
+
console.error("[myco] Failed to buffer event:", err);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* POST a capture event to the daemon with buffer fallback on failure.
|
|
277
|
+
* Used for user_prompt and tool_use events — both are replayed from the
|
|
278
|
+
* buffer by reconcileBufferBatches when the daemon restarts.
|
|
279
|
+
*/
|
|
280
|
+
async function postEventWithBuffer(
|
|
281
|
+
directory: string,
|
|
282
|
+
sessionId: string,
|
|
283
|
+
event: Record<string, unknown>,
|
|
284
|
+
): Promise<void> {
|
|
285
|
+
const result = await postJson(directory, "/events", event);
|
|
286
|
+
if (!result.ok) {
|
|
287
|
+
bufferEvent(directory, sessionId, event);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** Register an opencode session with the daemon. */
|
|
292
|
+
async function mycoRegisterSession(
|
|
293
|
+
directory: string,
|
|
294
|
+
sessionId: string,
|
|
295
|
+
parentSessionId: string | undefined,
|
|
296
|
+
): Promise<void> {
|
|
297
|
+
await postJson(directory, "/sessions/register", {
|
|
298
|
+
session_id: sessionId,
|
|
299
|
+
agent: "opencode",
|
|
300
|
+
parent_session_id: parentSessionId,
|
|
301
|
+
started_at: new Date().toISOString(),
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/** Unregister an opencode session. */
|
|
306
|
+
async function mycoUnregisterSession(directory: string, sessionId: string): Promise<void> {
|
|
307
|
+
await postJson(directory, "/sessions/unregister", { session_id: sessionId });
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/** Post a user prompt event. Images, if any, are shipped as an array of
|
|
311
|
+
* `{ data: base64, mediaType }` objects — the daemon's event dispatcher persists
|
|
312
|
+
* them as attachments keyed to the newly-opened prompt batch.
|
|
313
|
+
*
|
|
314
|
+
* Opencode has no on-disk transcript for Myco to mine, so images attached by
|
|
315
|
+
* the user in the TUI must travel with the prompt event itself. Other symbionts
|
|
316
|
+
* (claude-code, cursor) extract images from their JSONL transcripts at stop time.
|
|
317
|
+
*
|
|
318
|
+
* Falls back to the local buffer if the daemon is unreachable so events are
|
|
319
|
+
* replayed on daemon restart (same resilience the other symbionts get via
|
|
320
|
+
* src/hooks/send-event.ts).
|
|
321
|
+
*/
|
|
322
|
+
async function mycoPostUserPrompt(
|
|
323
|
+
directory: string,
|
|
324
|
+
sessionId: string,
|
|
325
|
+
prompt: string,
|
|
326
|
+
images: Array<{ data: string; mediaType: string }>,
|
|
327
|
+
): Promise<void> {
|
|
328
|
+
await postEventWithBuffer(directory, sessionId, {
|
|
329
|
+
type: "user_prompt",
|
|
330
|
+
session_id: sessionId,
|
|
331
|
+
agent: "opencode",
|
|
332
|
+
prompt,
|
|
333
|
+
...(images.length > 0 ? { images } : {}),
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/** Post a tool use event. Falls back to the local buffer on failure. */
|
|
338
|
+
async function mycoPostToolUse(
|
|
339
|
+
directory: string,
|
|
340
|
+
sessionId: string,
|
|
341
|
+
toolName: string,
|
|
342
|
+
toolInput: unknown,
|
|
343
|
+
toolOutput: string,
|
|
344
|
+
): Promise<void> {
|
|
345
|
+
await postEventWithBuffer(directory, sessionId, {
|
|
346
|
+
type: "tool_use",
|
|
347
|
+
session_id: sessionId,
|
|
348
|
+
agent: "opencode",
|
|
349
|
+
tool_name: toolName,
|
|
350
|
+
tool_input: toolInput,
|
|
351
|
+
output_preview: toolOutput,
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/** Post a stop event with the last assistant message as the response summary. */
|
|
356
|
+
async function mycoPostStop(
|
|
357
|
+
directory: string,
|
|
358
|
+
sessionId: string,
|
|
359
|
+
lastAssistantMessage: string | undefined,
|
|
360
|
+
): Promise<void> {
|
|
361
|
+
await postJson(directory, "/events/stop", {
|
|
362
|
+
session_id: sessionId,
|
|
363
|
+
agent: "opencode",
|
|
364
|
+
last_assistant_message: lastAssistantMessage,
|
|
365
|
+
});
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* Fetch the session-start context for a new opencode session. Hits the daemon's
|
|
370
|
+
* config-aware `POST /context` endpoint, which selects the digest tier the user
|
|
371
|
+
* has configured (`config.context.digest_tier`, default 5000) and returns the
|
|
372
|
+
* full session context (digest + branch + session ID lines).
|
|
373
|
+
*
|
|
374
|
+
* This is the same endpoint Claude Code's session-start hook uses, so opencode
|
|
375
|
+
* sessions receive the same context the user has configured for every other agent.
|
|
376
|
+
*/
|
|
377
|
+
async function fetchMycoSessionContext(
|
|
378
|
+
directory: string,
|
|
379
|
+
sessionId: string,
|
|
380
|
+
): Promise<string | null> {
|
|
381
|
+
const result = await postJson(directory, "/context", { session_id: sessionId });
|
|
382
|
+
if (!result.ok) return null;
|
|
383
|
+
const data = result.data as { text?: string } | undefined;
|
|
384
|
+
const text = data?.text?.trim() ?? "";
|
|
385
|
+
return text.length > 0 ? text : null;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// ---------------------------------------------------------------------------
|
|
389
|
+
// Opencode session injection — push synthetic context into session history.
|
|
390
|
+
// ---------------------------------------------------------------------------
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* Inject text into an opencode session as a synthetic (plugin-authored) user turn
|
|
394
|
+
* without triggering an AI response. The text part carries:
|
|
395
|
+
* - `synthetic: true` so opencode's TUI hides it from the chat log
|
|
396
|
+
* - `metadata.myco: true` so our own `chat.message` handler can distinguish
|
|
397
|
+
* this re-entry from a real user message (see MYCO_METADATA_MARKER)
|
|
398
|
+
* Errors are swallowed — injection is best-effort.
|
|
399
|
+
*/
|
|
400
|
+
async function injectSyntheticContext(
|
|
401
|
+
client: unknown,
|
|
402
|
+
sessionId: string,
|
|
403
|
+
text: string,
|
|
404
|
+
): Promise<void> {
|
|
405
|
+
try {
|
|
406
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
407
|
+
const c = client as any;
|
|
408
|
+
await c.session.prompt({
|
|
409
|
+
path: { id: sessionId },
|
|
410
|
+
body: {
|
|
411
|
+
parts: [
|
|
412
|
+
{
|
|
413
|
+
type: "text",
|
|
414
|
+
text,
|
|
415
|
+
synthetic: true,
|
|
416
|
+
metadata: { [MYCO_METADATA_MARKER]: true },
|
|
417
|
+
},
|
|
418
|
+
],
|
|
419
|
+
noReply: true,
|
|
420
|
+
},
|
|
421
|
+
});
|
|
422
|
+
} catch (error) {
|
|
423
|
+
// eslint-disable-next-line no-console
|
|
424
|
+
console.error("[myco] Failed to inject synthetic context:", error);
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/** Flatten todo items into a newline-separated summary. */
|
|
429
|
+
function formatTodos(
|
|
430
|
+
todos: Array<{ id?: string; content?: string; status?: string }>,
|
|
431
|
+
): string {
|
|
432
|
+
if (!todos || todos.length === 0) return "";
|
|
433
|
+
return todos
|
|
434
|
+
.map((t) => `[${t.status || "pending"}] ${t.content || ""}`)
|
|
435
|
+
.join("\n");
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/** Truncate tool output for storage. */
|
|
439
|
+
function summarizeToolOutput(output: unknown): string {
|
|
440
|
+
if (typeof output !== "string") return "";
|
|
441
|
+
return output.length > TOOL_OUTPUT_PREVIEW_CHARS
|
|
442
|
+
? output.slice(0, TOOL_OUTPUT_PREVIEW_CHARS) + "..."
|
|
443
|
+
: output;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
// ---------------------------------------------------------------------------
|
|
447
|
+
// Plugin entry
|
|
448
|
+
// ---------------------------------------------------------------------------
|
|
449
|
+
|
|
450
|
+
/**
|
|
451
|
+
* Opencode plugin entry. The function signature matches opencode's Plugin type
|
|
452
|
+
* via duck typing — we deliberately do NOT import the Plugin type from
|
|
453
|
+
* @opencode-ai/plugin so this file has zero external runtime dependencies.
|
|
454
|
+
* That guarantee lets teammates who clone a project that uses Myco still run
|
|
455
|
+
* opencode cleanly even when they don't have Myco installed locally.
|
|
456
|
+
*
|
|
457
|
+
* @param {{ client: any, directory: string, worktree: string }} ctx
|
|
458
|
+
*/
|
|
459
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
460
|
+
export const MycoPlugin = async ({ client, directory, worktree }: { client: any; directory: string; worktree: string }) => {
|
|
461
|
+
// Best-effort init log. Wrapped in try-catch so a future SDK shape change in
|
|
462
|
+
// opencode (e.g. client.app.log moving) cannot prevent the plugin from
|
|
463
|
+
// registering its handlers.
|
|
464
|
+
try {
|
|
465
|
+
await client.app.log({
|
|
466
|
+
service: "myco",
|
|
467
|
+
level: "info",
|
|
468
|
+
message: "Myco plugin initialized",
|
|
469
|
+
extra: { directory, worktree },
|
|
470
|
+
});
|
|
471
|
+
} catch {
|
|
472
|
+
// Swallow — init log is diagnostic only.
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
return {
|
|
476
|
+
/**
|
|
477
|
+
* Generic event handler: session lifecycle, todos.
|
|
478
|
+
*/
|
|
479
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
480
|
+
event: async ({ event }: { event: any }) => {
|
|
481
|
+
if (event.type === "session.created") {
|
|
482
|
+
const info = event.properties?.info ?? {};
|
|
483
|
+
const sessionId: string | undefined = info.id;
|
|
484
|
+
if (!sessionId) return;
|
|
485
|
+
|
|
486
|
+
activeOpencodeSessions.add(sessionId);
|
|
487
|
+
|
|
488
|
+
// Run the capture (register session with Myco daemon) and context fetch
|
|
489
|
+
// concurrently — they don't depend on each other, and parallelizing saves
|
|
490
|
+
// one round-trip of latency before the user's first turn lands. Context
|
|
491
|
+
// is fetched only for fresh sessions; resume sessions inherit the parent's
|
|
492
|
+
// history and don't need another injection.
|
|
493
|
+
//
|
|
494
|
+
// Re-entrancy: the synthetic text part carries `synthetic: true`; if opencode
|
|
495
|
+
// fires chat.message for it, our handler's synthetic-flag check skips it.
|
|
496
|
+
const [, sessionContext] = await Promise.all([
|
|
497
|
+
mycoRegisterSession(directory, sessionId, info.parentID || undefined),
|
|
498
|
+
info.parentID ? Promise.resolve(null) : fetchMycoSessionContext(directory, sessionId),
|
|
499
|
+
]);
|
|
500
|
+
|
|
501
|
+
if (sessionContext) {
|
|
502
|
+
await injectSyntheticContext(client, sessionId, sessionContext);
|
|
503
|
+
}
|
|
504
|
+
return;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
if (event.type === "session.deleted") {
|
|
508
|
+
const info = event.properties?.info ?? {};
|
|
509
|
+
if (info.id) {
|
|
510
|
+
activeOpencodeSessions.delete(info.id);
|
|
511
|
+
await mycoUnregisterSession(directory, info.id);
|
|
512
|
+
}
|
|
513
|
+
return;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
if (event.type === "server.instance.disposed") {
|
|
517
|
+
// Opencode TUI is shutting down. Flush all tracked sessions so the
|
|
518
|
+
// daemon can mark them completed immediately rather than waiting for
|
|
519
|
+
// the stale-session maintenance sweep (1-hour threshold).
|
|
520
|
+
//
|
|
521
|
+
// Fire-and-forget-parallel: the Bun process is about to exit, so we
|
|
522
|
+
// can't rely on awaited fetches completing. Promise.all gives the
|
|
523
|
+
// unregister calls their best shot at landing before teardown; any
|
|
524
|
+
// that don't make it fall back to the session-maintenance job.
|
|
525
|
+
if (activeOpencodeSessions.size === 0) return;
|
|
526
|
+
const toClose = Array.from(activeOpencodeSessions);
|
|
527
|
+
activeOpencodeSessions.clear();
|
|
528
|
+
await Promise.all(toClose.map((id) => mycoUnregisterSession(directory, id)));
|
|
529
|
+
return;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
if (event.type === "session.idle") {
|
|
533
|
+
const sessionId = event.properties?.sessionID;
|
|
534
|
+
if (!sessionId) return;
|
|
535
|
+
|
|
536
|
+
// Fetch the last assistant message for a response summary.
|
|
537
|
+
let responseSummary = "";
|
|
538
|
+
try {
|
|
539
|
+
// `limit` is a tail-limit in opencode's server (returns the last N
|
|
540
|
+
// messages in chronological order — verified empirically against
|
|
541
|
+
// opencode v1.4.1 via `opencode serve`). The tail window is large
|
|
542
|
+
// enough to capture a multi-message assistant block at end-of-turn
|
|
543
|
+
// without reading the full session history on every idle event.
|
|
544
|
+
const result = await client.session.messages({
|
|
545
|
+
path: { id: sessionId },
|
|
546
|
+
query: { directory, limit: SESSION_IDLE_TAIL_LIMIT },
|
|
547
|
+
});
|
|
548
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
549
|
+
const messages = ((result as any)?.data ?? []) as SessionMessage[];
|
|
550
|
+
responseSummary = collectAssistantSummaryFromMessages(messages);
|
|
551
|
+
} catch (err) {
|
|
552
|
+
// eslint-disable-next-line no-console
|
|
553
|
+
console.error("[myco] Failed to fetch messages for summary:", err);
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
await mycoPostStop(directory, sessionId, responseSummary || undefined);
|
|
557
|
+
return;
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
if (event.type === "todo.updated") {
|
|
561
|
+
const sessionId = event.properties?.sessionID;
|
|
562
|
+
if (!sessionId) return;
|
|
563
|
+
const todos = event.properties?.todos ?? [];
|
|
564
|
+
await mycoPostToolUse(
|
|
565
|
+
directory,
|
|
566
|
+
sessionId,
|
|
567
|
+
"TodoUpdate",
|
|
568
|
+
{ todos, count: todos.length },
|
|
569
|
+
formatTodos(todos),
|
|
570
|
+
);
|
|
571
|
+
}
|
|
572
|
+
},
|
|
573
|
+
|
|
574
|
+
/**
|
|
575
|
+
* Chat message: capture the user prompt + any image attachments.
|
|
576
|
+
*
|
|
577
|
+
* Per-turn spore injection is intentionally not done here. A previous iteration
|
|
578
|
+
* injected spores via session.prompt({ noReply: true }) inside this handler, but
|
|
579
|
+
* opencode re-fires chat.message for the synthetic turn and the first real user
|
|
580
|
+
* message landed during the re-entrancy window. Agents can fetch context on
|
|
581
|
+
* demand via the myco_context and myco_search MCP tools.
|
|
582
|
+
*
|
|
583
|
+
* Re-entrancy guard: we check for `metadata.myco === true` on any part to
|
|
584
|
+
* detect our session-start digest injection coming back around. Opencode
|
|
585
|
+
* itself sets `synthetic: true` for many internal purposes (plan-mode
|
|
586
|
+
* prompts, build-switch transitions, subagent task summaries), so the
|
|
587
|
+
* `synthetic` flag alone is NOT reliable as a re-entrancy signal — it
|
|
588
|
+
* would silently drop any user prompt that opencode touched for one of
|
|
589
|
+
* those internal reasons.
|
|
590
|
+
*/
|
|
591
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
592
|
+
"chat.message": async (input: any, output: any) => {
|
|
593
|
+
const sessionId = input?.sessionID;
|
|
594
|
+
if (!sessionId) return;
|
|
595
|
+
|
|
596
|
+
// Part shapes we care about: text (for the prompt string) and file
|
|
597
|
+
// (for image attachments encoded as data URLs — FilePart.url is
|
|
598
|
+
// `data:<mime>;base64,<data>` per
|
|
599
|
+
// packages/app/src/components/prompt-input/attachments.ts in opencode).
|
|
600
|
+
const allParts = (output?.parts ?? []) as Array<{
|
|
601
|
+
type?: string;
|
|
602
|
+
text?: string;
|
|
603
|
+
mime?: string;
|
|
604
|
+
url?: string;
|
|
605
|
+
synthetic?: boolean;
|
|
606
|
+
metadata?: { [key: string]: unknown };
|
|
607
|
+
}>;
|
|
608
|
+
// Skip if any part carries the Myco metadata marker — that means
|
|
609
|
+
// chat.message is firing for our own injectSyntheticContext call.
|
|
610
|
+
if (allParts.some((p) => p.metadata?.[MYCO_METADATA_MARKER] === true)) return;
|
|
611
|
+
|
|
612
|
+
// Prompt text = user's real text only. opencode emits `synthetic: true`
|
|
613
|
+
// text parts for internal scaffolding when the message contains file
|
|
614
|
+
// mentions, plan-mode switches, subagent tasks, and similar — see
|
|
615
|
+
// packages/opencode/src/session/prompt.ts. Those parts include full
|
|
616
|
+
// file contents, tool-call scaffolding, plan instructions, etc. Joining
|
|
617
|
+
// them into prompt_text would bloat every captured user prompt with
|
|
618
|
+
// system-level content that the user never typed.
|
|
619
|
+
const textParts = allParts
|
|
620
|
+
.filter((p) => p.type === "text" && p.text && p.synthetic !== true)
|
|
621
|
+
.map((p) => p.text as string);
|
|
622
|
+
const prompt = textParts.join("\n");
|
|
623
|
+
if (!prompt) return;
|
|
624
|
+
|
|
625
|
+
// Extract any image attachments from FilePart data URLs. Non-image file
|
|
626
|
+
// parts (code snippets, documents) are ignored here — only images travel
|
|
627
|
+
// to Myco as binary attachments via the existing attachment pipeline.
|
|
628
|
+
const images: Array<{ data: string; mediaType: string }> = [];
|
|
629
|
+
for (const part of allParts) {
|
|
630
|
+
if (
|
|
631
|
+
part.type !== "file" ||
|
|
632
|
+
!part.mime?.startsWith("image/") ||
|
|
633
|
+
typeof part.url !== "string" ||
|
|
634
|
+
!part.url.startsWith("data:")
|
|
635
|
+
) {
|
|
636
|
+
continue;
|
|
637
|
+
}
|
|
638
|
+
const commaIdx = part.url.indexOf(",");
|
|
639
|
+
if (commaIdx <= 0) continue;
|
|
640
|
+
const base64 = part.url.slice(commaIdx + 1);
|
|
641
|
+
if (base64) images.push({ data: base64, mediaType: part.mime });
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
await mycoPostUserPrompt(directory, sessionId, prompt, images);
|
|
645
|
+
},
|
|
646
|
+
|
|
647
|
+
/**
|
|
648
|
+
* Post-tool execution: ship tool usage to Myco.
|
|
649
|
+
*
|
|
650
|
+
* We forward `input.args` as `tool_input` — NOT `output.metadata` — because
|
|
651
|
+
* `args` carries the tool invocation arguments (including `filePath` for
|
|
652
|
+
* write/edit/patch tools), which Myco's plan-capture matcher needs to detect
|
|
653
|
+
* writes to .opencode/plans/*.md.
|
|
654
|
+
*/
|
|
655
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
656
|
+
"tool.execute.after": async (input: any, output: any) => {
|
|
657
|
+
const sessionId = input?.sessionID;
|
|
658
|
+
if (!sessionId) return;
|
|
659
|
+
|
|
660
|
+
const toolName = input?.tool ?? "unknown";
|
|
661
|
+
const toolInput = normalizeToolInput(input?.args ?? output?.metadata ?? {});
|
|
662
|
+
const toolOutput = summarizeToolOutput(output?.output);
|
|
663
|
+
|
|
664
|
+
await mycoPostToolUse(directory, sessionId, toolName, toolInput, toolOutput);
|
|
665
|
+
},
|
|
666
|
+
|
|
667
|
+
/**
|
|
668
|
+
* Compaction hook: fires BEFORE opencode generates a continuation summary
|
|
669
|
+
* during session compaction. Pushing the session context into output.context
|
|
670
|
+
* ensures Myco's project knowledge survives compaction rather than being
|
|
671
|
+
* dropped. The fetched context respects the user's configured digest tier.
|
|
672
|
+
*
|
|
673
|
+
* See https://opencode.ai/docs/plugins/#compaction-hooks
|
|
674
|
+
*/
|
|
675
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
676
|
+
"experimental.session.compacting": async (input: any, output: any) => {
|
|
677
|
+
const sessionId = input?.sessionID;
|
|
678
|
+
if (!sessionId) return;
|
|
679
|
+
|
|
680
|
+
const sessionContext = await fetchMycoSessionContext(directory, sessionId);
|
|
681
|
+
if (!sessionContext) return;
|
|
682
|
+
|
|
683
|
+
if (Array.isArray(output?.context)) {
|
|
684
|
+
output.context.push(COMPACTION_HEADING + sessionContext);
|
|
685
|
+
}
|
|
686
|
+
},
|
|
687
|
+
};
|
|
688
|
+
};
|
|
689
|
+
|
|
690
|
+
export default MycoPlugin;
|