geneseed 2.0.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/CHANGELOG.md +938 -0
- package/DESIGN.md +1423 -0
- package/LICENSE +21 -0
- package/QUICKSTART.md +48 -0
- package/README.md +336 -0
- package/SETUP.md +1027 -0
- package/SHIPPED.md +70 -0
- package/adapters/bob/README.md +100 -0
- package/adapters/claude-code/README.md +101 -0
- package/adapters/claude-code/settings.json +47 -0
- package/adapters/claude-code/skills/geneseed/SKILL.md +72 -0
- package/adapters/copilot/README.md +76 -0
- package/adapters/opencode/GLOBAL-HARNESS-SPEC.md +434 -0
- package/adapters/opencode/HOW-OPENCODE-LOADS.md +158 -0
- package/adapters/opencode/README.md +582 -0
- package/adapters/opencode/agents/orchestrator.md +25 -0
- package/adapters/opencode/opencode.json +44 -0
- package/adapters/opencode/plugins/geneseed-activity.js +430 -0
- package/adapters/opencode/plugins/geneseed-context.js +876 -0
- package/adapters/opencode/plugins/geneseed-guard.js +290 -0
- package/adapters/opencode/plugins/geneseed-learn.js +396 -0
- package/adapters/opencode/plugins/geneseed-notify.js +142 -0
- package/adapters/opencode/plugins/geneseed-ponytail.js +148 -0
- package/adapters/opencode/plugins/geneseed-workflow.js +183 -0
- package/adapters/opencode/workflows/_runtime.js +277 -0
- package/adapters/opencode/workflows/council.js +64 -0
- package/adapters/opencode/workflows/dispatch.js +89 -0
- package/adapters/opencode/workflows/research-plan-implement.js +43 -0
- package/adapters/opencode/workflows/review.js +57 -0
- package/bin/geneseed-cli.mjs +412 -0
- package/bin/geneseed-hook.mjs +129 -0
- package/bin/geneseed.mjs +1086 -0
- package/docs/declined.md +149 -0
- package/docs/limits.md +270 -0
- package/docs/opencode-plugin-setup.md +64 -0
- package/docs/token-footprint.md +68 -0
- package/docs/web/_groups.json +45 -0
- package/docs/web/about.md +6 -0
- package/docs/web/adapters-claude-code.md +8 -0
- package/docs/web/adapters-opencode-loads.md +8 -0
- package/docs/web/adapters-opencode-spec.md +8 -0
- package/docs/web/adapters-opencode.md +8 -0
- package/docs/web/agents.md +8 -0
- package/docs/web/authoring.md +11 -0
- package/docs/web/autostart.md +9 -0
- package/docs/web/claude-hooks.md +36 -0
- package/docs/web/cli.md +6 -0
- package/docs/web/collaboration.md +27 -0
- package/docs/web/configure-wiki.md +9 -0
- package/docs/web/design.md +7 -0
- package/docs/web/env-knobs.md +9 -0
- package/docs/web/exclusions.md +26 -0
- package/docs/web/first-session.md +28 -0
- package/docs/web/footprint.md +31 -0
- package/docs/web/foreman-mode.md +33 -0
- package/docs/web/glossary.md +6 -0
- package/docs/web/harnesses.md +28 -0
- package/docs/web/headless.md +9 -0
- package/docs/web/ingest-docs.md +9 -0
- package/docs/web/install-paths.md +9 -0
- package/docs/web/install-quick.md +35 -0
- package/docs/web/lsp-overview.md +37 -0
- package/docs/web/mcp-claude-code.md +10 -0
- package/docs/web/mcp-filesystem.md +9 -0
- package/docs/web/mcp-gitlab.md +9 -0
- package/docs/web/mcp-markitdown.md +9 -0
- package/docs/web/mcp-overview.md +34 -0
- package/docs/web/mcp-trouble.md +9 -0
- package/docs/web/mcp-verify.md +9 -0
- package/docs/web/memory.md +7 -0
- package/docs/web/migrate.md +39 -0
- package/docs/web/model.md +20 -0
- package/docs/web/notebook.md +7 -0
- package/docs/web/pipelines.md +24 -0
- package/docs/web/plugin-activity.md +23 -0
- package/docs/web/plugin-context.md +26 -0
- package/docs/web/plugin-guard.md +24 -0
- package/docs/web/plugin-learn.md +27 -0
- package/docs/web/plugin-notify.md +24 -0
- package/docs/web/plugin-ponytail.md +23 -0
- package/docs/web/plugin-setup.md +7 -0
- package/docs/web/plugin-workflow.md +24 -0
- package/docs/web/plugins.md +17 -0
- package/docs/web/project-context.md +9 -0
- package/docs/web/rules.md +8 -0
- package/docs/web/run-anywhere.md +9 -0
- package/docs/web/self-improve.md +8 -0
- package/docs/web/setup-choices.md +39 -0
- package/docs/web/skills.md +8 -0
- package/docs/web/themes-author.md +8 -0
- package/docs/web/themes.md +9 -0
- package/docs/web/token-footprint.md +7 -0
- package/docs/web/trouble.md +38 -0
- package/docs/web/upgrade.md +9 -0
- package/docs/web/verify.md +30 -0
- package/docs/web/wiki.md +7 -0
- package/docs/web/worktree.md +10 -0
- package/docs/web-ui.md +144 -0
- package/docs/wiki.md +43 -0
- package/geneseed +39 -0
- package/geneseed.cmd +34 -0
- package/harness.config.json +6 -0
- package/js/anim.mjs +326 -0
- package/js/catalog.mjs +112 -0
- package/js/checkout.mjs +88 -0
- package/js/cli-table.json +1034 -0
- package/js/cli.mjs +498 -0
- package/js/diff.mjs +384 -0
- package/js/doctor.mjs +1364 -0
- package/js/emit.mjs +1373 -0
- package/js/excludes.mjs +271 -0
- package/js/generate.mjs +379 -0
- package/js/hooks.mjs +1026 -0
- package/js/hosts.mjs +253 -0
- package/js/installs.mjs +378 -0
- package/js/inventory.mjs +175 -0
- package/js/lib/fs.mjs +913 -0
- package/js/lib/proc.mjs +28 -0
- package/js/lib/udiff.mjs +275 -0
- package/js/link.mjs +286 -0
- package/js/mcp.mjs +335 -0
- package/js/memory.mjs +108 -0
- package/js/menu.mjs +118 -0
- package/js/migrate.mjs +251 -0
- package/js/native.mjs +426 -0
- package/js/opencode.mjs +325 -0
- package/js/registry.mjs +110 -0
- package/js/render.mjs +282 -0
- package/js/settings.mjs +924 -0
- package/js/setup.mjs +492 -0
- package/js/status.mjs +281 -0
- package/js/themes.mjs +195 -0
- package/js/tui.mjs +444 -0
- package/js/uninstall.mjs +1137 -0
- package/js/update.mjs +910 -0
- package/js/web/actions.mjs +868 -0
- package/js/web/activity.mjs +284 -0
- package/js/web/api.mjs +873 -0
- package/js/web/docs.mjs +531 -0
- package/js/web/graph.mjs +0 -0
- package/js/web/jobs.mjs +480 -0
- package/js/web/server.mjs +1135 -0
- package/package.json +54 -0
- package/registry.json +474 -0
- package/src/AGENT.md.tmpl +390 -0
- package/src/agents/_template.md +76 -0
- package/src/agents/advocate.md +36 -0
- package/src/agents/architect.md +55 -0
- package/src/agents/developer.md +66 -0
- package/src/agents/docs.md +54 -0
- package/src/agents/empiricist.md +39 -0
- package/src/agents/explorer.md +54 -0
- package/src/agents/framer.md +36 -0
- package/src/agents/historian.md +51 -0
- package/src/agents/operator.md +37 -0
- package/src/agents/pragmatist.md +37 -0
- package/src/agents/reviewer.md +52 -0
- package/src/agents/security.md +57 -0
- package/src/agents/skeptic.md +48 -0
- package/src/agents/steward.md +36 -0
- package/src/agents/tester.md +54 -0
- package/src/agents/user-advocate.md +36 -0
- package/src/agents/visionary.md +36 -0
- package/src/laws/universal.md +387 -0
- package/src/memory/README.md +109 -0
- package/src/memory/gitignore +6 -0
- package/src/modes/README.md +26 -0
- package/src/modes/direct.md +2 -0
- package/src/modes/foreman.md +19 -0
- package/src/notebook/README.md +39 -0
- package/src/notebook/gitignore +7 -0
- package/src/postures/README.md +31 -0
- package/src/postures/artisan.md +14 -0
- package/src/postures/assistant.md +12 -0
- package/src/postures/expert.md +11 -0
- package/src/postures/mentor.md +11 -0
- package/src/postures/peer.md +12 -0
- package/src/skills/_template.md +47 -0
- package/src/skills/brainstorm.md +28 -0
- package/src/skills/clarify.md +28 -0
- package/src/skills/codebase-design.md +44 -0
- package/src/skills/commit.md +48 -0
- package/src/skills/council.md +34 -0
- package/src/skills/crash-course.md +28 -0
- package/src/skills/daydream/.claude-plugin/plugin.json +9 -0
- package/src/skills/daydream/SKILL.md +74 -0
- package/src/skills/daydream/VENDOR.md +25 -0
- package/src/skills/daydream/critic-prompt.md +38 -0
- package/src/skills/daydream/instructions.md +422 -0
- package/src/skills/daydream/synthesizer-prompt.md +41 -0
- package/src/skills/debug.md +37 -0
- package/src/skills/decode.md +28 -0
- package/src/skills/develop.md +29 -0
- package/src/skills/document-project.md +79 -0
- package/src/skills/domain-modeling.md +38 -0
- package/src/skills/drill.md +28 -0
- package/src/skills/feynman.md +26 -0
- package/src/skills/forge-mcp.md +48 -0
- package/src/skills/fresh-eyes.md +29 -0
- package/src/skills/frontend-design.md +58 -0
- package/src/skills/gap-detector.md +27 -0
- package/src/skills/geneseed-code-review.md +33 -0
- package/src/skills/geneseed.md +29 -0
- package/src/skills/git-archaeology.md +47 -0
- package/src/skills/git-rescue.md +68 -0
- package/src/skills/handoff.md +29 -0
- package/src/skills/herdr.md +164 -0
- package/src/skills/ingest.md +49 -0
- package/src/skills/learning-path.md +26 -0
- package/src/skills/migrate.md +45 -0
- package/src/skills/opencode-theme.md +52 -0
- package/src/skills/parallel-agents.md +28 -0
- package/src/skills/pipeline.md +52 -0
- package/src/skills/plan.md +45 -0
- package/src/skills/ponytail.md +29 -0
- package/src/skills/profile.md +58 -0
- package/src/skills/prose.md +42 -0
- package/src/skills/prototype.md +44 -0
- package/src/skills/react-view-transitions/AGENTS.md +955 -0
- package/src/skills/react-view-transitions/README.md +42 -0
- package/src/skills/react-view-transitions/SKILL.md +320 -0
- package/src/skills/react-view-transitions/VENDOR.md +16 -0
- package/src/skills/react-view-transitions/metadata.json +12 -0
- package/src/skills/react-view-transitions/references/css-recipes.md +242 -0
- package/src/skills/react-view-transitions/references/implementation.md +182 -0
- package/src/skills/react-view-transitions/references/nextjs.md +176 -0
- package/src/skills/react-view-transitions/references/patterns.md +262 -0
- package/src/skills/refactor.md +32 -0
- package/src/skills/release.md +43 -0
- package/src/skills/repo-map.md +36 -0
- package/src/skills/research.md +42 -0
- package/src/skills/review-response.md +36 -0
- package/src/skills/roast-me.md +28 -0
- package/src/skills/rule.md +100 -0
- package/src/skills/ship.md +49 -0
- package/src/skills/tdd.md +31 -0
- package/src/skills/tickets.md +44 -0
- package/src/skills/token-report/SKILL.md +76 -0
- package/src/skills/token-report/VENDOR.md +23 -0
- package/src/skills/token-report/scripts/token_report.mjs +1301 -0
- package/src/skills/wayfinder.md +49 -0
- package/src/skills/wiki.md +68 -0
- package/src/skills/workflow.md +38 -0
- package/themes/README.md +69 -0
- package/themes/_TEMPLATE.json +147 -0
- package/themes/biker.json +147 -0
- package/themes/commentator.json +147 -0
- package/themes/cyberpunk.json +147 -0
- package/themes/gamer.json +147 -0
- package/themes/imperial.json +147 -0
- package/themes/joker.json +147 -0
- package/themes/marvin.json +147 -0
- package/themes/mean.json +147 -0
- package/themes/military.json +147 -0
- package/themes/neutral.json +147 -0
- package/themes/opencode/README.md +58 -0
- package/themes/opencode/catppuccin.json +27 -0
- package/themes/opencode/dracula.json +27 -0
- package/themes/opencode/everforest.json +27 -0
- package/themes/opencode/gruvbox.json +27 -0
- package/themes/opencode/kanagawa.json +27 -0
- package/themes/opencode/neon.json +27 -0
- package/themes/opencode/nord.json +27 -0
- package/themes/opencode/rosepine.json +27 -0
- package/themes/opencode/tokyonight.json +27 -0
- package/themes/pirate.json +147 -0
- package/themes/sports.json +147 -0
- package/themes/verstappen.json +147 -0
- package/themes/wizard.json +147 -0
- package/web/dist/assets/About-DX1-VTBP.js +1 -0
- package/web/dist/assets/Activity-Dp9NaXOn.js +1 -0
- package/web/dist/assets/ActivityDetail-HpOBtrXS.js +1 -0
- package/web/dist/assets/Diff-Ct4G3Se8.js +5 -0
- package/web/dist/assets/Doctor-DGjkZFuA.js +1 -0
- package/web/dist/assets/Graph-b8o-S6kv.js +1 -0
- package/web/dist/assets/Harnesses-gr3-aHBF.js +1 -0
- package/web/dist/assets/Laws-DTwF5tTC.js +1 -0
- package/web/dist/assets/Library-BEZscyaQ.js +1 -0
- package/web/dist/assets/Markdown-k_LSLBGq.js +1 -0
- package/web/dist/assets/Profile-DydukTxP.js +1 -0
- package/web/dist/assets/Rules-CIRG4Ldt.js +1 -0
- package/web/dist/assets/Skills-Buoi1IBr.js +1 -0
- package/web/dist/assets/StatusBadge-CE_AJttl.js +1 -0
- package/web/dist/assets/Themes-CI4o3gJC.js +2 -0
- package/web/dist/assets/activity-BCW7e6Cy.js +1 -0
- package/web/dist/assets/audiowide-latin-400-normal-6GFCX7ni.woff2 +0 -0
- package/web/dist/assets/geist-mono-latin-400-normal-DKaoCDn5.woff2 +0 -0
- package/web/dist/assets/geist-mono-latin-500-normal-C3sF8Y1B.woff2 +0 -0
- package/web/dist/assets/geist-mono-latin-600-normal-BHzjB6_C.woff2 +0 -0
- package/web/dist/assets/hanken-grotesk-latin-400-italic-Bserb8X5.woff2 +0 -0
- package/web/dist/assets/hanken-grotesk-latin-400-normal-BG6hkEXj.woff2 +0 -0
- package/web/dist/assets/hanken-grotesk-latin-500-italic-BGfwwhek.woff2 +0 -0
- package/web/dist/assets/hanken-grotesk-latin-500-normal-DrDcrrxK.woff2 +0 -0
- package/web/dist/assets/hanken-grotesk-latin-600-normal-CIXX6EOa.woff2 +0 -0
- package/web/dist/assets/hanken-grotesk-latin-700-normal-CeQ8H3UY.woff2 +0 -0
- package/web/dist/assets/ibm-plex-mono-latin-400-normal-DMJ8VG8y.woff2 +0 -0
- package/web/dist/assets/ibm-plex-mono-latin-500-normal-DSY6xOcd.woff2 +0 -0
- package/web/dist/assets/ibm-plex-mono-latin-600-normal-BgSNZQsw.woff2 +0 -0
- package/web/dist/assets/ibm-plex-serif-latin-400-normal-BIGslYFI.woff2 +0 -0
- package/web/dist/assets/ibm-plex-serif-latin-500-normal-REKAfBVG.woff2 +0 -0
- package/web/dist/assets/ibm-plex-serif-latin-600-normal-DAxitmSu.woff2 +0 -0
- package/web/dist/assets/index-B1PIx7RM.js +1 -0
- package/web/dist/assets/index-BnTQUSUz.js +12 -0
- package/web/dist/assets/index-Ck2iCdaC.js +2 -0
- package/web/dist/assets/index-DUCsDv7z.css +1 -0
- package/web/dist/assets/inter-latin-400-normal-C38fXH4l.woff2 +0 -0
- package/web/dist/assets/inter-latin-500-normal-Cerq10X2.woff2 +0 -0
- package/web/dist/assets/inter-latin-600-normal-LgqL8muc.woff2 +0 -0
- package/web/dist/assets/inter-latin-700-normal-Yt3aPRUw.woff2 +0 -0
- package/web/dist/assets/jetbrains-mono-latin-400-normal-V6pRDFza.woff2 +0 -0
- package/web/dist/assets/jetbrains-mono-latin-500-normal-BWZEU5yA.woff2 +0 -0
- package/web/dist/assets/jetbrains-mono-latin-600-normal-C8RAYTDA.woff2 +0 -0
- package/web/dist/assets/marked.esm-JKvyS0Pp.js +46 -0
- package/web/dist/assets/oswald-latin-400-normal-BAFo8sP8.woff2 +0 -0
- package/web/dist/assets/oswald-latin-500-normal-B-eyT0Dw.woff2 +0 -0
- package/web/dist/assets/oswald-latin-600-normal-plFmSr5g.woff2 +0 -0
- package/web/dist/assets/oswald-latin-700-normal-vy6hmKSr.woff2 +0 -0
- package/web/dist/assets/poppins-latin-400-normal-cpxAROuN.woff2 +0 -0
- package/web/dist/assets/poppins-latin-500-normal-C8OXljZJ.woff2 +0 -0
- package/web/dist/assets/poppins-latin-600-normal-zEkxB9Mr.woff2 +0 -0
- package/web/dist/assets/poppins-latin-700-normal-Qrb0O0WB.woff2 +0 -0
- package/web/dist/assets/react-DOpiTn5v.js +32 -0
- package/web/dist/assets/roman-DkYN0LX9.js +1 -0
- package/web/dist/assets/space-grotesk-latin-400-normal-CJ-V5oYT.woff2 +0 -0
- package/web/dist/assets/space-grotesk-latin-500-normal-lFbtlQH6.woff2 +0 -0
- package/web/dist/assets/space-grotesk-latin-600-normal-DjKNqYRj.woff2 +0 -0
- package/web/dist/assets/space-grotesk-latin-700-normal-RjhwGPKo.woff2 +0 -0
- package/web/dist/assets/space-mono-latin-400-italic-YylcN9Ay.woff2 +0 -0
- package/web/dist/assets/space-mono-latin-400-normal-Rg4St2Dn.woff2 +0 -0
- package/web/dist/assets/space-mono-latin-700-italic-vNvENeTh.woff2 +0 -0
- package/web/dist/assets/space-mono-latin-700-normal-mWgeinG7.woff2 +0 -0
- package/web/dist/icon-maskable.svg +10 -0
- package/web/dist/icon.svg +8 -0
- package/web/dist/index.html +21 -0
- package/web/dist/manifest.webmanifest +14 -0
- package/web/dist/sw.js +40 -0
- package/web/src/pages/Laws.jsx +230 -0
|
@@ -0,0 +1,396 @@
|
|
|
1
|
+
// Geneseed — OpenCode learn plugin.
|
|
2
|
+
//
|
|
3
|
+
// The runtime-agnostic counterpart of the Claude Code `Stop` hook: on
|
|
4
|
+
// `session.idle` it distils durable memories from the conversation and writes them
|
|
5
|
+
// into the bundle's memory/ dir, maintaining MEMORY.md — exactly what the harness's
|
|
6
|
+
// own `learn` verb does, but self-contained in this plugin so no separate process and
|
|
7
|
+
// no model CLI are required. It distils with the SAME model the session already used
|
|
8
|
+
// (read from the transcript), so it inherits your OpenCode provider config: no API
|
|
9
|
+
// key, nothing to set for the model.
|
|
10
|
+
//
|
|
11
|
+
// TIMING — `session.idle` fires after EVERY turn (it is OpenCode's "session became
|
|
12
|
+
// idle / response ready" signal), not only at the true end of a session. Distilling
|
|
13
|
+
// on the first idle would therefore mine just turn 1 and miss the rest. So each idle
|
|
14
|
+
// RE-ARMS a debounce timer (GENESEED_LEARN_DEBOUNCE_MS, default 60s) and the distil
|
|
15
|
+
// runs once the session has been quiet for that long — i.e. at the real end. A
|
|
16
|
+
// continued session simply re-arms and re-distils; slug dedup keeps writes
|
|
17
|
+
// idempotent. (Trade-off: if OpenCode is killed inside the window the final distil
|
|
18
|
+
// is lost — still strictly better than capturing only turn 1, and far cheaper than
|
|
19
|
+
// re-distilling every turn.)
|
|
20
|
+
//
|
|
21
|
+
// Install (global — the bundle is used everywhere, so the plugin should be too):
|
|
22
|
+
// copy this file to ~/.config/opencode/plugins/geneseed-learn.js
|
|
23
|
+
// Or per-project: .opencode/plugins/geneseed-learn.js (build --emit opencode
|
|
24
|
+
// drops it there for you).
|
|
25
|
+
//
|
|
26
|
+
// Where it writes — first match wins:
|
|
27
|
+
// 1. $GENESEED_MEMORY an explicit memory dir
|
|
28
|
+
// 2. $GENESEED_HARNESS/memory (or /anamnesis for the imperial theme)
|
|
29
|
+
// 3. ./memory or ./Harness/memory (when the bundle is inside the project)
|
|
30
|
+
// If none resolve, it logs once and does nothing — it never blocks a session.
|
|
31
|
+
//
|
|
32
|
+
// It is intentionally conservative: trivial sessions are skipped, each session is
|
|
33
|
+
// processed at most once per process, and any error is swallowed.
|
|
34
|
+
|
|
35
|
+
import { promises as fs } from "node:fs"
|
|
36
|
+
import * as path from "node:path"
|
|
37
|
+
import { fileURLToPath } from "node:url"
|
|
38
|
+
import { homedir } from "node:os"
|
|
39
|
+
|
|
40
|
+
// The plugin's own directory. In the global install the layout is
|
|
41
|
+
// <cfg>/plugins/geneseed-learn.js beside <cfg>/memory, so the store can be found
|
|
42
|
+
// relative to this file — no manual `export GENESEED_HARNESS` needed.
|
|
43
|
+
const PLUGIN_DIR = path.dirname(fileURLToPath(import.meta.url))
|
|
44
|
+
|
|
45
|
+
// Sovereign-repo bypass — twin of the harness's own `sovereignBypass()`.
|
|
46
|
+
// <cfg>/excludes.json (user-owned, managed by `harness exclude`) lists folders where
|
|
47
|
+
// this GLOBAL install goes dormant. Any error degrades to "not excluded".
|
|
48
|
+
const norm = (p) => {
|
|
49
|
+
// Expand a leading "~" / "~/…" to the home dir — a hand-edited excludes.json entry
|
|
50
|
+
// like "~/vault" must resolve the same way here as it does for the Claude/Bob
|
|
51
|
+
// hook guard.
|
|
52
|
+
let raw = String(p)
|
|
53
|
+
if (raw === "~" || raw.startsWith("~/") || raw.startsWith("~\\")) {
|
|
54
|
+
raw = raw === "~" ? homedir() : path.join(homedir(), raw.slice(2))
|
|
55
|
+
}
|
|
56
|
+
let s = path.resolve(raw)
|
|
57
|
+
return process.platform === "win32" ? s.toLowerCase() : s
|
|
58
|
+
}
|
|
59
|
+
async function sovereignBypass(cwd) {
|
|
60
|
+
try {
|
|
61
|
+
const cfg = path.dirname(PLUGIN_DIR) // plugins/ sits directly in <cfg>
|
|
62
|
+
const raw = await fs.readFile(path.join(cfg, "excludes.json"), "utf8")
|
|
63
|
+
const entries = (JSON.parse(raw).excludes) || []
|
|
64
|
+
const here = norm(cwd || process.cwd())
|
|
65
|
+
for (const e of entries) {
|
|
66
|
+
const p = typeof e === "string" ? e : e && e.path
|
|
67
|
+
if (typeof p !== "string" || !p.trim()) continue
|
|
68
|
+
const base = norm(p.trim()).replace(/[\\/]+$/, "")
|
|
69
|
+
if (here === base || here.startsWith(base + path.sep)) return true
|
|
70
|
+
}
|
|
71
|
+
} catch { /* degrade to active */ }
|
|
72
|
+
return false
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const MAX_NOTES_CHARS = 16000 // cap the prompt; keep the most recent tail
|
|
76
|
+
const MIN_NOTES_CHARS = 200 // below this, the session is too trivial to mine
|
|
77
|
+
const MEMORY_DIR_NAMES = ["memory", "anamnesis"] // neutral + imperial
|
|
78
|
+
// NaN-safe: garbage in the env var would make setTimeout fire immediately (NaN
|
|
79
|
+
// delay → 0), distilling after EVERY turn instead of at session end.
|
|
80
|
+
const _debounceRaw = Number(process.env.GENESEED_LEARN_DEBOUNCE_MS || 60000)
|
|
81
|
+
const DEBOUNCE_MS = Number.isFinite(_debounceRaw) && _debounceRaw >= 0 ? _debounceRaw : 60000
|
|
82
|
+
|
|
83
|
+
// SINGLE SOURCE of the distil instructions: the harness extracts this exact
|
|
84
|
+
// LEARN_PROMPT_HEAD template literal at runtime, so there is nothing to keep in sync.
|
|
85
|
+
const LEARN_PROMPT_HEAD = `You are distilling durable memories from the notes below. Output zero or more
|
|
86
|
+
Markdown memory files in this exact format, separated by a line containing only
|
|
87
|
+
'---FILE---':
|
|
88
|
+
|
|
89
|
+
---
|
|
90
|
+
name: <kebab-case-slug>
|
|
91
|
+
description: <one-line summary>
|
|
92
|
+
type: user | feedback | project | reference
|
|
93
|
+
---
|
|
94
|
+
<the fact, stated plainly. For 'feedback' and 'project', add **Why:** and
|
|
95
|
+
**How to apply:** lines.>
|
|
96
|
+
|
|
97
|
+
DEFAULT TO 'NOTHING'. Writing a memory is the rare exception, not the norm — most
|
|
98
|
+
sessions should yield zero. Emit AT MOST ONE, and only if it is a GENERAL, reusable
|
|
99
|
+
principle you would want at the START of an unrelated future task.
|
|
100
|
+
|
|
101
|
+
CAPTURE only (high altitude, cross-session):
|
|
102
|
+
- a stable USER preference or working style the user showed or stated;
|
|
103
|
+
- a CONVENTION or constraint that will hold across many future tasks;
|
|
104
|
+
- a durable PROJECT decision or goal that outlives this session.
|
|
105
|
+
|
|
106
|
+
NEVER capture (this is the noise to cut):
|
|
107
|
+
- what was done, found, fixed, or resolved this session ("fixed X", "resolved
|
|
108
|
+
issue 7", "cleaned up the TODO", "renamed Y", "the landing screen redirects");
|
|
109
|
+
- anything tied to one file, function, ticket, bug, or feature;
|
|
110
|
+
- anything derivable from the code, the git history, or the current diff.
|
|
111
|
+
|
|
112
|
+
If you can only phrase it as "this session we …", it is NOT a memory:
|
|
113
|
+
- Too specific: "Fixed the failing test by adding await on line 42."
|
|
114
|
+
- Right (meta): "User wants async setup helpers always awaited."
|
|
115
|
+
- Too specific: "Resolved the magic-constants issue in config.js." -> NO memory
|
|
116
|
+
(a closed task, not a durable lesson).
|
|
117
|
+
|
|
118
|
+
When in doubt, output exactly: NOTHING.`
|
|
119
|
+
|
|
120
|
+
async function isDir(p) {
|
|
121
|
+
try { return (await fs.stat(p)).isDirectory() } catch { return false }
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async function resolveMemoryDir() {
|
|
125
|
+
const env = process.env.GENESEED_MEMORY
|
|
126
|
+
if (env && (await isDir(env))) return env
|
|
127
|
+
const harness = process.env.GENESEED_HARNESS
|
|
128
|
+
const bases = []
|
|
129
|
+
if (harness) bases.push(harness)
|
|
130
|
+
// The plugin's own dir (global install: <cfg>/plugins → <cfg>/memory) and the repo.
|
|
131
|
+
bases.push(path.resolve(PLUGIN_DIR, ".."), process.cwd(), path.join(process.cwd(), "Harness"))
|
|
132
|
+
for (const base of bases) {
|
|
133
|
+
for (const name of MEMORY_DIR_NAMES) {
|
|
134
|
+
const cand = path.join(base, name)
|
|
135
|
+
if (await isDir(cand)) return cand
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return null
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function partsText(parts) {
|
|
142
|
+
return (parts || [])
|
|
143
|
+
.filter((p) => p && p.type === "text" && typeof p.text === "string")
|
|
144
|
+
.map((p) => p.text)
|
|
145
|
+
.join("\n")
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// ---- per-agent memory (memory/agents/<name>.md) ------------------------------
|
|
149
|
+
// Child subagent sessions are task fragments — never mined into the main store —
|
|
150
|
+
// but a lesson about how the *agent* should operate next time is durable. Kept
|
|
151
|
+
// deliberately conservative, like the rest of this plugin: an unresolvable agent
|
|
152
|
+
// name skips entirely. The harness's own twin is behaviour-identical
|
|
153
|
+
// so claude/bob get the same loop via their Stop/SubagentStop hooks.
|
|
154
|
+
const AGENT_NAME_RE = /^[a-z][a-z0-9-]{1,40}$/
|
|
155
|
+
const MAX_AGENT_BULLETS = 100 // hard cap, oldest dropped; no pruning heuristics
|
|
156
|
+
|
|
157
|
+
// SINGLE SOURCE — the harness extracts this exact literal (see LEARN_PROMPT_HEAD).
|
|
158
|
+
export const AGENT_LESSON_PROMPT = `The notes below are one subagent dispatch ("agent run").
|
|
159
|
+
Output AT MOST ONE line: a durable lesson about how this agent should operate on a
|
|
160
|
+
FUTURE, unrelated dispatch (a boundary that proved wrong, an input it always needs,
|
|
161
|
+
a method that worked). Task residue ("fixed X", file names, ticket detail) is NOT a
|
|
162
|
+
lesson. If there is none — the overwhelmingly common case — output exactly: NOTHING.`
|
|
163
|
+
|
|
164
|
+
export function resolveAgentName(meta) {
|
|
165
|
+
const raw = meta?.agent ?? meta?.agentName ?? meta?.agentID ?? null
|
|
166
|
+
const name = typeof raw === "string" ? raw.trim().toLowerCase() : null
|
|
167
|
+
return name && AGENT_NAME_RE.test(name) ? name : null
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export async function appendAgentLesson(memDir, agent, lesson) {
|
|
171
|
+
const dir = path.join(memDir, "agents")
|
|
172
|
+
await fs.mkdir(dir, { recursive: true })
|
|
173
|
+
const file = path.join(dir, `${agent}.md`)
|
|
174
|
+
let bullets = []
|
|
175
|
+
try {
|
|
176
|
+
bullets = (await fs.readFile(file, "utf8")).split("\n").filter((l) => l.startsWith("- "))
|
|
177
|
+
} catch { /* first lesson for this agent */ }
|
|
178
|
+
const day = new Date().toISOString().slice(0, 10)
|
|
179
|
+
bullets.push(`- ${day}: ${lesson.replace(/\s+/g, " ").trim()}`)
|
|
180
|
+
bullets = bullets.slice(-MAX_AGENT_BULLETS)
|
|
181
|
+
await fs.writeFile(file, `# ${agent} — lessons\n` + bullets.join("\n") + "\n", "utf8")
|
|
182
|
+
return file
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Flatten a session's messages into "role: text" notes, and pick the model the
|
|
186
|
+
// session actually used so we distil with the user's own provider config.
|
|
187
|
+
function flatten(messages) {
|
|
188
|
+
const lines = []
|
|
189
|
+
let model = null
|
|
190
|
+
for (const m of messages) {
|
|
191
|
+
const info = m?.info ?? {}
|
|
192
|
+
const role = info.role
|
|
193
|
+
if (role !== "user" && role !== "assistant") continue
|
|
194
|
+
const text = partsText(m?.parts).trim()
|
|
195
|
+
if (text) lines.push(`${role}: ${text}`)
|
|
196
|
+
if (role === "assistant") {
|
|
197
|
+
const providerID = info.providerID ?? info.provider
|
|
198
|
+
const modelID = info.modelID ?? info.model
|
|
199
|
+
if (providerID && modelID) model = { providerID, modelID }
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
return { notes: lines.join("\n\n"), model }
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function envModel() {
|
|
206
|
+
const v = process.env.GENESEED_MODEL // "provider/model"
|
|
207
|
+
if (v && v.includes("/")) {
|
|
208
|
+
const [providerID, ...rest] = v.split("/")
|
|
209
|
+
return { providerID, modelID: rest.join("/") }
|
|
210
|
+
}
|
|
211
|
+
return null
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
async function existingSlugs(memDir) {
|
|
215
|
+
const skip = new Set(["memory", "readme"])
|
|
216
|
+
try {
|
|
217
|
+
const entries = await fs.readdir(memDir)
|
|
218
|
+
return new Set(
|
|
219
|
+
entries
|
|
220
|
+
.filter((f) => f.endsWith(".md"))
|
|
221
|
+
.map((f) => f.slice(0, -3))
|
|
222
|
+
.filter((s) => !skip.has(s.toLowerCase()))
|
|
223
|
+
)
|
|
224
|
+
} catch {
|
|
225
|
+
return new Set()
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function buildPrompt(notes, existing) {
|
|
230
|
+
const parts = [LEARN_PROMPT_HEAD, ""]
|
|
231
|
+
if (existing.size) {
|
|
232
|
+
parts.push(
|
|
233
|
+
"ALREADY STORED — do NOT emit a memory matching any of these slugs " +
|
|
234
|
+
"(skip updates too; only genuinely new facts):"
|
|
235
|
+
)
|
|
236
|
+
for (const slug of [...existing].sort()) parts.push(`- ${slug}`)
|
|
237
|
+
parts.push("")
|
|
238
|
+
}
|
|
239
|
+
parts.push("NOTES:", notes)
|
|
240
|
+
return parts.join("\n")
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function parseFrontmatter(chunk) {
|
|
244
|
+
const m = chunk.match(/^\s*---\s*\n([\s\S]*?)\n---\s*\n?([\s\S]*)$/)
|
|
245
|
+
if (!m) return {}
|
|
246
|
+
const fm = {}
|
|
247
|
+
for (const line of m[1].split("\n")) {
|
|
248
|
+
const i = line.indexOf(":")
|
|
249
|
+
if (i > 0) fm[line.slice(0, i).trim()] = line.slice(i + 1).trim().replace(/^"|"$/g, "")
|
|
250
|
+
}
|
|
251
|
+
return fm
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
async function writeMemories(output, memDir, existing) {
|
|
255
|
+
const written = []
|
|
256
|
+
const indexLines = []
|
|
257
|
+
for (let chunk of output.split(/^---FILE---\s*$/m)) {
|
|
258
|
+
chunk = chunk.trim()
|
|
259
|
+
if (!chunk || chunk.toUpperCase() === "NOTHING") continue
|
|
260
|
+
const fm = parseFrontmatter(chunk)
|
|
261
|
+
const name = (fm.name || "").trim()
|
|
262
|
+
if (!name || existing.has(name)) continue
|
|
263
|
+
await fs.writeFile(path.join(memDir, `${name}.md`), chunk.replace(/\n+$/, "") + "\n", "utf8")
|
|
264
|
+
existing.add(name)
|
|
265
|
+
written.push(name)
|
|
266
|
+
const desc = (fm.description || "").trim()
|
|
267
|
+
indexLines.push(`- [${name}](${name}.md)` + (desc ? ` — ${desc}` : ""))
|
|
268
|
+
}
|
|
269
|
+
if (indexLines.length) {
|
|
270
|
+
const index = path.join(memDir, "MEMORY.md")
|
|
271
|
+
let current = "# Memory Index\n"
|
|
272
|
+
try { current = (await fs.readFile(index, "utf8")).replace(/\n+$/, "") + "\n" } catch {}
|
|
273
|
+
await fs.writeFile(index, current + indexLines.join("\n") + "\n", "utf8")
|
|
274
|
+
}
|
|
275
|
+
return written
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
export const GeneseedLearn = async ({ client }) => {
|
|
279
|
+
const ours = new Set() // throwaway distil sessions — never mine our own output
|
|
280
|
+
const timers = new Map() // sid -> debounce timer, re-armed on each idle
|
|
281
|
+
const inFlight = new Set() // sid currently being distilled (guards against overlap)
|
|
282
|
+
let warnedNoDir = false
|
|
283
|
+
|
|
284
|
+
async function runDistill(sid) {
|
|
285
|
+
if (await sovereignBypass(process.cwd())) return
|
|
286
|
+
if (inFlight.has(sid)) return // a run is already underway; the next idle re-arms
|
|
287
|
+
inFlight.add(sid)
|
|
288
|
+
try {
|
|
289
|
+
// A child session (carries a parent link) is a subagent dispatch: never mined
|
|
290
|
+
// into the shared store, but distilled for ONE per-agent lesson if we can name
|
|
291
|
+
// the agent. Field absent → top-level user session, distilled the old way.
|
|
292
|
+
// Unresolvable agent on a child → skip entirely (the prior behaviour).
|
|
293
|
+
const info = await client.session.get?.({ path: { id: sid } }).catch(() => null)
|
|
294
|
+
const meta = info?.data ?? info
|
|
295
|
+
const isChild = !!(meta?.parentID ?? meta?.parentId)
|
|
296
|
+
const agentName = isChild ? resolveAgentName(meta) : null
|
|
297
|
+
if (isChild && !agentName) return
|
|
298
|
+
|
|
299
|
+
const memDir = await resolveMemoryDir()
|
|
300
|
+
if (!memDir) {
|
|
301
|
+
if (!warnedNoDir) {
|
|
302
|
+
warnedNoDir = true
|
|
303
|
+
console.error(
|
|
304
|
+
"[geneseed-learn] no memory dir — set $GENESEED_HARNESS or $GENESEED_MEMORY."
|
|
305
|
+
)
|
|
306
|
+
}
|
|
307
|
+
return
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
const res = await client.session.messages({ path: { id: sid } })
|
|
311
|
+
const messages = Array.isArray(res) ? res : res?.data ?? []
|
|
312
|
+
let { notes, model } = flatten(messages)
|
|
313
|
+
if (notes.length < MIN_NOTES_CHARS) return
|
|
314
|
+
notes = notes.slice(-MAX_NOTES_CHARS)
|
|
315
|
+
model = model ?? envModel()
|
|
316
|
+
if (!model) {
|
|
317
|
+
console.error(
|
|
318
|
+
"[geneseed-learn] could not determine a model — set $GENESEED_MODEL=provider/model."
|
|
319
|
+
)
|
|
320
|
+
return
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
const existing = await existingSlugs(memDir)
|
|
324
|
+
const prompt = isChild
|
|
325
|
+
? [AGENT_LESSON_PROMPT, "", "NOTES:", notes].join("\n")
|
|
326
|
+
: buildPrompt(notes, existing)
|
|
327
|
+
|
|
328
|
+
// Distil in a throwaway session with the session's own model, then drop it.
|
|
329
|
+
const session = await client.session.create({ body: { title: "geneseed-learn (auto)" } })
|
|
330
|
+
const newId = session?.id ?? session?.data?.id
|
|
331
|
+
if (!newId) {
|
|
332
|
+
// Prompting with {id: undefined} would 404 silently on every idle.
|
|
333
|
+
console.error("[geneseed-learn] session.create returned no id — skipping this distil.")
|
|
334
|
+
return
|
|
335
|
+
}
|
|
336
|
+
ours.add(newId)
|
|
337
|
+
let output = ""
|
|
338
|
+
try {
|
|
339
|
+
const reply = await client.session.prompt({
|
|
340
|
+
path: { id: newId },
|
|
341
|
+
body: { model, parts: [{ type: "text", text: prompt }] },
|
|
342
|
+
})
|
|
343
|
+
output = partsText(reply?.parts ?? reply?.data?.parts) || reply?.text || ""
|
|
344
|
+
} finally {
|
|
345
|
+
if (newId) await client.session.delete({ path: { id: newId } }).catch(() => {})
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
if (!output.trim() || output.trim().toUpperCase() === "NOTHING") return
|
|
349
|
+
if (isChild) {
|
|
350
|
+
const lesson = output.trim().split("\n")[0].replace(/^[-*]\s*/, "").trim()
|
|
351
|
+
if (lesson.length < 10 || lesson.length > 300) return // junk / truncation guard
|
|
352
|
+
const f = await appendAgentLesson(memDir, agentName, lesson)
|
|
353
|
+
console.error(`[geneseed-learn] agent lesson -> ${f}`)
|
|
354
|
+
return
|
|
355
|
+
}
|
|
356
|
+
const written = await writeMemories(output, memDir, existing)
|
|
357
|
+
if (written.length) {
|
|
358
|
+
console.error(`[geneseed-learn] wrote ${written.length} memory file(s): ${written.join(", ")}`)
|
|
359
|
+
}
|
|
360
|
+
} catch (err) {
|
|
361
|
+
// Never break the session over a memory write.
|
|
362
|
+
console.error(`[geneseed-learn] skipped: ${err?.message ?? err}`)
|
|
363
|
+
} finally {
|
|
364
|
+
inFlight.delete(sid)
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
return {
|
|
369
|
+
event: async ({ event }) => {
|
|
370
|
+
if (!event) return
|
|
371
|
+
const sid =
|
|
372
|
+
event.properties?.sessionID ??
|
|
373
|
+
event.payload?.sessionID ??
|
|
374
|
+
event.properties?.info?.id ??
|
|
375
|
+
event.payload?.info?.id
|
|
376
|
+
// A deleted session must not distil later from a stale timer (and its entry
|
|
377
|
+
// must not leak in the maps for the life of the process).
|
|
378
|
+
if (event.type === "session.deleted") {
|
|
379
|
+
const t = timers.get(sid)
|
|
380
|
+
if (t) clearTimeout(t)
|
|
381
|
+
timers.delete(sid)
|
|
382
|
+
ours.delete(sid)
|
|
383
|
+
return
|
|
384
|
+
}
|
|
385
|
+
if (event.type !== "session.idle") return
|
|
386
|
+
if (!sid || ours.has(sid)) return
|
|
387
|
+
// session.idle fires after every turn — debounce so we distil once, at the real
|
|
388
|
+
// end of the session (see header note). Each idle re-arms the timer.
|
|
389
|
+
const prev = timers.get(sid)
|
|
390
|
+
if (prev) clearTimeout(prev)
|
|
391
|
+
timers.set(sid, setTimeout(() => { timers.delete(sid); runDistill(sid) }, DEBOUNCE_MS))
|
|
392
|
+
},
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
export default GeneseedLearn
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
// Geneseed — OpenCode desktop-notification plugin.
|
|
2
|
+
//
|
|
3
|
+
// Pings the OS when the agent finishes a turn, so you can walk away from a long run
|
|
4
|
+
// and be called back when it is your move again. Hooks OpenCode's `session.idle`
|
|
5
|
+
// event (fired when the assistant goes quiet) the same "act, don't just instruct"
|
|
6
|
+
// way the other plugins hook their events.
|
|
7
|
+
//
|
|
8
|
+
// ANTI-SPAM: it only fires when the turn actually took a while — the gap between the
|
|
9
|
+
// session's last user prompt and now must exceed GENESEED_NOTIFY_MIN_SECONDS
|
|
10
|
+
// (default 30). A two-second back-and-forth never notifies; a five-minute build or
|
|
11
|
+
// test run does. Native subagent child sessions and the learn plugin's throwaway
|
|
12
|
+
// `geneseed-*` distil sessions are skipped, so background machinery stays silent.
|
|
13
|
+
//
|
|
14
|
+
// GENESEED_NOTIFY=off disable entirely.
|
|
15
|
+
// GENESEED_NOTIFY_MIN_SECONDS=N minimum turn length, in seconds, to notify (default 30; 0 = always).
|
|
16
|
+
// GENESEED_NOTIFY_TITLE="…" override the notification title (default "Geneseed").
|
|
17
|
+
// GENESEED_DEBUG=1 log decisions/delivery to stderr.
|
|
18
|
+
//
|
|
19
|
+
// DELIVERY is native and dependency-free — no npm package, nothing to install:
|
|
20
|
+
// macOS → `osascript` (always present)
|
|
21
|
+
// Linux → `notify-send` (from libnotify; absent → swallowed, no error)
|
|
22
|
+
// Windows → PowerShell balloon tip (System.Windows.Forms.NotifyIcon)
|
|
23
|
+
// The notifier is spawned detached and unref'd; a missing binary or any failure is
|
|
24
|
+
// swallowed, so it never blocks, delays, or breaks a session.
|
|
25
|
+
//
|
|
26
|
+
// Install: dropped into the plugins dir by `build --emit opencode[-global]` (the *.js
|
|
27
|
+
// glob), exactly like the context, learn, guard, and workflow plugins.
|
|
28
|
+
|
|
29
|
+
import { spawn } from "node:child_process"
|
|
30
|
+
|
|
31
|
+
const MODE = (process.env.GENESEED_NOTIFY || "on").toLowerCase()
|
|
32
|
+
const OFF = ["off", "0", "false", "no"].includes(MODE)
|
|
33
|
+
// NaN-safe: Math.max(0, NaN) is NaN, and `elapsed >= NaN` is always false — garbage
|
|
34
|
+
// in the env var would silently disable every notification.
|
|
35
|
+
const _minRaw = Number(process.env.GENESEED_NOTIFY_MIN_SECONDS ?? 30)
|
|
36
|
+
const MIN_MS = Math.max(0, Number.isFinite(_minRaw) ? _minRaw : 30) * 1000
|
|
37
|
+
const TITLE = process.env.GENESEED_NOTIFY_TITLE || "Geneseed"
|
|
38
|
+
const DEBUG = !!process.env.GENESEED_DEBUG
|
|
39
|
+
function log(msg) { if (DEBUG) console.error(`[geneseed-notify] ${msg}`) }
|
|
40
|
+
|
|
41
|
+
// The timestamp (ms) of the latest user prompt in a transcript — our proxy for "when
|
|
42
|
+
// this turn started", so we can measure how long the agent worked. Tolerant of the
|
|
43
|
+
// few shapes `info.time` takes across SDK versions. Exported for tests.
|
|
44
|
+
function lastUserMs(messages) {
|
|
45
|
+
if (!Array.isArray(messages)) return null
|
|
46
|
+
let t = null
|
|
47
|
+
for (const m of messages) {
|
|
48
|
+
const info = m?.info ?? {}
|
|
49
|
+
if (info.role !== "user") continue
|
|
50
|
+
const created = info?.time?.created ?? info?.time ?? info?.created
|
|
51
|
+
if (typeof created === "number") t = created
|
|
52
|
+
}
|
|
53
|
+
return t
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Pure gate: notify only for a real, top-level, long-enough turn. Exported for tests.
|
|
57
|
+
// - a subagent child session (has a parent) is background work → no.
|
|
58
|
+
// - a `geneseed-*` session is the learn plugin's throwaway distil → no.
|
|
59
|
+
// - unknown turn length → yes (don't suppress an opt-in ping over a missing field).
|
|
60
|
+
// - otherwise: only if the turn ran at least `minMs`.
|
|
61
|
+
function shouldNotify({ now, lastUserMs, parentID, title, minMs }) {
|
|
62
|
+
if (parentID) return false
|
|
63
|
+
if (typeof title === "string" && title.startsWith("geneseed-")) return false
|
|
64
|
+
if (lastUserMs == null) return true
|
|
65
|
+
return now - lastUserMs >= minMs
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// AppleScript double-quoted string literal.
|
|
69
|
+
function asStr(s) { return '"' + String(s).replace(/\\/g, "\\\\").replace(/"/g, '\\"') + '"' }
|
|
70
|
+
// PowerShell single-quoted string literal (escape ' by doubling).
|
|
71
|
+
function psStr(s) { return "'" + String(s).replace(/'/g, "''") + "'" }
|
|
72
|
+
|
|
73
|
+
function spawnDetached(cmd, args) {
|
|
74
|
+
const child = spawn(cmd, args, { stdio: "ignore", detached: true })
|
|
75
|
+
child.on("error", (e) => log(`spawn ${cmd} failed: ${e?.message ?? e}`)) // e.g. notifier not installed
|
|
76
|
+
child.unref()
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Best-effort native notification; every failure is swallowed.
|
|
80
|
+
function deliver(title, body) {
|
|
81
|
+
try {
|
|
82
|
+
if (process.platform === "darwin") {
|
|
83
|
+
spawnDetached("osascript", ["-e", `display notification ${asStr(body)} with title ${asStr(title)}`])
|
|
84
|
+
} else if (process.platform === "win32") {
|
|
85
|
+
const script =
|
|
86
|
+
"Add-Type -AssemblyName System.Windows.Forms;" +
|
|
87
|
+
"Add-Type -AssemblyName System.Drawing;" +
|
|
88
|
+
"$n=New-Object System.Windows.Forms.NotifyIcon;" +
|
|
89
|
+
"$n.Icon=[System.Drawing.SystemIcons]::Information;$n.Visible=$true;" +
|
|
90
|
+
`$n.ShowBalloonTip(5000,${psStr(title)},${psStr(body)},[System.Windows.Forms.ToolTipIcon]::Info);` +
|
|
91
|
+
"Start-Sleep -Seconds 6;$n.Dispose()"
|
|
92
|
+
spawnDetached("powershell", ["-NoProfile", "-NonInteractive", "-Command", script])
|
|
93
|
+
} else {
|
|
94
|
+
spawnDetached("notify-send", [title, body])
|
|
95
|
+
}
|
|
96
|
+
} catch (err) {
|
|
97
|
+
log(`deliver failed: ${err?.message ?? err}`)
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export const GeneseedNotify = async ({ client }) => {
|
|
102
|
+
return {
|
|
103
|
+
event: async ({ event }) => {
|
|
104
|
+
if (OFF || !event || event.type !== "session.idle") return
|
|
105
|
+
const sid =
|
|
106
|
+
event.properties?.sessionID ?? event.payload?.sessionID ??
|
|
107
|
+
event.properties?.info?.id ?? event.payload?.info?.id
|
|
108
|
+
if (!sid) return
|
|
109
|
+
try {
|
|
110
|
+
let title = "", parentID
|
|
111
|
+
try {
|
|
112
|
+
const info = await client.session.get?.({ path: { id: sid } })
|
|
113
|
+
const meta = info?.data ?? info
|
|
114
|
+
title = meta?.title ?? ""
|
|
115
|
+
parentID = meta?.parentID ?? meta?.parentId
|
|
116
|
+
} catch {}
|
|
117
|
+
|
|
118
|
+
let started = null
|
|
119
|
+
try {
|
|
120
|
+
const res = await client.session.messages({ path: { id: sid } })
|
|
121
|
+
started = lastUserMs(Array.isArray(res) ? res : res?.data ?? [])
|
|
122
|
+
} catch {}
|
|
123
|
+
|
|
124
|
+
if (!shouldNotify({ now: Date.now(), lastUserMs: started, parentID, title, minMs: MIN_MS })) {
|
|
125
|
+
log(`skipped ${sid} (child/throwaway or turn under ${MIN_MS}ms)`)
|
|
126
|
+
return
|
|
127
|
+
}
|
|
128
|
+
deliver(TITLE, title ? `Done: ${title}` : "Agent finished — your turn.")
|
|
129
|
+
log(`notified for ${sid}`)
|
|
130
|
+
} catch (err) {
|
|
131
|
+
log(`skipped: ${err?.message ?? err}`)
|
|
132
|
+
}
|
|
133
|
+
},
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// ponytail: OpenCode treats every export as a plugin and rejects non-functions; a
|
|
138
|
+
// bare helper export crashes startup or logs "not a function". Hang the test helpers
|
|
139
|
+
// off the factory instead — reachable via `import`, invisible to the loader.
|
|
140
|
+
Object.assign(GeneseedNotify, { lastUserMs, shouldNotify })
|
|
141
|
+
|
|
142
|
+
export default GeneseedNotify
|