@pellux/goodvibes-agent 1.5.9 → 1.6.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 +7 -0
- package/README.md +1 -1
- package/dist/package/main.js +11310 -4960
- package/docs/README.md +1 -1
- package/docs/voice-and-live-tts.md +47 -0
- package/package.json +2 -2
- package/src/agent/memory-prompt.ts +12 -1
- package/src/agent/prompt-context-receipts.ts +71 -6
- package/src/agent/vibe-file.ts +9 -5
- package/src/audio/spoken-turn-wiring.ts +3 -2
- package/src/cli/memory-command-wire.ts +373 -0
- package/src/cli/memory-command.ts +31 -22
- package/src/runtime/bootstrap-core.ts +2 -2
- package/src/runtime/bootstrap.ts +68 -0
- package/src/runtime/memory-spine-adoption.ts +50 -0
- package/src/runtime/memory-spine-rest-transport.ts +327 -0
- package/src/runtime/services.ts +33 -0
- package/src/tools/agent-local-registry-memory.ts +42 -11
- package/src/tools/agent-local-registry-tool.ts +5 -4
- package/src/version.ts +1 -1
- package/src/audio/spoken-turn-controller.ts +0 -455
- package/src/audio/text-chunker.ts +0 -110
package/docs/README.md
CHANGED
|
@@ -53,3 +53,50 @@ Leaving `tts.voice` empty lets the provider choose its default voice.
|
|
|
53
53
|
Spoken responses are conversation output. They are not automatically written to Agent Knowledge, local memory, default knowledge, or any other product segment.
|
|
54
54
|
|
|
55
55
|
If a spoken result should become durable, store it through an explicit Agent memory command or an Agent Knowledge ingestion path.
|
|
56
|
+
|
|
57
|
+
## Platform Voice-Config Cohesion
|
|
58
|
+
|
|
59
|
+
The `tts.*` config keys (`tts.provider`, `tts.voice`, `tts.speed`, `tts.llmProvider`,
|
|
60
|
+
`tts.llmModel`) are defined once, in the shared GoodVibes SDK config schema, and read
|
|
61
|
+
identically by every surface — Agent, TUI, and the daemon. Agent does not define its
|
|
62
|
+
own voice-config schema and does not read tts.* through any path other than the
|
|
63
|
+
standard `ConfigManager.get`/`set` API that every other Agent setting uses. Changing
|
|
64
|
+
`tts.provider` through `/config tts.provider` or `settings action:"set"` changes the
|
|
65
|
+
exact same key a TUI user would change through its own `/config` surface — the
|
|
66
|
+
key name, type, and default are one contract, not two independently-maintained ones.
|
|
67
|
+
`src/test/audio/voice-config-cohesion.test.ts` is the regression guard: it fails if
|
|
68
|
+
the Agent ever reads a tts.* key that isn't in the shared schema, or if a tts.*
|
|
69
|
+
reader stops importing `ConfigManager` from the shared SDK package.
|
|
70
|
+
|
|
71
|
+
What "shared" does **not** mean here: each surface still persists its *values* to its
|
|
72
|
+
own settings file (Agent's under its own surface root, TUI's under its own) — that is
|
|
73
|
+
the existing, general-purpose per-surface config storage model, not something voice-
|
|
74
|
+
specific, and changing it is out of scope for this ruling. "Shared" means the
|
|
75
|
+
schema/contract (the key names, types, and defaults) is one definition used by every
|
|
76
|
+
surface, so the same key always means the same thing and takes the same kind of
|
|
77
|
+
value everywhere — a user (or an operator script) setting `tts.voice` on one surface
|
|
78
|
+
is setting the same conceptual value the other surfaces would read under that name,
|
|
79
|
+
even though each surface keeps its own copy of the setting today.
|
|
80
|
+
|
|
81
|
+
Two related rulings, made for this parity pass:
|
|
82
|
+
|
|
83
|
+
- **Local synthesis stays local.** Agent synthesizes speech directly against the
|
|
84
|
+
configured provider (through the shared `VoiceService`/`VoiceProviderRegistry`) via
|
|
85
|
+
a local `mpv`/`ffplay` subprocess, rather than routing playback through the daemon's
|
|
86
|
+
`voice.tts`/`voice.tts.stream` HTTP routes. This is deliberate, not an oversight:
|
|
87
|
+
Agent is a terminal tool that must keep working when there is no daemon running
|
|
88
|
+
(offline use), so voice output cannot depend on a daemon round-trip. The daemon's
|
|
89
|
+
voice routes exist for network consumers (the web UI); Agent's local-first design
|
|
90
|
+
is the reason it doesn't use them, and the config it reads to pick a provider is
|
|
91
|
+
still the one shared contract described above.
|
|
92
|
+
- **Mic/STT input is an honest no-op in Agent, not a gap.** Agent has no
|
|
93
|
+
`getUserMedia`-equivalent microphone capture and does not call `voice.stt` or
|
|
94
|
+
`voice.realtime.session`. This is by design: Agent is a terminal application with
|
|
95
|
+
keyboard/text as its primary input surface, and the web UI is the platform's
|
|
96
|
+
intended owner of mic-based voice input (browser `getUserMedia`/`MediaRecorder`).
|
|
97
|
+
Agent already reports this honestly rather than implying a capability that doesn't
|
|
98
|
+
exist — see the wake-word/always-listening `not-published` posture described above,
|
|
99
|
+
which applies to mic input broadly, not only wake-word. If a future certified,
|
|
100
|
+
permission-scoped mic contract is published for terminal surfaces, this ruling is
|
|
101
|
+
the one to revisit; until then, building a partial mic flow in Agent would be
|
|
102
|
+
worse than not building one.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pellux/goodvibes-agent",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.6.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "GoodVibes personal operator assistant TUI with a proactive Agent product brain, isolated Agent Knowledge, local profiles, routines, skills, personas, and explicit build delegation.",
|
|
6
6
|
"type": "module",
|
|
@@ -92,7 +92,7 @@
|
|
|
92
92
|
},
|
|
93
93
|
"dependencies": {},
|
|
94
94
|
"devDependencies": {
|
|
95
|
-
"@pellux/goodvibes-sdk": "1.
|
|
95
|
+
"@pellux/goodvibes-sdk": "1.3.1",
|
|
96
96
|
"sql.js": "^1.14.1",
|
|
97
97
|
"sqlite-vec": "^0.1.9",
|
|
98
98
|
"zustand": "^5.0.12",
|
|
@@ -149,11 +149,22 @@ export interface BuildReviewedMemoryPromptOptions {
|
|
|
149
149
|
/** The current turn's raw text (the seam this comes from: TURN_SUBMITTED's `prompt`).
|
|
150
150
|
* Used only to RANK the already-eligible set — never to admit an otherwise-ineligible record. */
|
|
151
151
|
readonly turnText?: string | null;
|
|
152
|
+
/**
|
|
153
|
+
* Pre-fetched record set to use instead of `memoryRegistry.getAll()` — e.g. a
|
|
154
|
+
* memory-spine recall snapshot's records (SDK 1.2.0 sync-recall seam; see
|
|
155
|
+
* prompt-context-receipts.ts's `resolveMemoryRecords`). `memoryRegistry` is still
|
|
156
|
+
* used for the per-turn semantic ranking query (`rankMemoryForTurn`'s
|
|
157
|
+
* `vectorStats`/`searchSemantic` calls stay local-direct regardless of where the
|
|
158
|
+
* record set came from) — only the raw eligible set that ranking runs OVER is
|
|
159
|
+
* swappable, so the injected prompt text and any receipt describing it are always
|
|
160
|
+
* derived from the exact same record set instead of two independent reads.
|
|
161
|
+
*/
|
|
162
|
+
readonly records?: readonly MemoryRecord[];
|
|
152
163
|
}
|
|
153
164
|
|
|
154
165
|
export function buildReviewedMemoryPrompt(memoryRegistry: MemoryRegistry, options: BuildReviewedMemoryPromptOptions = {}): string | null {
|
|
155
166
|
const limit = options.limit ?? DEFAULT_LIMIT;
|
|
156
|
-
const eligible = memoryRegistry.getAll().filter(isPromptActiveMemory);
|
|
167
|
+
const eligible = (options.records ?? memoryRegistry.getAll()).filter(isPromptActiveMemory);
|
|
157
168
|
const ranking = rankMemoryForTurn(memoryRegistry, eligible, options.turnText);
|
|
158
169
|
const records = ranking.records.slice(0, Math.max(0, limit));
|
|
159
170
|
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { appendFileSync, existsSync, mkdirSync, readFileSync } from 'node:fs';
|
|
2
2
|
import { createHash } from 'node:crypto';
|
|
3
3
|
import { dirname } from 'node:path';
|
|
4
|
-
import type { MemoryRegistry } from '@pellux/goodvibes-sdk/platform/state';
|
|
4
|
+
import type { MemoryRecord, MemoryRegistry } from '@pellux/goodvibes-sdk/platform/state';
|
|
5
|
+
import type { MemoryRecallSnapshot } from '@pellux/goodvibes-sdk/platform/runtime/memory-spine';
|
|
5
6
|
import { getTierForContextWindow, getTierPromptSupplement } from '@pellux/goodvibes-sdk/platform/providers';
|
|
6
7
|
import type { ShellPathService } from '@/runtime/index.ts';
|
|
7
8
|
import { buildReviewedMemoryPrompt, describeMemoryPromptEligibility, isPromptActiveMemory, rankMemoryForTurn, relevanceBand } from './memory-prompt.ts';
|
|
@@ -77,6 +78,26 @@ export interface RuntimePromptCompositionInput {
|
|
|
77
78
|
* already-eligible memory set by relevance to this turn — see rankMemoryForTurn.
|
|
78
79
|
* Null/undefined when there is no active turn (e.g. a follow-up composition). */
|
|
79
80
|
readonly turnText?: string | null;
|
|
81
|
+
/**
|
|
82
|
+
* The memory spine's cached recall snapshot (SDK 1.2.0 sync-recall seam), when the
|
|
83
|
+
* caller has one to offer.
|
|
84
|
+
*
|
|
85
|
+
* SYNC-RECALL SEAM (memory-spine full-detach adoption, SDK 1.2.0): this function is
|
|
86
|
+
* called synchronously from the SDK's `Orchestrator.getSystemPrompt` callback, which
|
|
87
|
+
* the SDK defines as non-async, while a wire client's memory reads are async. Rather
|
|
88
|
+
* than reading `memoryRegistry.getAll()` directly (a frozen local snapshot that would
|
|
89
|
+
* silently miss whatever the daemon wrote after this process last opened its own
|
|
90
|
+
* copy — the exact failure the 1.1.0-era `memorySpineMode` degrade note used to flag),
|
|
91
|
+
* the caller drives an ASYNC pre-turn hook (`MemorySpineClient.refreshRecallSnapshot`)
|
|
92
|
+
* and passes the resulting freshness-stamped `MemoryRecallSnapshot` in here. When
|
|
93
|
+
* present, its `records` (captured with `{ recall: false }` — an unfiltered browse
|
|
94
|
+
* set, matching the old `getAll()` semantics exactly) replace the direct registry
|
|
95
|
+
* read, and its own honest `stale`/`mode`/`note` fields drive the 'memory' receipt
|
|
96
|
+
* segment's note instead of the old blanket "client mode is always degraded" note.
|
|
97
|
+
* `undefined` falls back to reading `memoryRegistry` directly (e.g. a caller that
|
|
98
|
+
* has not wired the spine, or an existing test) — the pre-1.2.0 behavior, unchanged.
|
|
99
|
+
*/
|
|
100
|
+
readonly memoryRecallSnapshot?: MemoryRecallSnapshot;
|
|
80
101
|
}
|
|
81
102
|
|
|
82
103
|
const RECEIPT_STORE_LIMIT = 200;
|
|
@@ -104,6 +125,29 @@ function joinPromptParts(...parts: Array<string | null | undefined>): string {
|
|
|
104
125
|
return parts.map((part) => part?.trim()).filter((part): part is string => Boolean(part)).join('\n\n');
|
|
105
126
|
}
|
|
106
127
|
|
|
128
|
+
/**
|
|
129
|
+
* The honest freshness note for the 'memory' receipt segment, sourced from the
|
|
130
|
+
* memory spine's own recall snapshot (see RuntimePromptCompositionInput.
|
|
131
|
+
* memoryRecallSnapshot). Surfaced only when there is something worth flagging —
|
|
132
|
+
* the snapshot came from a wire-adopted daemon (worth knowing regardless of
|
|
133
|
+
* freshness, since the wire's own copy is what was captured, not this process's),
|
|
134
|
+
* or the snapshot is stale (including "never yet captured", which the snapshot
|
|
135
|
+
* itself always reports as stale). `undefined` for the boring common case (local
|
|
136
|
+
* mode, freshly captured) — matching the pre-1.2.0 note's "nothing to degrade"
|
|
137
|
+
* behavior — or when the caller passed no snapshot at all.
|
|
138
|
+
*/
|
|
139
|
+
function memoryRecallSnapshotNote(snapshot: MemoryRecallSnapshot | undefined): string | undefined {
|
|
140
|
+
if (!snapshot) return undefined;
|
|
141
|
+
if (snapshot.mode !== 'client' && !snapshot.stale) return undefined;
|
|
142
|
+
return snapshot.note;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Combines up to two optional short notes with a single separator; undefined when both are absent. */
|
|
146
|
+
function combineNotes(first: string | null | undefined, second: string | null | undefined): string | undefined {
|
|
147
|
+
const parts = [first, second].map((part) => part?.trim()).filter((part): part is string => Boolean(part));
|
|
148
|
+
return parts.length > 0 ? parts.join(' — also: ') : undefined;
|
|
149
|
+
}
|
|
150
|
+
|
|
107
151
|
function modelLabel(model: unknown): string {
|
|
108
152
|
if (typeof model === 'string' && model.trim()) return model.trim();
|
|
109
153
|
if (typeof model === 'object' && model !== null) {
|
|
@@ -138,6 +182,18 @@ function receiptSegment(input: Omit<PromptContextReceiptSegment, 'approxTokens'>
|
|
|
138
182
|
};
|
|
139
183
|
}
|
|
140
184
|
|
|
185
|
+
/**
|
|
186
|
+
* Read via the memory spine's cached recall snapshot when the caller has one (SDK
|
|
187
|
+
* 1.2.0 sync-recall seam — see RuntimePromptCompositionInput.memoryRecallSnapshot);
|
|
188
|
+
* fall back to the direct registry read otherwise (unchanged pre-1.2.0 behavior).
|
|
189
|
+
* Shared by the prompt text build and the receipt segment build so both derive
|
|
190
|
+
* from the exact same record set — the receipt must describe what was actually
|
|
191
|
+
* injected, never a different snapshot than the prompt text itself used.
|
|
192
|
+
*/
|
|
193
|
+
function resolveMemoryRecords(input: RuntimePromptCompositionInput): readonly MemoryRecord[] {
|
|
194
|
+
return input.memoryRecallSnapshot ? input.memoryRecallSnapshot.records : input.memoryRegistry.getAll();
|
|
195
|
+
}
|
|
196
|
+
|
|
141
197
|
function buildRuntimePromptReceiptSegments(input: RuntimePromptCompositionInput): readonly PromptContextReceiptSegment[] {
|
|
142
198
|
const vibe = discoverVibeFiles(input.shellPaths);
|
|
143
199
|
// The VIBE prompt is a PROJECTION of persona/constraint records, not a
|
|
@@ -145,13 +201,13 @@ function buildRuntimePromptReceiptSegments(input: RuntimePromptCompositionInput)
|
|
|
145
201
|
const vibePrompt = buildVibeProjectionPrompt(input.memoryRegistry) ?? '';
|
|
146
202
|
const projectContext = discoverProjectContextFiles(input.shellPaths);
|
|
147
203
|
const projectContextPrompt = buildProjectContextPrompt(input.shellPaths) ?? '';
|
|
148
|
-
const memoryRecords = input
|
|
204
|
+
const memoryRecords = resolveMemoryRecords(input);
|
|
149
205
|
const eligibleMemory = memoryRecords.filter(isPromptActiveMemory);
|
|
150
206
|
const memoryRanking = rankMemoryForTurn(input.memoryRegistry, eligibleMemory, input.turnText);
|
|
151
207
|
const activeMemory = memoryRanking.records.slice(0, 10);
|
|
152
208
|
const activeMemoryIds = new Set(activeMemory.map((record) => record.id));
|
|
153
209
|
const suppressedMemory = memoryRecords.filter((record) => !activeMemoryIds.has(record.id));
|
|
154
|
-
const memoryPrompt = buildReviewedMemoryPrompt(input.memoryRegistry, { turnText: input.turnText }) ?? '';
|
|
210
|
+
const memoryPrompt = buildReviewedMemoryPrompt(input.memoryRegistry, { turnText: input.turnText, records: memoryRecords }) ?? '';
|
|
155
211
|
const routineSnapshot = AgentRoutineRegistry.fromShellPaths(input.shellPaths).snapshot();
|
|
156
212
|
const activeRoutines = routineSnapshot.enabledRoutines.filter((routine) => routine.reviewState === 'reviewed' && evaluateAgentRoutineReadiness(routine).ready);
|
|
157
213
|
const suppressedRoutines = routineSnapshot.enabledRoutines.filter((routine) => !activeRoutines.some((active) => active.id === routine.id));
|
|
@@ -237,8 +293,17 @@ function buildRuntimePromptReceiptSegments(input: RuntimePromptCompositionInput)
|
|
|
237
293
|
// Honest degrade note: when per-turn relevance scoring did not
|
|
238
294
|
// run — no active-turn text, semantic index unavailable, or no vector match —
|
|
239
295
|
// say so instead of silently presenting the fallback confidence/recency order as
|
|
240
|
-
// if it were a relevance ranking.
|
|
241
|
-
note
|
|
296
|
+
// if it were a relevance ranking. Combined with the memory spine's own
|
|
297
|
+
// freshness note (see memoryRecallSnapshotNote) when the recall snapshot is
|
|
298
|
+
// wire-sourced or stale — the ranking itself still runs against
|
|
299
|
+
// `input.memoryRegistry` directly (rankMemoryForTurn's per-turn semantic query
|
|
300
|
+
// needs a live vectorStats/searchSemantic call, which stays local-direct by the
|
|
301
|
+
// same vector-diagnostics ruling used elsewhere in the 1.2.0 adoption), so this
|
|
302
|
+
// note is about the RECORD SET's freshness, not the ranking.
|
|
303
|
+
note: combineNotes(
|
|
304
|
+
memoryRanking.scored ? undefined : memoryRanking.degradedReason,
|
|
305
|
+
memoryRecallSnapshotNote(input.memoryRecallSnapshot),
|
|
306
|
+
),
|
|
242
307
|
selected: activeMemory.map((record) => ({
|
|
243
308
|
id: record.id,
|
|
244
309
|
scope: record.scope,
|
|
@@ -346,7 +411,7 @@ export function composeRuntimePromptWithReceipt(input: RuntimePromptCompositionI
|
|
|
346
411
|
buildVibeProjectionPrompt(input.memoryRegistry),
|
|
347
412
|
buildProjectContextPrompt(input.shellPaths),
|
|
348
413
|
input.operatorPolicy,
|
|
349
|
-
buildReviewedMemoryPrompt(input.memoryRegistry, { turnText: input.turnText }),
|
|
414
|
+
buildReviewedMemoryPrompt(input.memoryRegistry, { turnText: input.turnText, records: resolveMemoryRecords(input) }),
|
|
350
415
|
buildEnabledRoutinesPrompt(input.shellPaths),
|
|
351
416
|
buildEnabledSkillsPrompt(input.shellPaths),
|
|
352
417
|
buildActivePersonaPrompt(input.shellPaths),
|
package/src/agent/vibe-file.ts
CHANGED
|
@@ -10,7 +10,11 @@ import { parseMarkdownFrontmatter, stripMarkdownFrontmatter } from './markdown-f
|
|
|
10
10
|
// the same '## VIBE.md' block from those records (caveat preserved); the file is
|
|
11
11
|
// demoted to an import/export FORMAT folded in via vibeBodyToConstraintOptions.
|
|
12
12
|
import { renderVibeProjection, vibeBodyToConstraintOptions } from '@pellux/goodvibes-sdk/platform/state';
|
|
13
|
-
import type { MemoryRecord,
|
|
13
|
+
import type { MemoryRecord, MemoryScope } from '@pellux/goodvibes-sdk/platform/state';
|
|
14
|
+
// Writes go through the memory-spine's MemoryAccess surface (add), not the raw
|
|
15
|
+
// MemoryRegistry, so this import folds correctly whether the agent is local or has
|
|
16
|
+
// adopted a daemon — see services.ts's memorySpineClient.
|
|
17
|
+
import type { MemoryAccess } from '@pellux/goodvibes-sdk/platform/runtime/memory-spine';
|
|
14
18
|
|
|
15
19
|
export type AgentVibeScope = 'project' | 'global';
|
|
16
20
|
|
|
@@ -235,7 +239,7 @@ export function buildVibeProjectionPrompt(memoryRecords: { getAll(): readonly Me
|
|
|
235
239
|
* this as a one-time migration, not every boot (mirrors the memory fold precedent).
|
|
236
240
|
*/
|
|
237
241
|
export async function importVibeFilesIntoMemory(
|
|
238
|
-
|
|
242
|
+
memorySpine: MemoryAccess,
|
|
239
243
|
shellPaths: AgentVibePaths,
|
|
240
244
|
): Promise<number> {
|
|
241
245
|
const snapshot = discoverVibeFiles(shellPaths);
|
|
@@ -249,7 +253,7 @@ export async function importVibeFilesIntoMemory(
|
|
|
249
253
|
sourceRef: file.path,
|
|
250
254
|
});
|
|
251
255
|
for (const opts of options) {
|
|
252
|
-
await
|
|
256
|
+
await memorySpine.add(opts);
|
|
253
257
|
created += 1;
|
|
254
258
|
}
|
|
255
259
|
}
|
|
@@ -306,7 +310,7 @@ function hashVibeBody(body: string): string {
|
|
|
306
310
|
* Returns the number of records created this run (0 when everything is already migrated).
|
|
307
311
|
*/
|
|
308
312
|
export async function importVibeFilesIntoMemoryOnce(
|
|
309
|
-
|
|
313
|
+
memorySpine: MemoryAccess,
|
|
310
314
|
shellPaths: AgentVibePaths,
|
|
311
315
|
): Promise<number> {
|
|
312
316
|
const markerPath = vibeImportMarkerPath(shellPaths);
|
|
@@ -326,7 +330,7 @@ export async function importVibeFilesIntoMemoryOnce(
|
|
|
326
330
|
sourceRef: file.path,
|
|
327
331
|
});
|
|
328
332
|
for (const opts of options) {
|
|
329
|
-
await
|
|
333
|
+
await memorySpine.add(opts);
|
|
330
334
|
created += 1;
|
|
331
335
|
}
|
|
332
336
|
marker.migrated[key] = hash;
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import type { ConfigManager } from '@pellux/goodvibes-sdk/platform/config';
|
|
2
2
|
import type { UiRuntimeEvents } from '@/runtime/index.ts';
|
|
3
3
|
import type { VoiceService } from '@pellux/goodvibes-sdk/platform/voice';
|
|
4
|
+
import { SpokenTurnController } from '@pellux/goodvibes-sdk/platform/voice';
|
|
4
5
|
import type { StreamingAudioPlayer } from './player.ts';
|
|
5
6
|
import { LocalStreamingAudioPlayer } from './player.ts';
|
|
6
|
-
import { SpokenTurnController } from './spoken-turn-controller.ts';
|
|
7
7
|
|
|
8
8
|
export interface SpokenTurnRuntime {
|
|
9
9
|
readonly unsubs: readonly (() => void)[];
|
|
@@ -31,8 +31,9 @@ export function wireSpokenTurnRuntime(options: WireSpokenTurnRuntimeOptions): Sp
|
|
|
31
31
|
const controller = new SpokenTurnController({
|
|
32
32
|
voiceService: options.voiceService,
|
|
33
33
|
configManager: options.configManager,
|
|
34
|
-
player,
|
|
34
|
+
sink: player,
|
|
35
35
|
notify: options.notify,
|
|
36
|
+
source: 'goodvibes-agent',
|
|
36
37
|
});
|
|
37
38
|
|
|
38
39
|
const turns = options.events.turns;
|