geneseed 4.0.0 → 4.1.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 +36 -0
- package/SHIPPED.md +1 -1
- package/adapters/opencode/HOW-OPENCODE-LOADS.md +35 -0
- package/adapters/opencode/plugins/geneseed-activity.js +105 -2
- package/adapters/opencode/plugins/geneseed-context.js +96 -1
- package/adapters/opencode/plugins/geneseed-guard.js +33 -1
- package/adapters/opencode/plugins/geneseed-learn.js +127 -14
- package/adapters/opencode/plugins/geneseed-notify.js +46 -1
- package/adapters/opencode/plugins/geneseed-ponytail.js +44 -2
- package/adapters/opencode/plugins/geneseed-workflow.js +124 -24
- package/docs/guides/mcp-filesystem.md +2 -2
- package/docs/guides/mcp-gitlab.md +2 -2
- package/docs/guides/mcp-markitdown.md +3 -3
- package/docs/guides/mcp.md +7 -7
- package/docs/guides/project-context.md +1 -1
- package/docs/reference/opencode-plugins.md +9 -0
- package/harness.config.json +1 -1
- package/js/cli-table.json +1 -1
- package/js/hosts/mcp.mjs +23 -152
- package/js/hosts/opencode.mjs +1 -1
- package/js/web/actions.mjs +15 -77
- package/js/web/api.mjs +8 -1
- package/js/web/handler.mjs +2 -2
- package/js/web/routes.mjs +1 -2
- package/package.json +1 -1
- package/src/agents/_template.md +2 -1
- package/src/agents/researcher.md +2 -1
- package/src/agents/reviewer.md +9 -4
- package/src/agents/tester.md +3 -3
- package/src/doctrines/rigor.md +8 -7
- package/src/skills/brainstorm/builder-check.md +16 -0
- package/src/skills/brainstorm.md +19 -11
- package/src/skills/daydream/SKILL.md +1 -1
- package/src/skills/daydream/VENDOR.md +4 -0
- package/src/skills/debug/ci.md +4 -4
- package/src/skills/debug.md +4 -4
- package/src/skills/develop.md +6 -4
- package/src/skills/fresh-eyes.md +1 -1
- package/src/skills/geneseed-code-review.md +8 -2
- package/src/skills/handoff.md +1 -1
- package/src/skills/parallel-agents.md +4 -4
- package/src/skills/pipeline.md +3 -3
- package/src/skills/plan.md +18 -6
- package/src/skills/review-response.md +5 -2
- package/src/skills/ship.md +2 -1
- package/src/skills/worktree.md +2 -0
- package/web/dist/assets/{Activity-BCtqxjfy.js → Activity-CqVt2u9s.js} +1 -1
- package/web/dist/assets/{ActivityDetail-Ba3Oo4dE.js → ActivityDetail-Bui5Gomc.js} +1 -1
- package/web/dist/assets/{Dashboard-DpHza68-.js → Dashboard-VlIx1-At.js} +2 -2
- package/web/dist/assets/{Docs-Bw_-UK0O.js → Docs-6cYYkVeA.js} +1 -1
- package/web/dist/assets/{GreenhouseView-DnJ1Ggp1.js → GreenhouseView-DgNY8QKU.js} +1 -1
- package/web/dist/assets/{Installs-CWoNKFBk.js → Installs-DZwZOUZJ.js} +1 -1
- package/web/dist/assets/{JournalView-g6-bw4c8.js → JournalView-DXpFbKOG.js} +1 -1
- package/web/dist/assets/{Laws-DkVWR1tb.js → Laws-Bh7ztekk.js} +1 -1
- package/web/dist/assets/{Library-CK9HZzjV.js → Library-DBLX-1SD.js} +1 -1
- package/web/dist/assets/{Loops-BP98SMOH.js → Loops-CvFMZ7_P.js} +1 -1
- package/web/dist/assets/{OperatorHudView-DI_n12M9.js → OperatorHudView-FPTUH1CB.js} +1 -1
- package/web/dist/assets/{Personal-Bk89f13a.js → Personal-DcVA502F.js} +1 -1
- package/web/dist/assets/{Tabs-3WuYIE1b.js → Tabs-DtyR4h75.js} +1 -1
- package/web/dist/assets/{api-CxgfYu18.js → api-CRqWld2H.js} +1 -1
- package/web/dist/assets/{drawable-oCUeq9e5.js → drawable-CNSPVZxX.js} +1 -1
- package/web/dist/assets/{index-CiXwChvf.js → index-BmjcLIOr.js} +2 -2
- package/web/dist/index.html +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,42 @@ label. For the capability ↔ spec map, see [SHIPPED.md](SHIPPED.md).
|
|
|
8
8
|
|
|
9
9
|
## [Unreleased]
|
|
10
10
|
|
|
11
|
+
## [4.1.0] — 2026-10-13
|
|
12
|
+
|
|
13
|
+
Minor: the planning, review, debugging and multi-agent skills take the lessons of Superpowers 7.0, and the host canary.
|
|
14
|
+
|
|
15
|
+
### Changed
|
|
16
|
+
- **brainstorm** asks which build choices (platform, language, key libraries) you want to own; one the agent makes goes in the decision list as its own call. It sizes the work: a quick task is just done, a small change keeps the confirmed list in chat as its spec, and only a project gets a spec file. A fresh subagent then reads that spec as the person who will build it and asks what they would have to guess, replacing the agent rereading its own spec.
|
|
17
|
+
- **plan**: a step records decisions (files, signatures, the spec's values, the test, the check command), not code. A step is marked done only once its check has run green, with the command on the line. A decision taken without asking gets a `Ruling:` line that is reported at the end, and the mid-run "stop for review" clause is gone. The plan file names its task and its spec, and the spec wins a conflict.
|
|
18
|
+
- **debug**: compare against the nearest working twin, trace a bad value back to where it was born, cite evidence as `file:line`, and probe a secret's presence, never its value. The CI branch diffs against the last green run first and replaces guessed sleeps with bounded waits.
|
|
19
|
+
- **Code review**: where the spec says nothing, a crash, hang or data loss on an input it implies is still blocking. Findings end with a *Set aside* list the caller rules on, and `ship` rules on it before pushing. The base is `origin/<default branch>` after a fetch. The reviewer never moves HEAD and never dispatches another reviewer.
|
|
20
|
+
- **pipeline / parallel-agents**: the developer↔tester loop gets two repair rounds, then one fresh developer, then stops (it allowed five, against `bound-the-loop`). An ambiguity is decided and recorded rather than stalling, small edits of one kind run as one unit, no dispatched agent dispatches its own, and an empty diff counts as a failure. `worktree` never forces a refused removal.
|
|
21
|
+
- The `cover-and-verify` doctrine and `develop` run the whole suite once at the end and name every failure, including ones the change did not cause; per step, only the affected tests still run.
|
|
22
|
+
|
|
23
|
+
### Fixed
|
|
24
|
+
- The daydream skill's cost line showed a mangled price on Claude Code, which replaces a dollar sign followed by a digit with the skill's arguments.
|
|
25
|
+
- On OpenCode 2, the Activity view now names each tool call. OpenCode 2 sends a tool's name only when its input starts streaming, not with the call itself, so every tool card read "undefined".
|
|
26
|
+
|
|
27
|
+
### Added
|
|
28
|
+
- A host canary, `node tests/host_canary.mjs`. It installs the real OpenCode 1, OpenCode 2, Claude Code and OpenClaude from npm and points each at a throwaway home holding the Geneseed install. It fails when a host refuses one of our OpenCode plugins, reports one of our Claude settings as invalid, or never runs our session-start hook. It never calls a model and needs no account. It runs in CI every Monday against the latest releases and on every pull request that changes what Geneseed installs, so the next host release that breaks the contract is caught within a week rather than by a user. IBM Bob is not covered, because Bob Shell is not published on npm.
|
|
29
|
+
|
|
30
|
+
## [4.0.2] — 2026-10-12
|
|
31
|
+
|
|
32
|
+
Patch: none of the OpenCode plugins loaded on OpenCode 2, and MCP is read-only.
|
|
33
|
+
|
|
34
|
+
### Fixed
|
|
35
|
+
- The seven OpenCode plugins now load on OpenCode 2. OpenCode 2 rewrote the plugin API and refused every Geneseed plugin with *"Plugin must export a default definition with an id and an effect or setup function"*. It skipped them without crashing, so the context load, learn, notify, ponytail, activity and the `workflow` tool were all gone, and the guard's safety gates were silently off. Each plugin now carries both APIs in one file and works on OpenCode 1.3.4 or newer and on OpenCode 2. Plugins no longer load on OpenCode older than 1.3.4. On OpenCode 2 a guard block shows up as a failed tool call that gives the reason, and the Activity view shows no todo list or files-touched count, because OpenCode 2 sends no event for either. Run `geneseed rebuild-all` after updating to install the new plugin files.
|
|
36
|
+
|
|
37
|
+
### Changed
|
|
38
|
+
- MCP is read-only in the web console and in `geneseed mcp`. Each active install lists only the servers its config already holds, enabled or disabled, with its config path and an **Open folder** button. The **Add** button, the on/off switch and the three built-in presets are gone. Which servers to wire depends on you and on your host, so you edit the config yourself; [MCP servers](docs/guides/mcp.md) still has the blocks for MarkItDown, GitLab and Filesystem. Geneseed no longer writes any MCP config, and the `POST /api/mcp` endpoint is removed.
|
|
39
|
+
|
|
40
|
+
## [4.0.1] — 2026-10-12
|
|
41
|
+
|
|
42
|
+
Patch: the web console could not tell apart two project installs of the same host (#244).
|
|
43
|
+
|
|
44
|
+
### Fixed
|
|
45
|
+
- Two project installs of one host (two repos on Bob, say) are now separate rows in the web console. Both used to get the id `bob:project`, so the Installs table showed both as selected and the topbar harness selector always switched back to the first. The id now carries the install root, and the selector names a project install by its folder. All four hosts had the bug and all four are covered by a test. Nothing is re-emitted; run `geneseed web restart` after updating.
|
|
46
|
+
|
|
11
47
|
## [4.0.0] — 2026-10-11
|
|
12
48
|
|
|
13
49
|
Major: a host-compatibility pass against the current IBM Bob, Claude Code, OpenCode and OpenClaude docs (#240), the host opportunities and a root-scan guard (#241), and author notes kept out of what the agent reads (#242). It is a major because three behaviours change visibly: Claude Code gates now block when they cannot run, OpenCode no longer falls back to `~/.claude/CLAUDE.md`, and the silent *process 8* migration is gone.
|
package/SHIPPED.md
CHANGED
|
@@ -20,7 +20,7 @@ historical references, not files distributed with the repo.
|
|
|
20
20
|
> **On version numbers.** [`harness.config.json`](harness.config.json)'s `version` is a
|
|
21
21
|
> human-readable label only. The canonical identity of an installed harness is the
|
|
22
22
|
> **source fingerprint** stamped in `.geneseed-version` (see `geneseed version`), not the
|
|
23
|
-
> string. Current label: **4.
|
|
23
|
+
> string. Current label: **4.1.0**.
|
|
24
24
|
|
|
25
25
|
## Capabilities
|
|
26
26
|
|
|
@@ -64,6 +64,41 @@ export const MyPlugin = async ({ client, $, directory, worktree }) => ({
|
|
|
64
64
|
**without** triggering a reply with `client.session.prompt({ path, body: { noReply: true, parts } })`.
|
|
65
65
|
- **`$`** — a shell executor for running commands.
|
|
66
66
|
|
|
67
|
+
### OpenCode 2 — a different plugin API
|
|
68
|
+
|
|
69
|
+
OpenCode 2 (the `@opencode/cli` package; V1 is still `opencode-ai`) still auto-loads every
|
|
70
|
+
`.js`/`.ts` in `plugins/` (or `plugin/`), but it accepts **only** a default-exported
|
|
71
|
+
definition: `export default { id, setup(ctx) }`. A V1 plugin fails to load with *"Plugin
|
|
72
|
+
must export a default definition with an id and an effect or setup function"*. Each one
|
|
73
|
+
is marked failed on its own and OpenCode carries on, so nothing warns you.
|
|
74
|
+
|
|
75
|
+
V1 (>= 1.3.4) also accepts an object default, `{ id, server }`, and each loader ignores
|
|
76
|
+
the other's key. So every Geneseed plugin exports **one object for both majors**:
|
|
77
|
+
`export default { id: "<file stem>", server: <V1 factory>, setup: <V2 setup> }`. On V1
|
|
78
|
+
older than 1.3.4 the plugins no longer load.
|
|
79
|
+
|
|
80
|
+
`setup(ctx)` gets domains instead of hooks returned in a map. Hooks register by name
|
|
81
|
+
(`ctx.tool.hook("execute.before")`, `ctx.session.hook("context" | "prompt" |
|
|
82
|
+
"compaction")`), tools through `ctx.tool.transform`, and events come from
|
|
83
|
+
`ctx.event.subscribe()` as `{ type, created, data }` (not `{ type, properties }`). What
|
|
84
|
+
changed for each plugin:
|
|
85
|
+
|
|
86
|
+
| V1 | V2 |
|
|
87
|
+
| --- | --- |
|
|
88
|
+
| `session.idle` | not published by core (deprecated). The turn ends with `session.execution.succeeded` / `failed` / `interrupted` |
|
|
89
|
+
| `experimental.chat.messages.transform` / `chat.system.transform` | `session.hook("context")`: mutable `system` parts and `LLM.Message` instances, on every request |
|
|
90
|
+
| `command.execute.before` | none. A markdown command's expanded template arrives through `session.hook("prompt")` |
|
|
91
|
+
| `client.session.messages` | `ctx.session.context({ sessionID })`: `user` / `assistant` messages after the last compaction |
|
|
92
|
+
| throwaway session + `prompt` to distil | `ctx.generate.text({ prompt, model })`: one stateless call |
|
|
93
|
+
| a hook throws to block a tool | a throw is a **defect that fails the whole step**. The guard renames the call instead (see `geneseed-guard.js`) |
|
|
94
|
+
| `@opencode-ai/plugin` `tool()` helper | `@opencode/plugin`; a plain JSON-schema `input` needs no helper |
|
|
95
|
+
| `todo.updated`, session `summary` diffs | no V2 event. The activity cards show no todos or files on V2 |
|
|
96
|
+
| tool part carries its name | `session.tool.called` has no name; it rides on `session.tool.input.started` |
|
|
97
|
+
| tools `bash`, `apply_patch`, `task` | renamed `shell`, `patch`, `subagent` (the old ids are aliases). The guard's substring classes cover both spellings |
|
|
98
|
+
|
|
99
|
+
None of this is checked by a mock alone: `node tests/host_canary.mjs` starts the real V1 and V2
|
|
100
|
+
and fails on the host's own `failed to load plugin` log line.
|
|
101
|
+
|
|
67
102
|
## 4. Subagents, skills & commands
|
|
68
103
|
|
|
69
104
|
Subdir names are **plural** canonically (`agents/`, `skills/`, `commands/`,
|
|
@@ -425,6 +425,109 @@ export const GeneseedActivity = async () => {
|
|
|
425
425
|
|
|
426
426
|
// ponytail: OpenCode treats every export as a plugin and rejects non-functions; hang
|
|
427
427
|
// the test helpers off the factory instead — reachable via import, invisible to the loader.
|
|
428
|
-
Object.assign(GeneseedActivity, { sidOf, nextStatus, applyEvent, acctTotals, errStr, snip, safeName, activityDir, enabledFromFlag })
|
|
428
|
+
Object.assign(GeneseedActivity, { sidOf, nextStatus, applyEvent, acctTotals, errStr, snip, safeName, activityDir, enabledFromFlag, toV1Events })
|
|
429
429
|
|
|
430
|
-
|
|
430
|
+
// --- OpenCode 2 --------------------------------------------------------------
|
|
431
|
+
// `export default { id, server, setup }` serves V1 (>= 1.3.4) and V2 from one file — why, and
|
|
432
|
+
// each V1 hook's V2 counterpart: adapters/opencode/HOW-OPENCODE-LOADS.md.
|
|
433
|
+
//
|
|
434
|
+
// V2's event stream is a different vocabulary (`{ type, created, data }`, the turn as an
|
|
435
|
+
// execution, a message as steps, tool calls as called/success/failed), but every fact the
|
|
436
|
+
// cards show is still in it. Rather than a second reducer, `toV1Events` translates each
|
|
437
|
+
// V2 event into the V1 event(s) the handler above already reduces, so both majors write
|
|
438
|
+
// the same files. Not carried: todos (V2 publishes no todo event) and files-touched (the
|
|
439
|
+
// V1 session summary has no V2 event).
|
|
440
|
+
|
|
441
|
+
// A V2 SessionError ({ type, message }) in the V1 error union errStr reads.
|
|
442
|
+
const v1Error = (e) => (e ? { name: e.type, data: { message: e.message || e.type } } : undefined)
|
|
443
|
+
|
|
444
|
+
// V2 event -> the V1 events it means ([] for one the cards ignore). `tools` remembers each
|
|
445
|
+
// running call's name and start (callID -> { name, start }) so its end can say both.
|
|
446
|
+
// Pure but for `tools`; exported for tests.
|
|
447
|
+
function toV1Events(ev, tools) {
|
|
448
|
+
const d = ev?.data ?? {}
|
|
449
|
+
const sessionID = d.sessionID
|
|
450
|
+
if (!sessionID) return []
|
|
451
|
+
const t = typeof ev.created === "number" ? ev.created : Date.now()
|
|
452
|
+
const msg = (info) => ({ type: "message.updated", properties: { info: { role: "assistant", sessionID, ...info } } })
|
|
453
|
+
const part = (p) => ({ type: "message.part.updated", properties: { part: { sessionID, ...p } } })
|
|
454
|
+
switch (ev.type) {
|
|
455
|
+
case "session.created":
|
|
456
|
+
return [{ type: "session.created", properties: { info: {
|
|
457
|
+
id: sessionID, title: d.title, parentID: d.parentID, directory: d.location?.directory } } }]
|
|
458
|
+
case "session.renamed":
|
|
459
|
+
return [{ type: "session.updated", properties: { info: { id: sessionID, title: d.title } } }]
|
|
460
|
+
case "session.deleted":
|
|
461
|
+
return [{ type: "session.deleted", properties: { sessionID } }]
|
|
462
|
+
case "session.execution.started":
|
|
463
|
+
return [{ type: "session.status", properties: { sessionID, status: { type: "busy" } } }]
|
|
464
|
+
case "session.execution.succeeded":
|
|
465
|
+
return [{ type: "session.idle", properties: { sessionID } }]
|
|
466
|
+
case "session.execution.failed":
|
|
467
|
+
return [{ type: "session.error", properties: { sessionID, error: v1Error(d.error) } },
|
|
468
|
+
{ type: "session.idle", properties: { sessionID } }]
|
|
469
|
+
case "session.execution.interrupted":
|
|
470
|
+
return [{ type: "session.status", properties: { sessionID, status: { type: "idle" } } }]
|
|
471
|
+
case "session.retry.scheduled":
|
|
472
|
+
return [{ type: "session.status", properties: { sessionID, status: { type: "retry" } } }]
|
|
473
|
+
case "session.step.started":
|
|
474
|
+
return [msg({ id: d.assistantMessageID, mode: d.agent, modelID: d.model?.id, time: { created: t } })]
|
|
475
|
+
case "session.step.ended":
|
|
476
|
+
return [msg({ id: d.assistantMessageID, cost: d.cost, tokens: d.tokens, time: { completed: t } })]
|
|
477
|
+
case "session.step.failed":
|
|
478
|
+
return [msg({ id: d.assistantMessageID, cost: d.cost, tokens: d.tokens, error: v1Error(d.error), time: { completed: t } })]
|
|
479
|
+
// The name rides only on `input.started`; `called` carries the input but no name.
|
|
480
|
+
case "session.tool.input.started":
|
|
481
|
+
tools.set(d.id, { name: d.name, start: undefined })
|
|
482
|
+
return []
|
|
483
|
+
case "session.tool.called": {
|
|
484
|
+
const name = tools.get(d.id)?.name ?? "tool"
|
|
485
|
+
tools.set(d.id, { name, start: t })
|
|
486
|
+
return [part({ type: "tool", callID: d.id, tool: name, messageID: d.assistantMessageID,
|
|
487
|
+
state: { status: "running", title: name, time: { start: t } } })]
|
|
488
|
+
}
|
|
489
|
+
case "session.tool.success":
|
|
490
|
+
case "session.tool.failed": {
|
|
491
|
+
const call = tools.get(d.id) ?? { name: "tool", start: undefined }
|
|
492
|
+
tools.delete(d.id)
|
|
493
|
+
const failed = ev.type === "session.tool.failed"
|
|
494
|
+
return [part({ type: "tool", callID: d.id, tool: call.name, messageID: d.assistantMessageID,
|
|
495
|
+
state: { status: failed ? "error" : "completed", title: call.name,
|
|
496
|
+
error: failed ? d.error?.message || d.error?.type : undefined, time: { start: call.start, end: t } } })]
|
|
497
|
+
}
|
|
498
|
+
case "session.reasoning.started":
|
|
499
|
+
case "session.reasoning.ended":
|
|
500
|
+
return [part({ type: "reasoning", id: `r:${d.assistantMessageID}:${d.ordinal}`, text: d.text ?? "" })]
|
|
501
|
+
case "session.text.ended":
|
|
502
|
+
return [part({ type: "text", id: `t:${d.assistantMessageID}:${d.ordinal}`, messageID: d.assistantMessageID, text: d.text })]
|
|
503
|
+
case "permission.asked":
|
|
504
|
+
return [{ type: "permission.updated", properties: { sessionID, id: d.id, title: d.message || d.action } }]
|
|
505
|
+
case "permission.replied":
|
|
506
|
+
return [{ type: "permission.replied", properties: { sessionID, permissionID: d.requestID } }]
|
|
507
|
+
default:
|
|
508
|
+
return []
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
async function setupV2(ctx) {
|
|
513
|
+
const hooks = await GeneseedActivity()
|
|
514
|
+
const tools = new Map()
|
|
515
|
+
// The user's prompt, as V1's chat.message hook received it.
|
|
516
|
+
await ctx.session.hook("prompt", async (event) => {
|
|
517
|
+
try {
|
|
518
|
+
await hooks["chat.message"]({ sessionID: event?.sessionID, messageID: event?.messageID },
|
|
519
|
+
{ parts: [{ type: "text", text: event?.prompt?.text }] })
|
|
520
|
+
} catch (err) { log(`prompt hook failed: ${err?.message ?? err}`) }
|
|
521
|
+
})
|
|
522
|
+
const controller = new AbortController()
|
|
523
|
+
void (async () => {
|
|
524
|
+
for await (const ev of ctx.event.subscribe({ signal: controller.signal })) {
|
|
525
|
+
for (const event of toV1Events(ev, tools)) {
|
|
526
|
+
try { await hooks.event({ event }) } catch (err) { log(`event ${event.type} failed: ${err?.message ?? err}`) }
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
})().catch((err) => log(`event stream ended: ${err?.message ?? err}`))
|
|
530
|
+
return () => controller.abort()
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
export default { id: "geneseed-activity", server: GeneseedActivity, setup: setupV2 }
|
|
@@ -989,4 +989,99 @@ export const GeneseedContext = async (ctx) => {
|
|
|
989
989
|
}
|
|
990
990
|
}
|
|
991
991
|
|
|
992
|
-
|
|
992
|
+
// ---- OpenCode 2 ----------------------------------------------------------------
|
|
993
|
+
// `export default { id, server, setup }` serves V1 (>= 1.3.4) and V2 from one file — why, and
|
|
994
|
+
// each V1 hook's V2 counterpart: adapters/opencode/HOW-OPENCODE-LOADS.md.
|
|
995
|
+
//
|
|
996
|
+
// V2 has no `messages.transform`: `session.hook("context")` is its stable per-request
|
|
997
|
+
// equivalent (the outgoing system + messages, mutable), so the invisible delivery needs
|
|
998
|
+
// no fallback detection there. Messages are `LLM.Message` class instances, not V1's
|
|
999
|
+
// `{info, parts}`. A V2 hook that throws is a defect that fails the whole request,
|
|
1000
|
+
// so every callback below swallows its own errors.
|
|
1001
|
+
|
|
1002
|
+
// Build a user message the request will accept: reuse the class of a message already in
|
|
1003
|
+
// it (LLM.Message validates on construction), else a plain object of the same shape.
|
|
1004
|
+
function v2UserMessage(messages, text) {
|
|
1005
|
+
const content = [{ type: "text", text }]
|
|
1006
|
+
const Ctor = messages.find((m) => m && m.constructor && m.constructor !== Object)?.constructor
|
|
1007
|
+
if (Ctor) { try { return new Ctor({ role: "user", content }) } catch {} }
|
|
1008
|
+
return { role: "user", content }
|
|
1009
|
+
}
|
|
1010
|
+
const v2HasMarker = (messages) => messages.some((m) =>
|
|
1011
|
+
(m?.content ?? []).some((p) => typeof p?.text === "string" && p.text.includes(MARKER)))
|
|
1012
|
+
const v2Model = (ref) => (ref?.providerID && ref?.id ? `${ref.providerID}/${ref.id}` : null)
|
|
1013
|
+
|
|
1014
|
+
async function setupV2(ctx) {
|
|
1015
|
+
const root = repoRoot({ worktree: ctx?.location?.project?.directory, directory: ctx?.location?.directory })
|
|
1016
|
+
const blockCache = { text: null, at: 0 }
|
|
1017
|
+
// Same scope rule as V1's skipSession: child/subagent sessions inherit the parent's
|
|
1018
|
+
// context, geneseed-* sessions stay clean. A failed lookup is not cached.
|
|
1019
|
+
const sessionSkip = new Map()
|
|
1020
|
+
async function skipSession(sid) {
|
|
1021
|
+
if (!sid) return false
|
|
1022
|
+
const hit = sessionSkip.get(sid)
|
|
1023
|
+
if (hit !== undefined) return hit
|
|
1024
|
+
try {
|
|
1025
|
+
const info = await ctx.session.get({ sessionID: sid })
|
|
1026
|
+
const skip = Boolean(info?.parentID) || String(info?.title ?? "").startsWith("geneseed-")
|
|
1027
|
+
sessionSkip.set(sid, skip)
|
|
1028
|
+
return skip
|
|
1029
|
+
} catch {
|
|
1030
|
+
return false
|
|
1031
|
+
}
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
if (!VISIBLE) {
|
|
1035
|
+
await ctx.session.hook("context", async (event) => {
|
|
1036
|
+
if (INJECT_OFF) return
|
|
1037
|
+
try {
|
|
1038
|
+
if (await sovereignBypass(root)) return
|
|
1039
|
+
if (!Array.isArray(event?.messages) || v2HasMarker(event.messages)) return
|
|
1040
|
+
if (await skipSession(event.sessionID)) return
|
|
1041
|
+
const cached = await cachedBlockText(root, blockCache)
|
|
1042
|
+
if (!cached) return
|
|
1043
|
+
event.messages.unshift(v2UserMessage(event.messages, withModel(cached, v2Model(event.model) || envModelStr())))
|
|
1044
|
+
log("context hook: injected context invisibly")
|
|
1045
|
+
} catch (err) {
|
|
1046
|
+
log(`context hook error: ${err?.message ?? err}`)
|
|
1047
|
+
}
|
|
1048
|
+
})
|
|
1049
|
+
return
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
// Visible delivery (GENESEED_CONTEXT_VISIBLE): one synthetic message when a session is
|
|
1053
|
+
// created, re-pushed into the compaction request so it outlives the summary.
|
|
1054
|
+
await ctx.session.hook("compaction", async (event) => {
|
|
1055
|
+
if (INJECT_OFF) return
|
|
1056
|
+
try {
|
|
1057
|
+
if (await sovereignBypass(root)) return
|
|
1058
|
+
if (!Array.isArray(event?.messages) || await skipSession(event.sessionID)) return
|
|
1059
|
+
const { block } = await resolveBlock(root)
|
|
1060
|
+
if (block) event.messages.unshift(v2UserMessage(event.messages, block.text))
|
|
1061
|
+
} catch (err) {
|
|
1062
|
+
log(`compaction error: ${err?.message ?? err}`)
|
|
1063
|
+
}
|
|
1064
|
+
})
|
|
1065
|
+
const controller = new AbortController()
|
|
1066
|
+
void (async () => {
|
|
1067
|
+
for await (const ev of ctx.event.subscribe({ signal: controller.signal })) {
|
|
1068
|
+
if (ev?.type !== "session.created" || INJECT_OFF) continue
|
|
1069
|
+
const d = ev.data ?? {}
|
|
1070
|
+
try {
|
|
1071
|
+
if (d.parentID || String(d.title ?? "").startsWith("geneseed-")) continue
|
|
1072
|
+
if (await sovereignBypass(root)) continue
|
|
1073
|
+
const { block } = await resolveBlock(root)
|
|
1074
|
+
if (!block) continue
|
|
1075
|
+
await ctx.session.synthetic({
|
|
1076
|
+
sessionID: d.sessionID, text: withModel(block.text, v2Model(d.model) || envModelStr()),
|
|
1077
|
+
description: "Geneseed project context", resume: false,
|
|
1078
|
+
})
|
|
1079
|
+
} catch (err) {
|
|
1080
|
+
log(`visible injection error: ${err?.message ?? err}`)
|
|
1081
|
+
}
|
|
1082
|
+
}
|
|
1083
|
+
})().catch((err) => log(`event stream ended: ${err?.message ?? err}`))
|
|
1084
|
+
return () => controller.abort()
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
export default { id: "geneseed-context", server: GeneseedContext, setup: setupV2 }
|
|
@@ -633,4 +633,36 @@ export const GeneseedGuard = async (ctx) => {
|
|
|
633
633
|
}
|
|
634
634
|
}
|
|
635
635
|
|
|
636
|
-
|
|
636
|
+
// ---- OpenCode 2 ----------------------------------------------------------------
|
|
637
|
+
// `export default { id, server, setup }` serves V1 (>= 1.3.4) and V2 from one file — why, and
|
|
638
|
+
// each V1 hook's V2 counterpart: adapters/opencode/HOW-OPENCODE-LOADS.md.
|
|
639
|
+
//
|
|
640
|
+
// V2 runs the same gates through `tool.hook("execute.before")`, but cannot block the
|
|
641
|
+
// V1 way: a V2 hook that throws is a defect, which fails the whole agent step instead
|
|
642
|
+
// of the one call (only a `Tool.Error` settles a call cleanly, and a hook cannot raise
|
|
643
|
+
// one). So a deny RENAMES the call: core then finds no tool by that name and settles it
|
|
644
|
+
// with a `Tool.Error` whose text carries the reason — the call never runs, the model sees
|
|
645
|
+
// why, the session continues. The input is emptied as well, for code mode, which runs the
|
|
646
|
+
// original tool whatever the event's name says; every tool this guard gates requires
|
|
647
|
+
// arguments, so `{}` fails validation there before anything executes.
|
|
648
|
+
//
|
|
649
|
+
// `permission.evaluate` (V2's deny hook) was not used: it sees only the action and a
|
|
650
|
+
// resource list (the shell tool's is per sub-command), not the full input the content
|
|
651
|
+
// scan and the patch-path gates need.
|
|
652
|
+
async function setupV2(ctx) {
|
|
653
|
+
const hooks = await GeneseedGuard({
|
|
654
|
+
worktree: ctx?.location?.project?.directory, directory: ctx?.location?.directory,
|
|
655
|
+
})
|
|
656
|
+
await ctx.tool.hook("execute.before", async (event) => {
|
|
657
|
+
try {
|
|
658
|
+
await hooks["tool.execute.before"]({ tool: event.tool }, { args: event.input })
|
|
659
|
+
} catch (err) {
|
|
660
|
+
const why = String(err?.message || "")
|
|
661
|
+
if (!why.startsWith("[geneseed-guard]")) return
|
|
662
|
+
event.tool = why
|
|
663
|
+
event.input = {}
|
|
664
|
+
}
|
|
665
|
+
})
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
export default { id: "geneseed-guard", server: GeneseedGuard, setup: setupV2 }
|
|
@@ -301,6 +301,23 @@ async function writeMemories(output, memDir, existing) {
|
|
|
301
301
|
return written
|
|
302
302
|
}
|
|
303
303
|
|
|
304
|
+
// The distil's reply into the store: a child session's one agent lesson, else memory
|
|
305
|
+
// files plus their MEMORY.md lines. Shared by the V1 and V2 runs.
|
|
306
|
+
async function storeDistil(output, { isChild, memDir, agentName, existing }) {
|
|
307
|
+
if (!output.trim() || output.trim().toUpperCase() === "NOTHING") return
|
|
308
|
+
if (isChild) {
|
|
309
|
+
const lesson = output.trim().split("\n")[0].replace(/^[-*]\s*/, "").trim()
|
|
310
|
+
if (lesson.length < 10 || lesson.length > 300) return // junk / truncation guard
|
|
311
|
+
const f = await appendAgentLesson(memDir, agentName, lesson)
|
|
312
|
+
console.error(`[geneseed-learn] agent lesson -> ${f}`)
|
|
313
|
+
return
|
|
314
|
+
}
|
|
315
|
+
const written = await writeMemories(output, memDir, existing)
|
|
316
|
+
if (written.length) {
|
|
317
|
+
console.error(`[geneseed-learn] wrote ${written.length} memory file(s): ${written.join(", ")}`)
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
304
321
|
export const GeneseedLearn = async (ctx) => {
|
|
305
322
|
const { client } = ctx ?? {}
|
|
306
323
|
const ours = new Set() // throwaway distil sessions — never mine our own output
|
|
@@ -376,18 +393,7 @@ export const GeneseedLearn = async (ctx) => {
|
|
|
376
393
|
if (newId) await client.session.delete({ path: { id: newId } }).catch(() => {})
|
|
377
394
|
}
|
|
378
395
|
|
|
379
|
-
|
|
380
|
-
if (isChild) {
|
|
381
|
-
const lesson = output.trim().split("\n")[0].replace(/^[-*]\s*/, "").trim()
|
|
382
|
-
if (lesson.length < 10 || lesson.length > 300) return // junk / truncation guard
|
|
383
|
-
const f = await appendAgentLesson(memDir, agentName, lesson)
|
|
384
|
-
console.error(`[geneseed-learn] agent lesson -> ${f}`)
|
|
385
|
-
return
|
|
386
|
-
}
|
|
387
|
-
const written = await writeMemories(output, memDir, existing)
|
|
388
|
-
if (written.length) {
|
|
389
|
-
console.error(`[geneseed-learn] wrote ${written.length} memory file(s): ${written.join(", ")}`)
|
|
390
|
-
}
|
|
396
|
+
await storeDistil(output, { isChild, memDir, agentName, existing })
|
|
391
397
|
} catch (err) {
|
|
392
398
|
// Never break the session over a memory write.
|
|
393
399
|
console.error(`[geneseed-learn] skipped: ${err?.message ?? err}`)
|
|
@@ -426,6 +432,113 @@ export const GeneseedLearn = async (ctx) => {
|
|
|
426
432
|
|
|
427
433
|
// OpenCode treats every export as a plugin and rejects non-functions; hang the test
|
|
428
434
|
// helpers off the factory instead — reachable via import, invisible to the loader.
|
|
429
|
-
Object.assign(GeneseedLearn, { writeMemories, resolveAgentName, appendAgentLesson })
|
|
435
|
+
Object.assign(GeneseedLearn, { writeMemories, resolveAgentName, appendAgentLesson, flattenV2 })
|
|
436
|
+
|
|
437
|
+
// ---- OpenCode 2 ----------------------------------------------------------------
|
|
438
|
+
// `export default { id, server, setup }` serves V1 (>= 1.3.4) and V2 from one file — why, and
|
|
439
|
+
// each V1 hook's V2 counterpart: adapters/opencode/HOW-OPENCODE-LOADS.md.
|
|
440
|
+
//
|
|
441
|
+
// What differs on V2:
|
|
442
|
+
// - The debounce re-arms on `session.execution.succeeded`: V2 core never publishes
|
|
443
|
+
// `session.idle` (deprecated in its schema), and the execution pair is its turn
|
|
444
|
+
// boundary. Events are `{ type, data }`, not `{ type, properties }`.
|
|
445
|
+
// - The transcript is `session.context()` — the messages after the last compaction —
|
|
446
|
+
// typed `user` (text) / `assistant` (content parts, `model: { providerID, id }`).
|
|
447
|
+
// - The distil is one stateless `generate.text({ prompt, model })` call with the
|
|
448
|
+
// session's own model: no throwaway session to create, guard against and delete.
|
|
449
|
+
function flattenV2(messages) {
|
|
450
|
+
const lines = []
|
|
451
|
+
let model = null
|
|
452
|
+
for (const m of messages || []) {
|
|
453
|
+
if (m?.type === "user") {
|
|
454
|
+
const text = String(m.text ?? "").trim()
|
|
455
|
+
if (text) lines.push(`user: ${text}`)
|
|
456
|
+
} else if (m?.type === "assistant") {
|
|
457
|
+
const text = (m.content || [])
|
|
458
|
+
.filter((p) => p?.type === "text" && typeof p.text === "string")
|
|
459
|
+
.map((p) => p.text).join("\n").trim()
|
|
460
|
+
if (text) lines.push(`assistant: ${text}`)
|
|
461
|
+
if (m.model?.providerID && m.model?.id) model = m.model
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
return { notes: lines.join("\n\n"), model }
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
async function setupV2(ctx) {
|
|
468
|
+
const root = ctx?.location?.project?.directory || ctx?.location?.directory || process.cwd()
|
|
469
|
+
const timers = new Map()
|
|
470
|
+
const inFlight = new Set()
|
|
471
|
+
let warnedNoDir = false
|
|
472
|
+
|
|
473
|
+
async function runDistill(sid) {
|
|
474
|
+
if (await sovereignBypass(root)) return
|
|
475
|
+
if (inFlight.has(sid)) return
|
|
476
|
+
inFlight.add(sid)
|
|
477
|
+
try {
|
|
478
|
+
const meta = await ctx.session.get({ sessionID: sid }).catch(() => null)
|
|
479
|
+
if (String(meta?.title ?? "").startsWith("geneseed-")) return
|
|
480
|
+
const isChild = !!meta?.parentID
|
|
481
|
+
const agentName = isChild ? resolveAgentName(meta) : null
|
|
482
|
+
if (isChild && !agentName) return
|
|
483
|
+
|
|
484
|
+
const memDir = await resolveMemoryDir()
|
|
485
|
+
if (!memDir) {
|
|
486
|
+
if (!warnedNoDir) {
|
|
487
|
+
warnedNoDir = true
|
|
488
|
+
console.error("[geneseed-learn] no memory dir — set $GENESEED_HARNESS or $GENESEED_MEMORY.")
|
|
489
|
+
}
|
|
490
|
+
return
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
let { notes, model } = flattenV2(await ctx.session.context({ sessionID: sid }))
|
|
494
|
+
if (notes.length < MIN_NOTES_CHARS) return
|
|
495
|
+
notes = notes.slice(-MAX_NOTES_CHARS)
|
|
496
|
+
if (!model) {
|
|
497
|
+
const env = envModel()
|
|
498
|
+
model = env ? { providerID: env.providerID, id: env.modelID } : null
|
|
499
|
+
}
|
|
500
|
+
if (!model) {
|
|
501
|
+
console.error("[geneseed-learn] could not determine a model — set $GENESEED_MODEL=provider/model.")
|
|
502
|
+
return
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
const existing = await existingSlugs(memDir)
|
|
506
|
+
const prompt = isChild
|
|
507
|
+
? [AGENT_LESSON_PROMPT, "", "NOTES:", notes].join("\n")
|
|
508
|
+
: buildPrompt(notes, existing)
|
|
509
|
+
const reply = await ctx.generate.text({
|
|
510
|
+
prompt, model: { providerID: model.providerID, id: model.id, ...(model.variant ? { variant: model.variant } : {}) },
|
|
511
|
+
})
|
|
512
|
+
await storeDistil(String(reply?.text ?? ""), { isChild, memDir, agentName, existing })
|
|
513
|
+
} catch (err) {
|
|
514
|
+
console.error(`[geneseed-learn] skipped: ${err?.message ?? err}`)
|
|
515
|
+
} finally {
|
|
516
|
+
inFlight.delete(sid)
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
const controller = new AbortController()
|
|
521
|
+
void (async () => {
|
|
522
|
+
for await (const ev of ctx.event.subscribe({ signal: controller.signal })) {
|
|
523
|
+
const sid = ev?.data?.sessionID
|
|
524
|
+
if (!sid) continue
|
|
525
|
+
if (ev.type === "session.deleted") {
|
|
526
|
+
const t = timers.get(sid)
|
|
527
|
+
if (t) clearTimeout(t)
|
|
528
|
+
timers.delete(sid)
|
|
529
|
+
continue
|
|
530
|
+
}
|
|
531
|
+
if (ev.type !== "session.execution.succeeded") continue
|
|
532
|
+
const prev = timers.get(sid)
|
|
533
|
+
if (prev) clearTimeout(prev)
|
|
534
|
+
timers.set(sid, setTimeout(() => { timers.delete(sid); runDistill(sid) }, DEBOUNCE_MS))
|
|
535
|
+
}
|
|
536
|
+
})().catch((err) => console.error(`[geneseed-learn] event stream ended: ${err?.message ?? err}`))
|
|
537
|
+
return () => {
|
|
538
|
+
controller.abort()
|
|
539
|
+
for (const t of timers.values()) clearTimeout(t)
|
|
540
|
+
timers.clear()
|
|
541
|
+
}
|
|
542
|
+
}
|
|
430
543
|
|
|
431
|
-
export default GeneseedLearn
|
|
544
|
+
export default { id: "geneseed-learn", server: GeneseedLearn, setup: setupV2 }
|
|
@@ -157,4 +157,49 @@ export const GeneseedNotify = async ({ client }) => {
|
|
|
157
157
|
// off the factory instead — reachable via `import`, invisible to the loader.
|
|
158
158
|
Object.assign(GeneseedNotify, { lastUserMs, shouldNotify, notifyCommand })
|
|
159
159
|
|
|
160
|
-
|
|
160
|
+
// ---- OpenCode 2 ----------------------------------------------------------------
|
|
161
|
+
// `export default { id, server, setup }` serves V1 (>= 1.3.4) and V2 from one file — why, and
|
|
162
|
+
// each V1 hook's V2 counterpart: adapters/opencode/HOW-OPENCODE-LOADS.md.
|
|
163
|
+
//
|
|
164
|
+
// V2 core never publishes `session.idle` (deprecated in its schema). The turn boundary is
|
|
165
|
+
// the execution pair instead: `session.execution.started` stamps when the agent began,
|
|
166
|
+
// `session.execution.succeeded`/`failed` ends it — so the turn length comes straight off
|
|
167
|
+
// the events, no transcript read. An interrupted execution is the user's own doing (or a
|
|
168
|
+
// shutdown) and is not announced. Events are `{ type, created, data }`, not V1's
|
|
169
|
+
// `{ type, properties }`.
|
|
170
|
+
async function setupV2(ctx) {
|
|
171
|
+
if (OFF) return
|
|
172
|
+
const started = new Map()
|
|
173
|
+
const controller = new AbortController()
|
|
174
|
+
void (async () => {
|
|
175
|
+
for await (const ev of ctx.event.subscribe({ signal: controller.signal })) {
|
|
176
|
+
const sid = ev?.data?.sessionID
|
|
177
|
+
if (!sid) continue
|
|
178
|
+
if (ev.type === "session.execution.started") { started.set(sid, ev.created); continue }
|
|
179
|
+
if (ev.type === "session.execution.interrupted" || ev.type === "session.deleted") { started.delete(sid); continue }
|
|
180
|
+
if (ev.type !== "session.execution.succeeded" && ev.type !== "session.execution.failed") continue
|
|
181
|
+
const begin = started.get(sid) ?? null
|
|
182
|
+
started.delete(sid)
|
|
183
|
+
try {
|
|
184
|
+
let title = "", parentID
|
|
185
|
+
try {
|
|
186
|
+
const info = await ctx.session.get({ sessionID: sid })
|
|
187
|
+
title = info?.title ?? ""
|
|
188
|
+
parentID = info?.parentID
|
|
189
|
+
} catch {}
|
|
190
|
+
const now = typeof ev.created === "number" ? ev.created : Date.now()
|
|
191
|
+
if (!shouldNotify({ now, lastUserMs: begin, parentID, title, minMs: MIN_MS })) {
|
|
192
|
+
log(`skipped ${sid} (child/throwaway or turn under ${MIN_MS}ms)`)
|
|
193
|
+
continue
|
|
194
|
+
}
|
|
195
|
+
deliver(TITLE, title ? `Done: ${title}` : "Agent finished — your turn.")
|
|
196
|
+
log(`notified for ${sid}`)
|
|
197
|
+
} catch (err) {
|
|
198
|
+
log(`skipped: ${err?.message ?? err}`)
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
})().catch((err) => log(`event stream ended: ${err?.message ?? err}`))
|
|
202
|
+
return () => controller.abort()
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export default { id: "geneseed-notify", server: GeneseedNotify, setup: setupV2 }
|
|
@@ -143,6 +143,48 @@ export const GeneseedPonytail = async () => ({
|
|
|
143
143
|
// any that isn't a function — a bare helper export crashes startup ("null is not an
|
|
144
144
|
// object") or logs "not a function". Hang the test helpers off the factory instead;
|
|
145
145
|
// they ride along for `import`, invisible to the loader. Upgrade path: none needed.
|
|
146
|
-
Object.assign(GeneseedPonytail, { normalizeMode, defaultMode, ponytailInstructions })
|
|
146
|
+
Object.assign(GeneseedPonytail, { normalizeMode, defaultMode, ponytailInstructions, levelFromPrompt })
|
|
147
147
|
|
|
148
|
-
|
|
148
|
+
// --- OpenCode 2 --------------------------------------------------------------
|
|
149
|
+
// `export default { id, server, setup }` serves V1 (>= 1.3.4) and V2 from one file — why, and
|
|
150
|
+
// each V1 hook's V2 counterpart: adapters/opencode/HOW-OPENCODE-LOADS.md.
|
|
151
|
+
//
|
|
152
|
+
// V2 has neither hook. `session.hook("context")` carries the outgoing system parts (the
|
|
153
|
+
// ruleset is pushed there), and there is no `command.execute.before`: a markdown command
|
|
154
|
+
// expands its template into the prompt, so the switch is read off the prompt instead —
|
|
155
|
+
// the first line of PONYTAIL_COMMAND_BODY (js/hosts/opencode.mjs), `Ponytail level
|
|
156
|
+
// requested: <args>`. Change that line and this pattern together.
|
|
157
|
+
const PONYTAIL_PROMPT_RE = /^Ponytail level requested:[ \t]*(.*)$/m
|
|
158
|
+
|
|
159
|
+
// The level a `/ponytail` prompt asks for: undefined when the text is not one, null
|
|
160
|
+
// when its argument is no known level, else the level (empty argument = `full`).
|
|
161
|
+
function levelFromPrompt(text) {
|
|
162
|
+
const m = PONYTAIL_PROMPT_RE.exec(String(text ?? ""))
|
|
163
|
+
if (!m || m.index !== 0) return undefined
|
|
164
|
+
const arg = m[1].trim()
|
|
165
|
+
return normalizeMode(arg) || (arg ? null : "full")
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
async function setupV2(ctx) {
|
|
169
|
+
await ctx.session.hook("prompt", (event) => {
|
|
170
|
+
try {
|
|
171
|
+
const mode = levelFromPrompt(event?.prompt?.text)
|
|
172
|
+
if (mode === undefined) return
|
|
173
|
+
if (!mode) { log("ignored unknown level"); return }
|
|
174
|
+
writeMode(mode)
|
|
175
|
+
log(`ponytail ${mode}`)
|
|
176
|
+
} catch (err) {
|
|
177
|
+
log(`prompt hook error: ${err?.message ?? err}`)
|
|
178
|
+
}
|
|
179
|
+
})
|
|
180
|
+
await ctx.session.hook("context", (event) => {
|
|
181
|
+
try {
|
|
182
|
+
const text = ponytailInstructions(readMode())
|
|
183
|
+
if (text && Array.isArray(event?.system)) event.system.push({ type: "text", text })
|
|
184
|
+
} catch (err) {
|
|
185
|
+
log(`context hook error: ${err?.message ?? err}`)
|
|
186
|
+
}
|
|
187
|
+
})
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export default { id: "geneseed-ponytail", server: GeneseedPonytail, setup: setupV2 }
|