dsh-connect 0.8.0 → 0.9.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/README.i18n.yaml +2 -2
- package/README.md +134 -17
- package/README.zh.md +110 -17
- package/client/client.js +510 -80
- package/client/client.js.map +4 -4
- package/client/locale.mjs +227 -0
- package/client/panel-state.mjs +54 -0
- package/client/settings-client.mjs +272 -60
- package/docs/images/settings-advanced-en.png +0 -0
- package/docs/images/settings-advanced-zh.png +0 -0
- package/docs/images/settings-defaults-en.png +0 -0
- package/docs/images/settings-defaults-zh.png +0 -0
- package/docs/images/settings-overview-en.png +0 -0
- package/docs/images/settings-overview-zh.png +0 -0
- package/lib/binding.d.ts.map +1 -1
- package/lib/binding.js +2 -2
- package/lib/binding.js.map +1 -1
- package/lib/channels/dingtalk/message.d.ts.map +1 -1
- package/lib/channels/dingtalk/message.js +7 -0
- package/lib/channels/dingtalk/message.js.map +1 -1
- package/lib/channels/feishu/adapter.d.ts +6 -12
- package/lib/channels/feishu/adapter.d.ts.map +1 -1
- package/lib/channels/feishu/adapter.js +2 -17
- package/lib/channels/feishu/adapter.js.map +1 -1
- package/lib/channels/feishu/i18n.d.ts +2 -0
- package/lib/channels/feishu/i18n.d.ts.map +1 -1
- package/lib/channels/feishu/i18n.js +2 -0
- package/lib/channels/feishu/i18n.js.map +1 -1
- package/lib/channels/feishu/index.d.ts +23 -2
- package/lib/channels/feishu/index.d.ts.map +1 -1
- package/lib/channels/feishu/index.js +32 -6
- package/lib/channels/feishu/index.js.map +1 -1
- package/lib/channels/telegram/adapter.d.ts.map +1 -1
- package/lib/channels/telegram/adapter.js +7 -1
- package/lib/channels/telegram/adapter.js.map +1 -1
- package/lib/chat-key.d.ts +29 -0
- package/lib/chat-key.d.ts.map +1 -0
- package/lib/chat-key.js +38 -0
- package/lib/chat-key.js.map +1 -0
- package/lib/index.d.ts +15 -5
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js +201 -31
- package/lib/index.js.map +1 -1
- package/lib/runner.d.ts +54 -0
- package/lib/runner.d.ts.map +1 -1
- package/lib/runner.js +158 -23
- package/lib/runner.js.map +1 -1
- package/lib/scheduler.d.ts.map +1 -1
- package/lib/scheduler.js +2 -2
- package/lib/scheduler.js.map +1 -1
- package/lib/service.d.ts +16 -0
- package/lib/service.d.ts.map +1 -1
- package/lib/service.js +46 -2
- package/lib/service.js.map +1 -1
- package/lib/settings/channel-runtime.d.ts +75 -0
- package/lib/settings/channel-runtime.d.ts.map +1 -0
- package/lib/settings/channel-runtime.js +165 -0
- package/lib/settings/channel-runtime.js.map +1 -0
- package/lib/settings/channels.d.ts +11 -0
- package/lib/settings/channels.d.ts.map +1 -1
- package/lib/settings/channels.js +31 -0
- package/lib/settings/channels.js.map +1 -1
- package/lib/settings/credential-store.d.ts +52 -6
- package/lib/settings/credential-store.d.ts.map +1 -1
- package/lib/settings/credential-store.js +92 -18
- package/lib/settings/credential-store.js.map +1 -1
- package/lib/settings/namespace.d.ts +175 -0
- package/lib/settings/namespace.d.ts.map +1 -0
- package/lib/settings/namespace.js +200 -0
- package/lib/settings/namespace.js.map +1 -0
- package/lib/settings/secret-disclosure.d.ts +50 -0
- package/lib/settings/secret-disclosure.d.ts.map +1 -0
- package/lib/settings/secret-disclosure.js +132 -0
- package/lib/settings/secret-disclosure.js.map +1 -0
- package/lib/settings/settings-model.d.ts +17 -0
- package/lib/settings/settings-model.d.ts.map +1 -1
- package/lib/settings/settings-model.js +8 -0
- package/lib/settings/settings-model.js.map +1 -1
- package/lib/settings/settings-rpc.d.ts +71 -3
- package/lib/settings/settings-rpc.d.ts.map +1 -1
- package/lib/settings/settings-rpc.js +167 -24
- package/lib/settings/settings-rpc.js.map +1 -1
- package/lib/settings/settings-service.d.ts +9 -1
- package/lib/settings/settings-service.d.ts.map +1 -1
- package/lib/settings/settings-service.js +74 -6
- package/lib/settings/settings-service.js.map +1 -1
- package/lib/state-dir.d.ts +28 -0
- package/lib/state-dir.d.ts.map +1 -0
- package/lib/state-dir.js +33 -0
- package/lib/state-dir.js.map +1 -0
- package/lib/stream.d.ts.map +1 -1
- package/lib/stream.js +3 -2
- package/lib/stream.js.map +1 -1
- package/package.json +11 -9
package/lib/runner.js
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Per-chat agent driver: serializes inbound messages, creates/resumes the bound
|
|
3
|
-
* DSH agent (with preset composition + model selection), and bridges
|
|
4
|
-
*
|
|
3
|
+
* DSH agent (with preset composition + model selection), and bridges two live
|
|
4
|
+
* feeds into the adapter's streaming reply: the durable `session/event` stream
|
|
5
|
+
* (turn, tool, todo and settlement events) and the transient
|
|
6
|
+
* `agent/assistant-stream` frames that carry the model's deltas. Since
|
|
7
|
+
* 0.1.5-rc.2 the deltas are no longer session events — `assistant/chunk` is
|
|
8
|
+
* gone — so the two subscriptions are separate and both are required.
|
|
5
9
|
* @module dsh-connect/runner
|
|
6
10
|
*/
|
|
7
11
|
import { randomUUID } from "node:crypto";
|
|
@@ -12,6 +16,7 @@ import { SessionId } from "@deepseek-ai/dsh-session";
|
|
|
12
16
|
import { createUserMessage, ReasoningEffortId } from "@deepseek-ai/dsh-llm";
|
|
13
17
|
import { createAsyncQueue, applyStreamChunk, applyToolCall, toolCallSummary, questionTextOf, summarizeTurn, classifyError, mapReason, truncate, fmtTokens, textOf, } from "./stream.js";
|
|
14
18
|
import { helpText, parseCommand } from "./commands.js";
|
|
19
|
+
import { resolveStateDir } from "./state-dir.js";
|
|
15
20
|
import { menuTitle, rootMenuSections, reasonLabel, goalPhaseLabel, listWorkspaces, PROGRESS_PRESET_MS } from "./menus.js";
|
|
16
21
|
import { MenuController } from "./menu-controller.js";
|
|
17
22
|
import { messages } from "./i18n.js";
|
|
@@ -89,6 +94,19 @@ function sameDir(a, b) {
|
|
|
89
94
|
}
|
|
90
95
|
/** How often the proactive progress watchdog re-checks whether a status card is due (ms). */
|
|
91
96
|
const PROGRESS_WATCHDOG_CHECK_MS = 15_000;
|
|
97
|
+
/**
|
|
98
|
+
* Preset composed when the configured one cannot be used.
|
|
99
|
+
*
|
|
100
|
+
* `standard` is the roster's general-purpose preset and ships in every build,
|
|
101
|
+
* so it is the safest thing to fall back to. The roster still gets asked first
|
|
102
|
+
* — if it disagrees the first mountable row wins, and only a roster with no
|
|
103
|
+
* usable row at all leaves the agent uncomposted.
|
|
104
|
+
*/
|
|
105
|
+
const FALLBACK_PRESET_ID = "standard";
|
|
106
|
+
/** How a preset id reads in a log line: a quoted id, or the roster's default. */
|
|
107
|
+
function describePreset(id) {
|
|
108
|
+
return id === undefined || id === "" ? "the roster default" : JSON.stringify(id);
|
|
109
|
+
}
|
|
92
110
|
export class AgentRunner {
|
|
93
111
|
ctx;
|
|
94
112
|
config;
|
|
@@ -102,6 +120,8 @@ export class AgentRunner {
|
|
|
102
120
|
reminders;
|
|
103
121
|
queue = [];
|
|
104
122
|
running = false;
|
|
123
|
+
/** Set by `dispose()`; makes the runner permanently inert. */
|
|
124
|
+
disposed = false;
|
|
105
125
|
agent;
|
|
106
126
|
handle;
|
|
107
127
|
turn;
|
|
@@ -154,7 +174,31 @@ export class AgentRunner {
|
|
|
154
174
|
const first = registry?.list?.()[0];
|
|
155
175
|
return first?.path ?? process.cwd();
|
|
156
176
|
}
|
|
177
|
+
/**
|
|
178
|
+
* Tear the runner down for good — the counterpoint to `enqueue`.
|
|
179
|
+
*
|
|
180
|
+
* Called when the runner's channel adapter is deactivated (a settings change
|
|
181
|
+
* removed that channel from `channels[]`) or the service shuts down. The
|
|
182
|
+
* runner is inert afterwards: `enqueue` drops work and the drain loop stops
|
|
183
|
+
* before its next turn. `ConnectService` deletes its map entry, so a later
|
|
184
|
+
* message builds a fresh runner instead of reviving this one.
|
|
185
|
+
*
|
|
186
|
+
* `queuedMessages` is cleared rather than replayed: those messages belong to
|
|
187
|
+
* a channel that no longer has an adapter to answer on, so replaying them
|
|
188
|
+
* would run agent turns whose output has nowhere to go.
|
|
189
|
+
*/
|
|
190
|
+
async dispose() {
|
|
191
|
+
this.disposed = true;
|
|
192
|
+
this.queue.length = 0;
|
|
193
|
+
await this.disposeAgent();
|
|
194
|
+
const binding = this.bindings.get(this.channel, this.chatKey);
|
|
195
|
+
if (binding !== undefined && binding.lockOwner !== undefined) {
|
|
196
|
+
this.bindings.put({ ...lockRelease(binding), queuedMessages: [] });
|
|
197
|
+
}
|
|
198
|
+
}
|
|
157
199
|
enqueue(msg) {
|
|
200
|
+
if (this.disposed)
|
|
201
|
+
return;
|
|
158
202
|
const command = parseCommand(msg.text);
|
|
159
203
|
if (command.kind !== "message") {
|
|
160
204
|
// Command handlers touch the adapter directly (no per-call catch) — a
|
|
@@ -199,15 +243,6 @@ export class AgentRunner {
|
|
|
199
243
|
this.maybeNudgeContext(turn);
|
|
200
244
|
}
|
|
201
245
|
}
|
|
202
|
-
if (event.type === "assistant/chunk") {
|
|
203
|
-
const chunk = event.data.chunk;
|
|
204
|
-
// First reasoning delta = the milestone the proactive progress notice reports.
|
|
205
|
-
if (chunk.type === "reasoning-delta" && turn.milestone === undefined) {
|
|
206
|
-
turn.milestone = this.t.progressThinking;
|
|
207
|
-
}
|
|
208
|
-
applyStreamChunk(turn, this.t.thinkingHint, chunk, this.notifyLevel);
|
|
209
|
-
return;
|
|
210
|
-
}
|
|
211
246
|
if (event.type === "tool/call") {
|
|
212
247
|
const name = event.data.name;
|
|
213
248
|
if (typeof name !== "string" || name === "")
|
|
@@ -336,11 +371,11 @@ export class AgentRunner {
|
|
|
336
371
|
return choice === "confirm:yes";
|
|
337
372
|
}
|
|
338
373
|
async drain() {
|
|
339
|
-
if (this.running)
|
|
374
|
+
if (this.running || this.disposed)
|
|
340
375
|
return;
|
|
341
376
|
this.running = true;
|
|
342
377
|
try {
|
|
343
|
-
while (this.queue.length > 0) {
|
|
378
|
+
while (this.queue.length > 0 && !this.disposed) {
|
|
344
379
|
const msg = this.queue.shift();
|
|
345
380
|
if (msg === undefined)
|
|
346
381
|
break;
|
|
@@ -571,6 +606,10 @@ export class AgentRunner {
|
|
|
571
606
|
* report it) while the actual LLM requests keep using the old default.
|
|
572
607
|
* The default model still seeds the agent through `agentOptions` in
|
|
573
608
|
* `ensureAgent`, which `buildRequest` uses as its fallback route.
|
|
609
|
+
*
|
|
610
|
+
* Preset resolution is best-effort — see {@link resolvePresetId}. This method
|
|
611
|
+
* never throws for a preset reason, so no configuration id can stop a turn
|
|
612
|
+
* from composing an agent at all.
|
|
574
613
|
*/
|
|
575
614
|
async composeSetup(_selection) {
|
|
576
615
|
const presets = this.ctx.get("agentPresets");
|
|
@@ -579,14 +618,86 @@ export class AgentRunner {
|
|
|
579
618
|
setup: () => undefined,
|
|
580
619
|
};
|
|
581
620
|
}
|
|
582
|
-
const
|
|
621
|
+
const id = await this.resolvePresetId(presets);
|
|
622
|
+
if (id === undefined) {
|
|
623
|
+
return {
|
|
624
|
+
setup: () => undefined,
|
|
625
|
+
};
|
|
626
|
+
}
|
|
583
627
|
return {
|
|
584
|
-
agentPreset:
|
|
628
|
+
agentPreset: id,
|
|
585
629
|
setup: async (agentCtx) => {
|
|
586
|
-
await presets.mount(agentCtx,
|
|
630
|
+
await presets.mount(agentCtx, id);
|
|
587
631
|
},
|
|
588
632
|
};
|
|
589
633
|
}
|
|
634
|
+
/**
|
|
635
|
+
* Resolve the preset to compose, degrading instead of aborting the turn.
|
|
636
|
+
*
|
|
637
|
+
* The host looks a preset up by id, so one stale id — `agentPresets.default`
|
|
638
|
+
* naming a preset this install does not ship, or a preset directory edited
|
|
639
|
+
* into an unreadable state — used to throw straight out of `composeSetup`.
|
|
640
|
+
* That happens before an agent exists, so *every* turn of every bound chat
|
|
641
|
+
* died with the raw host error and nothing in the session log to explain it.
|
|
642
|
+
* Resolution is therefore best-effort: the configured id is tried, then
|
|
643
|
+
* {@link FALLBACK_PRESET_ID}, and when neither composes the agent is built
|
|
644
|
+
* without a preset — the same shape a host with no `agentPresets` service
|
|
645
|
+
* already produced — so the turn still runs.
|
|
646
|
+
*/
|
|
647
|
+
async resolvePresetId(presets) {
|
|
648
|
+
const wanted = this.config.agentPreset;
|
|
649
|
+
const resolved = await this.tryPreset(presets, wanted);
|
|
650
|
+
if (resolved !== undefined)
|
|
651
|
+
return resolved;
|
|
652
|
+
const recovered = await this.tryPreset(presets, await this.fallbackPresetId(presets));
|
|
653
|
+
if (recovered !== undefined) {
|
|
654
|
+
this.log(`connect: falling back to agent preset ${JSON.stringify(recovered)}`);
|
|
655
|
+
return recovered;
|
|
656
|
+
}
|
|
657
|
+
this.log("connect: no usable agent preset; composing the agent without one");
|
|
658
|
+
return undefined;
|
|
659
|
+
}
|
|
660
|
+
/**
|
|
661
|
+
* Resolve `id`, reporting why it is unusable instead of throwing.
|
|
662
|
+
*
|
|
663
|
+
* A preset that resolves but reports `broken` counts as unusable: the host's
|
|
664
|
+
* mounting paths refuse those, so composing one would only move this failure
|
|
665
|
+
* to `mount`, after the log line that would have explained it.
|
|
666
|
+
*
|
|
667
|
+
* `undefined` id means "the roster default", which is what `resolve` does
|
|
668
|
+
* with it — the default is as fallible as an explicit id, so it is routed
|
|
669
|
+
* through the same recovery.
|
|
670
|
+
*/
|
|
671
|
+
async tryPreset(presets, id) {
|
|
672
|
+
try {
|
|
673
|
+
const resolved = await presets.resolve(id);
|
|
674
|
+
if (resolved.broken !== undefined) {
|
|
675
|
+
this.log(`connect: agent preset ${describePreset(id)} failed to load: ${resolved.broken}`);
|
|
676
|
+
return undefined;
|
|
677
|
+
}
|
|
678
|
+
return resolved.id;
|
|
679
|
+
}
|
|
680
|
+
catch (error) {
|
|
681
|
+
this.log(`connect: agent preset ${describePreset(id)} is unusable: ${String(error)}`);
|
|
682
|
+
return undefined;
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
/**
|
|
686
|
+
* The id to retry with: {@link FALLBACK_PRESET_ID} while the roster still
|
|
687
|
+
* offers it, otherwise the roster's first mountable row.
|
|
688
|
+
*
|
|
689
|
+
* Never throws — the caller is already handling a failure, and a roster that
|
|
690
|
+
* cannot be listed should still leave the hardcoded id to be tried.
|
|
691
|
+
*/
|
|
692
|
+
async fallbackPresetId(presets) {
|
|
693
|
+
try {
|
|
694
|
+
const mountable = ((await presets.list?.()) ?? []).filter((row) => row.broken === undefined);
|
|
695
|
+
return mountable.find((row) => row.id === FALLBACK_PRESET_ID)?.id ?? mountable[0]?.id ?? FALLBACK_PRESET_ID;
|
|
696
|
+
}
|
|
697
|
+
catch {
|
|
698
|
+
return FALLBACK_PRESET_ID;
|
|
699
|
+
}
|
|
700
|
+
}
|
|
590
701
|
/** Agents this runner has already attached a `session/event` listener to. */
|
|
591
702
|
watchedAgents = new WeakSet();
|
|
592
703
|
watchAgent(agent) {
|
|
@@ -600,6 +711,28 @@ export class AgentRunner {
|
|
|
600
711
|
agent.ctx.on("session/event", (session, event) => {
|
|
601
712
|
this.onSessionEvent(session, event);
|
|
602
713
|
});
|
|
714
|
+
// Live model deltas. Deltas used to arrive as `assistant/chunk` session
|
|
715
|
+
// events; that type is gone, and a turn's stream is now durable only at
|
|
716
|
+
// settlement (embedded in `assistant/message` / `assistant/attempt`). The
|
|
717
|
+
// live feed is this separate agent-scoped event instead — `dsh-scope`
|
|
718
|
+
// routes it by its `agent` subject, so a listener on `agent.ctx` sees only
|
|
719
|
+
// this agent's frames, exactly like the `session/event` subscription above
|
|
720
|
+
// (no id filter needed). Frames carry real `StreamChunk`s, so
|
|
721
|
+
// `applyStreamChunk` is unchanged.
|
|
722
|
+
agent.ctx.on("agent/assistant-stream", ({ frame }) => {
|
|
723
|
+
const turn = this.turn;
|
|
724
|
+
if (turn === undefined)
|
|
725
|
+
return;
|
|
726
|
+
// `start` / `end` only bracket the attempt; the card is driven by chunks.
|
|
727
|
+
if (frame.type !== "chunk")
|
|
728
|
+
return;
|
|
729
|
+
const chunk = frame.chunk;
|
|
730
|
+
// First reasoning delta = the milestone the proactive progress notice reports.
|
|
731
|
+
if (chunk.type === "reasoning-delta" && turn.milestone === undefined) {
|
|
732
|
+
turn.milestone = this.t.progressThinking;
|
|
733
|
+
}
|
|
734
|
+
applyStreamChunk(turn, this.t.thinkingHint, chunk, this.notifyLevel);
|
|
735
|
+
});
|
|
603
736
|
}
|
|
604
737
|
async driveAgent(agent, msg) {
|
|
605
738
|
const firstSeq = agent.session.seq;
|
|
@@ -700,7 +833,7 @@ export class AgentRunner {
|
|
|
700
833
|
this.log(`connect: deferred compaction failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
701
834
|
});
|
|
702
835
|
}
|
|
703
|
-
const outcome = summarizeTurn(agent.session.
|
|
836
|
+
const outcome = summarizeTurn(agent.session.snapshotEvents(), firstSeq);
|
|
704
837
|
const text = outcome.text !== "" ? outcome.text : this.turn.lastText;
|
|
705
838
|
this.turn = undefined;
|
|
706
839
|
return { ...outcome, text };
|
|
@@ -930,7 +1063,7 @@ export class AgentRunner {
|
|
|
930
1063
|
const agent = this.agent;
|
|
931
1064
|
if (agent === undefined)
|
|
932
1065
|
return [];
|
|
933
|
-
const events = agent.session.
|
|
1066
|
+
const events = agent.session.snapshotEvents();
|
|
934
1067
|
for (let i = events.length - 1; i >= 0; i--) {
|
|
935
1068
|
const event = events[i];
|
|
936
1069
|
if (event.type === "todo/write")
|
|
@@ -1449,7 +1582,7 @@ export class AgentRunner {
|
|
|
1449
1582
|
}
|
|
1450
1583
|
/** Extract the most recent turn's completion info from session events. */
|
|
1451
1584
|
getLastTurnInfo(agent) {
|
|
1452
|
-
const events = agent.session.
|
|
1585
|
+
const events = agent.session.snapshotEvents();
|
|
1453
1586
|
for (let i = events.length - 1; i >= 0; i--) {
|
|
1454
1587
|
const event = events[i];
|
|
1455
1588
|
if (event.type === "turn/end") {
|
|
@@ -1492,7 +1625,9 @@ export class AgentRunner {
|
|
|
1492
1625
|
lines.push(this.t.progressSetting(this.progressTimeoutMs === 0 ? this.t.progressOff : this.t.progressMinutes(Math.round(this.progressTimeoutMs / 60_000))));
|
|
1493
1626
|
lines.push(this.t.allowUsersField(this.config.allowUsers.length, this.config.allowUsers.length === 0));
|
|
1494
1627
|
lines.push(this.t.allowChatsField(this.config.allowChats.length, this.config.allowChats.length === 0));
|
|
1495
|
-
|
|
1628
|
+
// Show the directory the stores actually resolve to (config → env → default),
|
|
1629
|
+
// not the raw config value, so the status card can't disagree with disk.
|
|
1630
|
+
lines.push(this.t.stateDirField(resolveStateDir(this.config)));
|
|
1496
1631
|
await this.adapter.sendText(target, lines.join("\n"));
|
|
1497
1632
|
}
|
|
1498
1633
|
async setModel(provider, model, msg) {
|
|
@@ -1717,7 +1852,7 @@ export class AgentRunner {
|
|
|
1717
1852
|
await this.adapter.sendText(target, this.t.historySessions(sessions.length, lines.join("\n")));
|
|
1718
1853
|
return;
|
|
1719
1854
|
}
|
|
1720
|
-
const events = agent.session.
|
|
1855
|
+
const events = agent.session.snapshotEvents();
|
|
1721
1856
|
const rows = [];
|
|
1722
1857
|
for (let i = events.length - 1; i >= 0 && rows.length < limit; i--) {
|
|
1723
1858
|
const e = events[i];
|
|
@@ -1766,7 +1901,7 @@ export class AgentRunner {
|
|
|
1766
1901
|
async showSchedule(target) {
|
|
1767
1902
|
// Agent-level reminders (session `schedule` tool) + persistent chat-level
|
|
1768
1903
|
// reminders (`/remind`), merged into one list.
|
|
1769
|
-
const agentReminders = this.agent === undefined ? [] : foldReminders(this.agent.session.
|
|
1904
|
+
const agentReminders = this.agent === undefined ? [] : foldReminders(this.agent.session.snapshotEvents());
|
|
1770
1905
|
const persisted = this.reminders?.listFor(this.channel, this.chatKey) ?? [];
|
|
1771
1906
|
const now = Date.now();
|
|
1772
1907
|
const locale = this.language === "en" ? "en-US" : "zh-CN";
|
|
@@ -1939,7 +2074,7 @@ export class AgentRunner {
|
|
|
1939
2074
|
* Generate Markdown representation of the conversation history.
|
|
1940
2075
|
*/
|
|
1941
2076
|
generateMarkdown(agent) {
|
|
1942
|
-
const events = agent.session.
|
|
2077
|
+
const events = agent.session.snapshotEvents();
|
|
1943
2078
|
const lines = [];
|
|
1944
2079
|
// Header
|
|
1945
2080
|
lines.push(`# Conversation History`);
|