dsh-agent-persona 0.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/lib/index.js ADDED
@@ -0,0 +1,1481 @@
1
+ // @ts-nocheck —— 迁移第一步:先建立构建与产物形状,类型逐步补全
2
+ import { Remote, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol';
3
+ /**
4
+ * agent-persona — host half (v2: many personas, each with its own scope).
5
+ *
6
+ * Model
7
+ * -----
8
+ * A persona is a first-class record: `{ id, name, enabled, text, targets[] }`.
9
+ * Every target says WHERE the persona applies:
10
+ *
11
+ * { kind: 'workspace' | 'sessionId', match: 'exact' | 'prefix' | 'regex' | 'contains', value: string }
12
+ *
13
+ * Resolution (see {@link resolvePersona}) is deliberately simple and explainable:
14
+ *
15
+ * 1. personas are tried in ARRAY ORDER (the settings page shows that order and
16
+ * lets it be reordered) — the first match wins;
17
+ * 2. inside one persona the targets are OR-ed (any target matching is enough);
18
+ * 3. a persona with NO targets applies nowhere (a draft), it is never a catch-all: so it
19
+ * belongs at the bottom of the list;
20
+ * 4. a DISABLED persona is skipped entirely;
21
+ * 5. a matching persona whose text is EMPTY wins and contributes nothing —
22
+ * that is how you carve an exception out of a catch-all ("no persona here");
23
+ * 6. when nothing matches, the section renders `''` and `renderPrompt()` drops
24
+ * it, so the agent keeps the stock DSH system prompt.
25
+ *
26
+ * Placement: ONE global system-prompt section at
27
+ * `getSectionOrder('DEPLOYMENT_PERSONA_PREFIX') + 1` (order 1) — right after the
28
+ * harness identity (-1000) and the persona prefix (0), ahead of every
29
+ * tool-guidance section (500+). Workspace `AGENTS.md` is not a section at all
30
+ * (`dsh-agent-instructions` turns it into a durable USER-role message whose own
31
+ * intro says it does not override system instructions), so a persona here
32
+ * outranks `AGENTS.md` structurally.
33
+ *
34
+ * Storage lives in this plugin's own directory — `$DSH_HOME/dsh-agent-persona/`
35
+ * holds `personas.json` (the data) and `state.json` (a load heartbeat), so a
36
+ * deployment does not scatter loose files across `$DSH_HOME`. The data file is
37
+ * re-read on every assembly when its `mtimeNs:size` stamp changes, so edits apply
38
+ * to the next request.
39
+ *
40
+ * The settings page offers dropdowns instead of asking for raw paths and ids;
41
+ * `listTargets` is what feeds them (workspaces + sessions with titles).
42
+ *
43
+ * Row config (all optional):
44
+ * tuneProvider / tuneModel — pin the model used by the AI-tuning button for
45
+ * this deployment; when unset it uses the model DSH itself is set to use
46
+ * (`agentDefaultModel`), which is also what the settings page preselects.
47
+ * storePath — override the persona store location (default
48
+ * `$DSH_HOME/dsh-agent-persona/personas.json`).
49
+ */
50
+ import Schema from '@deepseek-ai/schemastery';
51
+ import { RPC_ENDPOINTS } from './endpoints.js';
52
+ import { mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from 'node:fs';
53
+ import { readFile } from 'node:fs/promises';
54
+ import { homedir } from 'node:os';
55
+ import { dirname, join, resolve } from 'node:path';
56
+ export const name = 'agent-persona';
57
+ /**
58
+ * Configuration. Written as a Schemastery schema so the defaults live here, the shape is checked
59
+ * when the row loads, and a wrong value fails loudly instead of being ignored.
60
+ */
61
+ export const Config = Schema.object({
62
+ /** Where the personas live. Empty means `$DSH_HOME/dsh-agent-persona/personas.json`. */
63
+ storePath: Schema.string().default(''),
64
+ });
65
+ export const inject = ['systemPrompt', 'connection', 'webServer'];
66
+ const DSH_HOME = process.env.DSH_HOME ?? join(homedir(), '.dsh');
67
+ /** Everything this plugin writes lives under one directory of its own. */
68
+ const STATE_DIR = join(DSH_HOME, 'dsh-agent-persona');
69
+ const STORE_FILE = join(STATE_DIR, 'personas.json');
70
+ const HEARTBEAT_FILE = join(STATE_DIR, 'state.json');
71
+ /** Pre-0.1.1 layout: loose files directly under $DSH_HOME. Migrated on first load. */
72
+ const SECTION_NAME = 'agent-persona';
73
+ /** The prompt section a deployment (or a preset) uses for its own persona line. */
74
+ const PERSONA_PREFIX_SECTION = 'deployment:persona-prefix';
75
+ /** The harness's own identity line, the section a `replace` persona also takes over. */
76
+ const HARNESS_IDENTITY_SECTION = 'harness:identity';
77
+ const HARNESS_IDENTITY_TEXT = 'You are an AI agent powered by DeepSeek Harness';
78
+ /** Persona injection modes: append after the preset persona, or replace it. */
79
+ export const PERSONA_MODES = ['append', 'replace'];
80
+ const MAX_TEXT_BYTES = 64 * 1024;
81
+ const STORE_VERSION = 1;
82
+ /** Prompt variables a persona may reference; anything else is de-braced. */
83
+ const ALLOWED_VARIABLES = new Set(['model', 'cwd']);
84
+ /** Target vocabulary, surfaced to the settings page. */
85
+ export const TARGET_KINDS = ['workspace', 'sessionId'];
86
+ export const TARGET_MATCHES = ['exact', 'prefix', 'regex', 'contains'];
87
+ const TUNE_MODES = new Set(['polish', 'expand', 'compress', 'draft']);
88
+ // ─────────────────────────────────────────────────────────── store
89
+ const emptyStore = () => ({ version: STORE_VERSION, personas: [], prefs: normalizePrefs(undefined) });
90
+ /**
91
+ * Move one persona to an absolute position (what a drag-and-drop emits).
92
+ * @param store - the store to adjust in place.
93
+ * @param id - persona to move.
94
+ * @param toIndex - target position, clamped into range.
95
+ * @returns whether the order actually changed.
96
+ */
97
+ export const reorderPersonas = (store, id, toIndex) => {
98
+ const personas = store?.personas ?? [];
99
+ const from = personas.findIndex((persona) => persona.id === id);
100
+ if (from === -1)
101
+ return false;
102
+ const to = Math.max(0, Math.min(personas.length - 1, Number.isFinite(toIndex) ? Math.trunc(toIndex) : from));
103
+ if (to === from)
104
+ return false;
105
+ const [persona] = personas.splice(from, 1);
106
+ personas.splice(to, 0, persona);
107
+ return true;
108
+ };
109
+ /**
110
+ * A workspace or a session belongs to exactly one persona. When a save claims a
111
+ * value that another persona holds with the same kind and the same exact value,
112
+ * the claim moves to the incoming persona and the other one loses that row.
113
+ *
114
+ * Only exact values can be reasoned about: prefix / regex / contains rows may
115
+ * still overlap, and the first match in list order wins there.
116
+ * @param store - the store to adjust in place.
117
+ * @param persona - the persona whose claims win.
118
+ * @returns the names of the personas that lost a row.
119
+ */
120
+ export const takeOverClaims = (store, persona) => {
121
+ const claimed = new Set((persona.targets ?? [])
122
+ .filter((target) => target.kind !== undefined && target.value !== '')
123
+ .map((target) => `${target.kind}\u0000${target.value}`));
124
+ if (claimed.size === 0)
125
+ return [];
126
+ const moved = [];
127
+ for (const other of store.personas ?? []) {
128
+ if (other === persona || other.id === persona.id)
129
+ continue;
130
+ const kept = (other.targets ?? []).filter((target) => !claimed.has(`${target.kind}\u0000${target.value}`));
131
+ if (kept.length !== (other.targets ?? []).length) {
132
+ other.targets = kept;
133
+ moved.push(other.name);
134
+ }
135
+ }
136
+ return moved;
137
+ };
138
+ const clone = (value) => JSON.parse(JSON.stringify(value));
139
+ const newId = () => `psn_${Math.random().toString(36).slice(2, 8)}${Date.now().toString(36).slice(-4)}`;
140
+ const normalizeTarget = (raw) => ({
141
+ kind: TARGET_KINDS.includes(raw?.kind) ? raw.kind : 'workspace',
142
+ match: TARGET_MATCHES.includes(raw?.match) ? raw.match : 'exact',
143
+ value: typeof raw?.value === 'string' ? raw.value : '',
144
+ });
145
+ const normalizePersona = (raw) => ({
146
+ id: typeof raw?.id === 'string' && raw.id !== '' ? raw.id : newId(),
147
+ name: typeof raw?.name === 'string' && raw.name.trim() !== '' ? raw.name.trim() : '未命名人设',
148
+ enabled: raw?.enabled !== false,
149
+ fallback: raw?.fallback === true,
150
+ text: typeof raw?.text === 'string' ? raw.text : '',
151
+ mode: raw?.mode === 'replace' ? 'replace' : 'append',
152
+ targets: (Array.isArray(raw?.targets) ? raw.targets : []).map(normalizeTarget),
153
+ });
154
+ /**
155
+ * Bring a stored document to the v2 shape. v1 kept one persona per workspace
156
+ * plus one `default`; each entry that carried anything meaningful becomes a
157
+ * persona, and the v1 default becomes a persona with no targets (imported, but inert until it is
158
+ * given at least one).
159
+ * @param parsed - the raw JSON document (any version).
160
+ * @param warn - diagnostic sink.
161
+ * @returns the v2 store plus a migration report (empty when nothing moved).
162
+ */
163
+ /**
164
+ * Interface preferences live in the store, next to the personas, so one file carries everything the
165
+ * plugin remembers and two deployments pointed at different files are fully independent. The two
166
+ * display switches default to on: a fresh install shows the page everywhere until told otherwise.
167
+ */
168
+ const DEFAULT_PREFS = { autosave: false, showTab: true, showSidebar: true };
169
+ export const normalizePrefs = (raw) => ({
170
+ autosave: raw?.autosave === true,
171
+ showTab: raw?.showTab !== false,
172
+ showSidebar: raw?.showSidebar !== false,
173
+ });
174
+ const loadStore = (storePath, warn) => {
175
+ let parsed;
176
+ try {
177
+ parsed = JSON.parse(readFileSync(storePath, 'utf8'));
178
+ }
179
+ catch (error) {
180
+ if (error?.code !== 'ENOENT')
181
+ warn(`could not read ${storePath}; starting from an empty store`, error);
182
+ return emptyStore();
183
+ }
184
+ if (parsed === null || typeof parsed !== 'object' || !Array.isArray(parsed.personas)) {
185
+ warn(`${storePath} is not a persona store; starting from an empty one`);
186
+ return emptyStore();
187
+ }
188
+ return { version: STORE_VERSION, personas: parsed.personas.map(normalizePersona), prefs: normalizePrefs(parsed.prefs) };
189
+ };
190
+ const persist = (store, storePath) => {
191
+ mkdirSync(dirname(storePath), { recursive: true });
192
+ const tmp = `${storePath}.tmp-${process.pid}`;
193
+ writeFileSync(tmp, `${JSON.stringify(store, null, 2)}\n`, { mode: 0o600 });
194
+ renameSync(tmp, storePath);
195
+ };
196
+ export const sanitizePersona = (text, warn = () => { }) => {
197
+ const dropped = [];
198
+ const neutral = String(text).replaceAll('{{', '\u0000').replaceAll('}}', '\u0001');
199
+ const out = neutral
200
+ .replace(/\u0000\s*([A-Za-z_][A-Za-z0-9_]*)\s*\u0001/g, (_match, variable) => {
201
+ if (ALLOWED_VARIABLES.has(variable))
202
+ return `{{${variable}}}`;
203
+ dropped.push(variable);
204
+ return variable;
205
+ })
206
+ // Stray markers (a `{{` with no complete group) are dropped rather than
207
+ // turned into spaces, so the sentence does not gain run-on whitespace.
208
+ .replaceAll('\u0000', '')
209
+ .replaceAll('\u0001', '');
210
+ if (dropped.length > 0)
211
+ warn(`persona text references unsupported prompt variables (${[...new Set(dropped)].join(', ')}); rendered as plain text`);
212
+ return out;
213
+ };
214
+ // ─────────────────────────────────────────────────────────── matching
215
+ const regexCache = new Map();
216
+ const compile = (value) => {
217
+ if (regexCache.has(value))
218
+ return regexCache.get(value);
219
+ let compiled;
220
+ try {
221
+ compiled = new RegExp(value);
222
+ }
223
+ catch {
224
+ compiled = undefined;
225
+ }
226
+ regexCache.set(value, compiled);
227
+ return compiled;
228
+ };
229
+ const subjectFor = (target, subject) => (target.kind === 'sessionId' ? subject.sessionId : subject.cwd);
230
+ /**
231
+ * Does one target claim this subject?
232
+ * @param target - normalized target record.
233
+ * @param subject - `{ cwd?: string, sessionId?: string }`.
234
+ * @returns true when the target matches; an unusable target (empty value, bad
235
+ * regex, or a subject field the session does not have) never matches.
236
+ */
237
+ export const matchTarget = (target, subject) => {
238
+ const value = target.value.trim();
239
+ if (value === '')
240
+ return false;
241
+ const raw = subjectFor(target, subject);
242
+ if (typeof raw !== 'string' || raw === '')
243
+ return false;
244
+ const isPath = target.kind === 'workspace';
245
+ // Only `exact` normalises paths (so a trailing slash or `..` still matches);
246
+ // prefix/contains/regex compare the raw strings the user typed, which is what
247
+ // makes a plain substring like `project-x` usable on a cwd.
248
+ switch (target.match) {
249
+ case 'exact':
250
+ return isPath ? resolve(raw) === resolve(value) : raw === value;
251
+ case 'prefix':
252
+ return raw.startsWith(value);
253
+ case 'contains':
254
+ return raw.includes(value);
255
+ case 'regex': {
256
+ const compiled = compile(value);
257
+ return compiled === undefined ? false : compiled.test(raw);
258
+ }
259
+ default:
260
+ return false;
261
+ }
262
+ };
263
+ /** The target list that claims this subject, empty when none does. */
264
+ /**
265
+ * How specific one matching rule is. A session-level rule beats a directory-level
266
+ * one, and an exact value beats a pattern, so the most specific claim wins without
267
+ * the user having to order the list by hand.
268
+ * @param target - the matching row.
269
+ * @returns 6 (sessionId/exact) down to 1 (workspace pattern); a target-less
270
+ * persona scores 0, i.e. it only takes what nothing else claimed.
271
+ */
272
+ export const targetSpecificity = (target) => {
273
+ if (target?.kind === undefined)
274
+ return 0;
275
+ const kind = target.kind === 'sessionId' ? 3 : 1;
276
+ const match = target.match === 'exact' ? 2 : target.match === 'prefix' ? 1 : 0;
277
+ return kind * 2 + match;
278
+ };
279
+ export const matchingTargets = (persona, subject) => (persona.targets ?? []).filter((target) => matchTarget(target, subject));
280
+ /**
281
+ * Pick the persona that owns this assembly.
282
+ * @param personas - the store's persona list, already in priority order.
283
+ * @param subject - `{ cwd?, sessionId? }` for the assembling agent.
284
+ * @returns the winning persona and why, or `reason: 'none'`.
285
+ */
286
+ export const resolvePersona = (personas, subject) => {
287
+ // Most specific claim wins; the list order only breaks ties. A persona with no
288
+ // targets) scores 0 and therefore takes exactly what nothing else claimed.
289
+ let winner;
290
+ let winnerScore = -1;
291
+ for (const persona of personas ?? []) {
292
+ if (!persona.enabled)
293
+ continue;
294
+ // A persona without a target applies nowhere: it is a draft, not a catch-all. Nothing
295
+ // claimed means the harness keeps its own system prompt, which is the honest default.
296
+ if (persona.targets.length === 0)
297
+ continue;
298
+ const matchedBy = matchingTargets(persona, subject);
299
+ if (matchedBy.length === 0)
300
+ continue;
301
+ const score = Math.max(...matchedBy.map(targetSpecificity));
302
+ if (score > winnerScore) {
303
+ winner = { persona, reason: 'target', matchedBy, specificity: score };
304
+ winnerScore = score;
305
+ }
306
+ }
307
+ if (winner !== undefined)
308
+ return winner;
309
+ // Nothing claimed a position: the persona marked as the default takes the rest, and if there is
310
+ // none the harness keeps its own system prompt.
311
+ for (const persona of personas ?? []) {
312
+ if (!persona.enabled || persona.fallback !== true)
313
+ continue;
314
+ return { persona, reason: 'fallback', matchedBy: [], specificity: 0 };
315
+ }
316
+ return { persona: undefined, reason: 'none', matchedBy: [], specificity: -1 };
317
+ };
318
+ const headerOf = (agent) => agent?.session?.header ?? agent?.session ?? {};
319
+ export const subjectOf = (agent) => {
320
+ const header = headerOf(agent);
321
+ const id = typeof header?.id === 'string' && header.id !== '' ? header.id : agent?.id;
322
+ const cwd = typeof header?.cwd === 'string' && header.cwd !== '' ? resolve(header.cwd) : undefined;
323
+ return { cwd, sessionId: typeof id === 'string' ? id : undefined };
324
+ };
325
+ /** The persona text one assembly should carry, or `''` for the stock prompt. */
326
+ export const personaTextFor = (personas, agent, warn = () => { }) => {
327
+ // An agent-less assembly has no cwd/session, so no persona with targets can
328
+ // claim it — which is exactly what makes a deployment-wide persona possible.
329
+ const picked = resolvePersona(personas, subjectOf(agent));
330
+ if (picked.persona === undefined)
331
+ return '';
332
+ return picked.persona.text.trim() === '' ? '' : sanitizePersona(picked.persona.text, warn).trim();
333
+ };
334
+ // ─────────────────────────────────────────────────────────── remote service
335
+ /**
336
+ * Everything the picker needs about a session, out of ONE read of DSH's
337
+ * projection cache: the title, the first prompt and the latest prompt.
338
+ *
339
+ * The projection file is the cheapest source by a wide margin — a session log is
340
+ * 2.6x-5.8x larger on a real machine and is a zstd-compressed append-only file
341
+ * that cannot be seeked, so reading its tail would be the most expensive option
342
+ * of all. The service is tried first (the intended fast path) but its
343
+ * `recordFor(id)` needs an identity witness a listed header cannot reconstruct,
344
+ * so the file below is what actually answers; both are cached per process.
345
+ */
346
+ /**
347
+ * Clip one message for transport without breaking its Markdown. A cut that lands
348
+ * inside an open ``` fence would swallow every line after it (and can push the
349
+ * renderer into its plain-text fallback), so the fence is closed first.
350
+ * @param text - the message body.
351
+ * @param maxChars - how much to keep.
352
+ * @returns the clipped body with a truncation note.
353
+ */
354
+ export const clipMarkdown = (text, maxChars) => {
355
+ if (text.length <= maxChars)
356
+ return text;
357
+ // The notice is the interface's job (it renders its own truncated row), so the text itself
358
+ // stays clean — what a reader copies is the message, not a note glued to its end.
359
+ const head = text.slice(0, maxChars);
360
+ // Cut at a block boundary: the renderer asked for fields a fragment does not have
361
+ // ("Cannot read properties of undefined (reading 'code')") only for clipped text,
362
+ // and never for the same message read in full, so the kept text must be a whole
363
+ // sequence of blocks rather than a slice that ends inside one.
364
+ const paragraph = head.lastIndexOf('\n\n');
365
+ const lineBreak = head.lastIndexOf('\n');
366
+ const floor = maxChars * 0.4;
367
+ let cut = paragraph > floor ? head.slice(0, paragraph) : lineBreak > floor ? head.slice(0, lineBreak) : head;
368
+ const fences = (cut.match(/^```/gm) ?? []).length;
369
+ if (fences % 2 === 1) {
370
+ const opening = cut.lastIndexOf('\n```');
371
+ cut = opening === -1 ? '' : cut.slice(0, opening);
372
+ }
373
+ if ((cut.match(/`/g) ?? []).length % 2 === 1)
374
+ cut = cut.slice(0, cut.lastIndexOf('`'));
375
+ return cut.trimEnd();
376
+ };
377
+ /** One line, at most 48 characters, for a label taken from a prompt. */
378
+ const summarize = (text) => {
379
+ const flat = String(text).replace(/\s+/g, ' ').trim();
380
+ return flat.length > 48 ? `${flat.slice(0, 48)}…` : flat;
381
+ };
382
+ const projectionLabels = new Map();
383
+ const labelFromProjections = async (ctx, id) => {
384
+ if (projectionLabels.has(id))
385
+ return projectionLabels.get(id);
386
+ const resolved = await readProjectionLabel(ctx, id);
387
+ if (projectionLabels.size > 400)
388
+ projectionLabels.clear();
389
+ projectionLabels.set(id, resolved);
390
+ return resolved;
391
+ };
392
+ const projectionValue = (record, key) => {
393
+ const row = record?.rows?.[key] ?? record?.[key];
394
+ if (row === undefined)
395
+ return undefined;
396
+ return typeof row === 'object' && row !== null && 'val' in row ? row.val : row;
397
+ };
398
+ const readProjectionLabel = async (ctx, id) => {
399
+ const fromRecord = (record) => {
400
+ if (record === undefined)
401
+ return undefined;
402
+ const title = projectionValue(record, 'title');
403
+ const inputs = projectionValue(record, 'titleInput');
404
+ const outline = projectionValue(record, 'turnOutline')?.turns;
405
+ const latest = Array.isArray(outline) && outline.length > 0 ? outline[outline.length - 1]?.prompt : undefined;
406
+ const stats = projectionValue(record, 'sessionStats');
407
+ const metadata = projectionValue(record, 'sessionListMetadata');
408
+ // How many times this session was talked to — same file, no extra read.
409
+ const turns = Number.isFinite(stats?.turns) ? stats.turns
410
+ : Array.isArray(outline) && outline.length > 0 ? outline.length
411
+ : Number.isFinite(inputs?.count) ? inputs.count : undefined;
412
+ return {
413
+ title: typeof title === 'string' && title !== '' ? title : undefined,
414
+ firstPrompt: typeof inputs?.first?.text === 'string' ? inputs.first.text : undefined,
415
+ latestPrompt: typeof latest === 'string' ? latest : undefined,
416
+ ...(turns === undefined ? {} : { turns }),
417
+ ...(metadata?.blank === true ? { blank: true } : {}),
418
+ };
419
+ };
420
+ try {
421
+ const cache = ctx.get('sessionProjectionCache');
422
+ const record = cache?.recordFor?.(id);
423
+ const fromView = fromRecord(cache?.viewRecord?.(record, ['title', 'titleInput', 'turnOutline'])) ?? fromRecord(record);
424
+ if (fromView !== undefined && (fromView.title !== undefined || fromView.firstPrompt !== undefined || fromView.latestPrompt !== undefined)) {
425
+ return fromView;
426
+ }
427
+ }
428
+ catch {
429
+ /* fall through to the file */
430
+ }
431
+ try {
432
+ const file = join(DSH_HOME, 'storages', 'session_projcache', 'sessions', `${id}.json`);
433
+ const parsed = JSON.parse(await readFile(file, 'utf8'));
434
+ return fromRecord(parsed?.record) ?? fromRecord(parsed) ?? {};
435
+ }
436
+ catch {
437
+ return {};
438
+ }
439
+ };
440
+ /**
441
+ * The candidate lists the settings page offers as dropdowns. Every source is
442
+ * probed defensively: a deployment whose services differ simply gets an empty
443
+ * list, and the page falls back to typing a value.
444
+ * @param ctx - host context.
445
+ * @param warn - warning sink.
446
+ * @returns workspaces (`{path,title,sessionCount}`) and the newest 200 sessions
447
+ * (`{id,title?,cwd,createdAt}`, newest first).
448
+ */
449
+ export const collectTargets = async (ctx, { personas = [], warn = () => { } } = {}) => {
450
+ /** Which enabled persona claims a value exactly, if any. */
451
+ const ownerOf = (kind, value) => {
452
+ // Ownership must agree with injection: the winner is the most specific match,
453
+ // not whichever persona happens to sit first in the list.
454
+ const subject = kind === 'workspace' ? { cwd: value } : { sessionId: value };
455
+ const winner = resolvePersona(personas, subject).persona;
456
+ if (winner === undefined)
457
+ return undefined;
458
+ const claims = (winner.targets ?? []).some((target) => target.kind === kind && matchTarget(target, subject));
459
+ return claims ? { id: winner.id, name: winner.name } : undefined;
460
+ };
461
+ const workspaces = [];
462
+ try {
463
+ const registry = ctx.get('workspaceRegistry');
464
+ const list = registry?.list;
465
+ if (typeof list !== 'function')
466
+ throw new Error('this deployment exposes no workspace list');
467
+ for (const workspace of (await list.call(registry)) ?? []) {
468
+ const path = typeof workspace?.path === 'string' ? workspace.path : undefined;
469
+ if (path === undefined)
470
+ continue;
471
+ let sessionCount = 0;
472
+ try {
473
+ sessionCount = (workspace.sessionIds ?? []).length;
474
+ }
475
+ catch {
476
+ sessionCount = 0;
477
+ }
478
+ const owner = ownerOf('workspace', path);
479
+ workspaces.push({
480
+ path,
481
+ title: typeof workspace.title === 'string' && workspace.title !== '' ? workspace.title : path,
482
+ sessionCount,
483
+ ...(owner === undefined ? {} : { owner }),
484
+ });
485
+ }
486
+ }
487
+ catch (error) {
488
+ warn('the workspace list is unavailable; target values must be typed', error);
489
+ }
490
+ const sessions = [];
491
+ // Archived ids and the hidden-session tallies live at function scope: the loop
492
+ // that fills them runs inside the try below.
493
+ const archived = collectArchivedSessionIds(ctx);
494
+ const hidden = { subagents: 0, archived: 0, blank: 0 };
495
+ try {
496
+ const persistence = ctx.get('sessionPersistence') ?? ctx.get('sessions');
497
+ const list = persistence?.list;
498
+ if (typeof list !== 'function')
499
+ throw new Error('this deployment exposes no session list');
500
+ const rows = (await list.call(persistence)) ?? [];
501
+ const titles = ctx.get('sessionTitle');
502
+ const live = ctx.get('sessions');
503
+ for (const row of rows) {
504
+ const header = row?.header ?? row;
505
+ const id = header?.id;
506
+ if (typeof id !== 'string')
507
+ continue;
508
+ // Only conversations a human can open and type into: a sub-agent session
509
+ // (spawned by the model — origin 'subagent' / depth > 0) and an archived one
510
+ // are both unreachable from the sidebar.
511
+ if (header?.origin === 'subagent' || (typeof header?.delegationDepth === 'number' && header.delegationDepth > 0)) {
512
+ hidden.subagents += 1;
513
+ continue;
514
+ }
515
+ if (archived.has(id)) {
516
+ hidden.archived += 1;
517
+ continue;
518
+ }
519
+ let title;
520
+ try {
521
+ const session = live?.get?.(id) ?? row?.session;
522
+ const value = session === undefined ? undefined : titles?.get?.(session);
523
+ if (typeof value === 'string' && value !== '')
524
+ title = value;
525
+ }
526
+ catch {
527
+ /* a title is a nice-to-have, never a requirement */
528
+ }
529
+ const owner = ownerOf('sessionId', id);
530
+ sessions.push({
531
+ id,
532
+ ...(title === undefined ? {} : { title }),
533
+ cwd: typeof header?.cwd === 'string' ? header.cwd : null,
534
+ createdAt: typeof header?.createdAt === 'number' ? header.createdAt : null,
535
+ ...(owner === undefined ? {} : { owner }),
536
+ });
537
+ }
538
+ }
539
+ catch (error) {
540
+ warn('the session list is unavailable; target values must be typed', error);
541
+ }
542
+ sessions.sort((a, b) => (b.createdAt ?? 0) - (a.createdAt ?? 0));
543
+ const newest = sessions.slice(0, 200);
544
+ // Reading a session is the expensive path, so only the newest handful without
545
+ // a title pay for it — and they are the ones a user picks from anyway.
546
+ // Projection files are large (they carry context timelines: ~265 KB each on a
547
+ // real machine, up to ~850 KB) and parsing one costs ~1 ms, so only the newest
548
+ // slice is read inline — enough to label the flat "recent sessions" list and
549
+ // the first directory groups. Reads are async (the loop keeps running) and every
550
+ // answer is cached for the process, so each session is read at most once.
551
+ let projectionBudget = 16;
552
+ const empty = [];
553
+ for (const session of newest) {
554
+ if (session.title !== undefined || projectionBudget <= 0)
555
+ continue;
556
+ projectionBudget -= 1;
557
+ const label = await labelFromProjections(ctx, session.id);
558
+ // A session nobody ever typed into (the flag the sidebar itself hides on):
559
+ // it holds no conversation to give a persona.
560
+ if (label.blank === true) {
561
+ empty.push(session);
562
+ continue;
563
+ }
564
+ if (label.turns !== undefined)
565
+ session.turns = label.turns;
566
+ const fallback = label.title ?? label.firstPrompt ?? label.latestPrompt;
567
+ if (fallback !== undefined && fallback.trim() !== '')
568
+ session.title = summarize(fallback);
569
+ }
570
+ for (const session of empty) {
571
+ const at = newest.indexOf(session);
572
+ if (at !== -1)
573
+ newest.splice(at, 1);
574
+ hidden.blank += 1;
575
+ }
576
+ return { workspaces, sessions: newest, hidden };
577
+ };
578
+ /**
579
+ * Apply a persona's injection mode to an assembled prompt.
580
+ *
581
+ * The persona text itself always rides in this plugin's own section, so
582
+ * `replace` only has to drop the deployment's persona line — `append` leaves the
583
+ * assembly untouched. A section already carrying something else (an agent preset
584
+ * or a subagent shadowed it) is never overwritten: another author's persona wins.
585
+ * @param assembly - the `system-prompt/assemble` value.
586
+ * @param options.mode - `append` or `replace`.
587
+ * @param options.deploymentPrefix - the deployment's own persona text, when readable.
588
+ * @returns the assembly to hand on (the input object when nothing changes).
589
+ */
590
+ export const applyPersonaMode = (assembly, { mode, deploymentPrefix }) => {
591
+ if (mode !== 'replace' || assembly === null || typeof assembly !== 'object')
592
+ return assembly;
593
+ const sections = assembly.sections;
594
+ if (!Array.isArray(sections))
595
+ return assembly;
596
+ const next = sections.slice();
597
+ let changed = false;
598
+ // The deployment's own identity ("You are a coding agent powered by …"), but only when
599
+ // it still is that text: a preset's own persona is not ours to remove.
600
+ const prefix = next.findIndex((entry) => entry?.name === PERSONA_PREFIX_SECTION);
601
+ if (prefix !== -1) {
602
+ const current = typeof next[prefix].text === 'string' ? next[prefix].text : '';
603
+ if (deploymentPrefix === undefined || current === deploymentPrefix) {
604
+ next[prefix] = { ...next[prefix], text: '' };
605
+ changed = true;
606
+ }
607
+ }
608
+ // The harness's own identity line as well, so a replace persona is the only identity
609
+ // left. Matched by name, or by its text when the name is not known here.
610
+ const identity = next.findIndex((entry) => entry?.name === HARNESS_IDENTITY_SECTION
611
+ || (typeof entry?.text === 'string' && entry.text.trim().startsWith(HARNESS_IDENTITY_TEXT)));
612
+ if (identity !== -1) {
613
+ next[identity] = { ...next[identity], text: '' };
614
+ changed = true;
615
+ }
616
+ return changed ? { ...assembly, sections: next } : assembly;
617
+ };
618
+ /** Read one event's plain text out of the shapes DSH session logs use. */
619
+ const eventText = (event) => {
620
+ const parts = [];
621
+ const push = (value) => { if (typeof value === 'string' && value !== '')
622
+ parts.push(value); };
623
+ const data = event?.data ?? {};
624
+ const blocks = [data.content, data.message?.content, data.text];
625
+ for (const block of blocks) {
626
+ if (typeof block === 'string')
627
+ push(block);
628
+ else if (Array.isArray(block))
629
+ for (const part of block)
630
+ push(part?.text ?? part?.content);
631
+ }
632
+ return parts.join('\n').trim();
633
+ };
634
+ /**
635
+ * Session ids the user has archived. The registry is asked first; the workspace
636
+ * store file is the fallback, and an unreadable store simply means "none".
637
+ */
638
+ export const collectArchivedSessionIds = (ctx) => {
639
+ const ids = new Set();
640
+ const add = (value) => {
641
+ if (Array.isArray(value))
642
+ for (const id of value)
643
+ if (typeof id === 'string' && id !== '')
644
+ ids.add(id);
645
+ };
646
+ try {
647
+ const registry = ctx.get('workspaceRegistry');
648
+ add(registry?.archivedSessionIds);
649
+ for (const workspace of registry?.list?.() ?? [])
650
+ add(workspace?.archivedSessionIds);
651
+ }
652
+ catch {
653
+ /* fall through to the store */
654
+ }
655
+ if (ids.size === 0) {
656
+ try {
657
+ const store = JSON.parse(readFileSync(join(DSH_HOME, 'storages', 'workspace.json'), 'utf8'));
658
+ const rows = store?.record?.rows ?? store;
659
+ add(rows?.global?.val?.archivedSessionIds ?? rows?.global?.archivedSessionIds);
660
+ const tables = rows?.tables?.val?.workspaces ?? rows?.tables?.workspaces;
661
+ for (const key of Object.keys(tables ?? {}))
662
+ add(tables[key]?.archivedSessionIds);
663
+ }
664
+ catch {
665
+ /* no store on disk: nothing is archived as far as we know */
666
+ }
667
+ }
668
+ return ids;
669
+ };
670
+ /**
671
+ * The last system prompt a session actually sent: read out of its own event log, so it
672
+ * is the assembled text rather than a reconstruction of it.
673
+ * @param ctx - the plugin context.
674
+ * @param id - the session id.
675
+ * @returns the prompt text, or null when the log cannot be read.
676
+ */
677
+ export const collectLastSystemPrompt = async (ctx, id) => {
678
+ const sessionId = typeof id === 'string' ? id : '';
679
+ if (sessionId === '')
680
+ return null;
681
+ const persistence = ctx.get('sessionPersistence');
682
+ if (persistence === undefined || typeof persistence.open !== 'function')
683
+ return null;
684
+ let handle;
685
+ try {
686
+ handle = await persistence.open(sessionId, 'read');
687
+ }
688
+ catch {
689
+ try {
690
+ handle = await persistence.open(sessionId);
691
+ }
692
+ catch {
693
+ return null;
694
+ }
695
+ }
696
+ const window = 2000;
697
+ let cursor = 0;
698
+ let last;
699
+ for (let step = 0; step < 12; step += 1) {
700
+ let events;
701
+ try {
702
+ const page = await handle.read(cursor, window);
703
+ events = Array.isArray(page?.events) ? page.events : [];
704
+ }
705
+ catch {
706
+ return last ?? null;
707
+ }
708
+ for (const event of events) {
709
+ if (String(event?.type ?? event?.kind ?? '') !== 'system/message')
710
+ continue;
711
+ const text = eventText(event).trim();
712
+ if (text !== '')
713
+ last = text;
714
+ }
715
+ if (events.length < window)
716
+ break;
717
+ cursor += events.length;
718
+ }
719
+ return last ?? null;
720
+ };
721
+ /**
722
+ * What the persona view shows for one session: which persona applies, and the system
723
+ * prompt that session last sent.
724
+ * @param ctx - the plugin context.
725
+ * @param id - the session id.
726
+ * @param personas - the stored personas.
727
+ * @returns the view payload.
728
+ */
729
+ export const sessionPromptFrom = async (ctx, id, personas) => {
730
+ const sessionId = typeof id === 'string' ? id : '';
731
+ if (sessionId === '')
732
+ return { id: '', available: false, persona: null, prompt: null, reason: 'none', matchedBy: [] };
733
+ let cwd;
734
+ try {
735
+ const rows = (await ctx.get('sessionPersistence')?.list?.()) ?? [];
736
+ const row = rows.find((item) => (item?.header ?? item)?.id === sessionId);
737
+ const header = row?.header ?? row;
738
+ if (typeof header?.cwd === 'string' && header.cwd !== '')
739
+ cwd = header.cwd;
740
+ }
741
+ catch {
742
+ /* a session without a readable list is still worth showing */
743
+ }
744
+ const picked = resolvePersona(personas ?? [], { cwd, sessionId });
745
+ return {
746
+ id: sessionId,
747
+ available: true,
748
+ cwd: cwd ?? null,
749
+ reason: picked.reason,
750
+ matchedBy: (picked.matchedBy ?? []).map((target) => ({ kind: target.kind, match: target.match, value: target.value })),
751
+ persona: picked.persona === undefined ? null : {
752
+ id: picked.persona.id,
753
+ name: picked.persona.name,
754
+ mode: picked.persona.mode ?? 'append',
755
+ fallback: picked.persona.fallback === true,
756
+ enabled: picked.persona.enabled !== false,
757
+ text: picked.persona.text ?? '',
758
+ targets: (picked.persona.targets ?? []).map((target) => ({ kind: target.kind, match: target.match, value: target.value })),
759
+ },
760
+ prompt: await collectLastSystemPrompt(ctx, sessionId),
761
+ };
762
+ };
763
+ /**
764
+ * The options a history call carries, in one place: the runtime used to forward a
765
+ * hand-written subset, so a new option (tail/keep) silently never reached the
766
+ * reader and 跳到最新 quietly read the beginning instead.
767
+ * @param input - the remote call's payload.
768
+ * @returns the reader options.
769
+ */
770
+ export const historyOptions = (input) => ({
771
+ offset: input?.offset,
772
+ events: input?.events,
773
+ maxChars: input?.maxChars,
774
+ tail: input?.tail === true,
775
+ keep: input?.keep,
776
+ });
777
+ /**
778
+ * Read one window of a session's conversation.
779
+ *
780
+ * Storage is addressed through per-session handles and the backend is a
781
+ * zstd-compressed append-only log, so a read decompresses from the start: asking
782
+ * for many small pages costs far more than a few large ones. The contract is
783
+ * therefore one generous window plus `nextOffset`, and the page only fetches
784
+ * another window when the user asks for more. Each message is capped so a single
785
+ * huge tool output cannot blow up the payload or the DOM.
786
+ * @param ctx - host context.
787
+ * @param id - session id.
788
+ * @param options.offset - first event index to read (default 0).
789
+ * @param options.events - how many events to read in this window (default 2000, max 4000).
790
+ * @param options.maxChars - per-message text cap (default 1000).
791
+ * @returns a page: `{ messages, offset, nextOffset, done, eventCount, unavailable }`.
792
+ */
793
+ export const collectSessionHistory = async (ctx, id, options = {}) => {
794
+ const sessionId = typeof id === 'string' ? id : '';
795
+ if (sessionId === '')
796
+ return { id: '', available: false, unavailable: true, messages: [] };
797
+ const window = Number.isFinite(options?.events) ? Math.max(1, Math.min(4000, Math.trunc(options.events))) : 2000;
798
+ // `tail`: walk to the end and hand back only the last exchange, so the caller can
799
+ // show "what were we just talking about" without shipping the whole history.
800
+ if (options?.tail === true) {
801
+ const keep = Number.isFinite(options?.keep) ? Math.max(1, Math.min(20, Math.trunc(options.keep))) : 4;
802
+ let cursor = 0;
803
+ let last = [];
804
+ let total = 0;
805
+ let scanned = 0;
806
+ let page;
807
+ for (let step = 0; step < 12; step += 1) {
808
+ page = await collectSessionHistory(ctx, sessionId, { offset: cursor, events: window, maxChars: options?.maxChars });
809
+ if (page.available === false)
810
+ return page;
811
+ last = [...last, ...page.messages];
812
+ // Keep enough history that the last *user* input survives: an agent turn can
813
+ // emit several assistant messages, which used to push the question out of a
814
+ // fixed four-message window.
815
+ if (last.length > 60)
816
+ last = last.slice(-60);
817
+ total += page.messages.length;
818
+ scanned += page.eventCount ?? 0;
819
+ if (page.done === true || page.nextOffset === null)
820
+ break;
821
+ cursor = page.nextOffset;
822
+ }
823
+ const lastUser = last.map((message) => message.role).lastIndexOf('user');
824
+ const exchange = lastUser === -1 ? last.slice(-keep) : last.slice(lastUser);
825
+ return {
826
+ id: sessionId,
827
+ available: true,
828
+ unavailable: false,
829
+ cwd: page?.cwd ?? null,
830
+ createdAt: page?.createdAt ?? null,
831
+ tail: true,
832
+ done: true,
833
+ offset: cursor,
834
+ nextOffset: null,
835
+ eventCount: page?.eventCount ?? 0,
836
+ scanned,
837
+ total,
838
+ messages: exchange,
839
+ };
840
+ }
841
+ const offset = Number.isFinite(options?.offset) ? Math.max(0, Math.trunc(options.offset)) : 0;
842
+ const maxChars = Number.isFinite(options?.maxChars) ? Math.max(200, Math.min(40000, Math.trunc(options.maxChars))) : 1000;
843
+ let handle;
844
+ let persistence;
845
+ try {
846
+ persistence = ctx.get('sessionPersistence');
847
+ }
848
+ catch {
849
+ persistence = undefined;
850
+ }
851
+ for (const name of ['open', 'openSession', 'handle', 'read', 'load', 'get']) {
852
+ const reader = persistence?.[name];
853
+ if (typeof reader !== 'function')
854
+ continue;
855
+ // `open(id, access)`: the access mode is required, and `'read'` never takes
856
+ // write ownership. Older/newer signatures take just the id, hence the retry.
857
+ for (const args of [[sessionId, 'read'], [sessionId]]) {
858
+ try {
859
+ handle = await reader.call(persistence, ...args);
860
+ }
861
+ catch {
862
+ handle = undefined;
863
+ }
864
+ if (handle !== undefined && typeof handle === 'object')
865
+ break;
866
+ handle = undefined;
867
+ }
868
+ if (handle !== undefined)
869
+ break;
870
+ }
871
+ if (handle === undefined) {
872
+ try {
873
+ const live = ctx.get('sessions')?.get?.(sessionId);
874
+ if (live !== undefined)
875
+ handle = { header: live.header, events: live.events ?? [] };
876
+ }
877
+ catch {
878
+ /* no live session either */
879
+ }
880
+ }
881
+ if (handle === undefined) {
882
+ return { id: sessionId, available: false, unavailable: true, messages: [], offset, nextOffset: null, done: true };
883
+ }
884
+ let events = [];
885
+ let more = false;
886
+ if (typeof handle.read === 'function') {
887
+ try {
888
+ const page = await handle.read(offset, window);
889
+ events = Array.isArray(page?.events) ? page.events : Array.isArray(page) ? page : [];
890
+ const total = typeof page?.eventCount === 'number' ? page.eventCount : typeof page?.total === 'number' ? page.total : undefined;
891
+ more = total === undefined ? events.length >= window : offset + events.length < total;
892
+ }
893
+ catch {
894
+ events = [];
895
+ }
896
+ }
897
+ else if (Array.isArray(handle.events)) {
898
+ events = handle.events.slice(offset, offset + window);
899
+ more = offset + events.length < handle.events.length;
900
+ }
901
+ const messages = [];
902
+ // Only what the user actually typed. Everything else that arrives on the user
903
+ // side is injected by plugins — workspace instructions (13k chars in a real
904
+ // session), runtime-context snapshots, the skill catalog, goal rounds — and
905
+ // showing it would flood the view with text the user never wrote. Events that
906
+ // carry no `source` at all are kept, so a future DSH that stops labelling them
907
+ // does not hide the whole conversation.
908
+ let skipped = 0;
909
+ let eventIndex = -1;
910
+ for (const event of events) {
911
+ eventIndex += 1;
912
+ const kind = String(event?.type ?? event?.kind ?? '');
913
+ if (kind !== 'user/message' && kind !== 'assistant/message')
914
+ continue;
915
+ if (kind === 'user/message') {
916
+ const source = event?.data?.source ?? event?.source;
917
+ const sourceKind = typeof source?.kind === 'string' ? source.kind : undefined;
918
+ if (sourceKind !== undefined && sourceKind !== 'user') {
919
+ skipped += 1;
920
+ continue;
921
+ }
922
+ }
923
+ const text = eventText(event);
924
+ if (text === '')
925
+ continue;
926
+ const clipped = text.length > maxChars;
927
+ messages.push({
928
+ role: kind === 'user/message' ? 'user' : 'assistant',
929
+ // Where this message lives, so one message can be re-read in full later
930
+ // (`sessionHistory({ offset: at, events: 1, maxChars: <big> })`).
931
+ at: offset + eventIndex,
932
+ text: clipped ? clipMarkdown(text, maxChars) : text,
933
+ ...(clipped ? { truncated: true } : {}),
934
+ });
935
+ }
936
+ const header = handle.header ?? {};
937
+ return {
938
+ id: sessionId,
939
+ available: true,
940
+ unavailable: false,
941
+ cwd: typeof header.cwd === 'string' ? header.cwd : null,
942
+ createdAt: typeof header.createdAt === 'number' ? header.createdAt : null,
943
+ offset,
944
+ nextOffset: more ? offset + events.length : null,
945
+ done: !more,
946
+ eventCount: events.length,
947
+ total: messages.length,
948
+ skipped,
949
+ messages,
950
+ };
951
+ };
952
+ /** `agentPersona` — the namespace the settings page talks to. */
953
+ class WorkspacePersonaService extends TypertRemoteService {
954
+ constructor(ctx, runtime) {
955
+ super(ctx, 'agentPersona');
956
+ for (const initializer of MARKER_INITIALIZERS)
957
+ initializer.call(this);
958
+ this.runtime = runtime;
959
+ }
960
+ /** Everything the settings page renders. */
961
+ async listPersonas() {
962
+ try {
963
+ return this.runtime.view();
964
+ }
965
+ catch (error) {
966
+ return { error: `listPersonas failed: ${String((error && error.stack) || error)}` };
967
+ }
968
+ }
969
+ /** Merge one or more interface preferences and return the refreshed view. */
970
+ async savePrefs(input) {
971
+ try {
972
+ return this.runtime.savePrefs(input);
973
+ }
974
+ catch (error) {
975
+ return { error: `savePrefs failed: ${String((error && error.stack) || error)}` };
976
+ }
977
+ }
978
+ /** Create (no `id`) or update (with `id`) one persona. */
979
+ async savePersona(input) {
980
+ try {
981
+ return this.runtime.save(input);
982
+ }
983
+ catch (error) {
984
+ return { error: `savePersona failed: ${String((error && error.stack) || error)}` };
985
+ }
986
+ }
987
+ /** Remove one persona. */
988
+ async deletePersona(input) {
989
+ try {
990
+ return this.runtime.remove(input);
991
+ }
992
+ catch (error) {
993
+ return { error: `deletePersona failed: ${String((error && error.stack) || error)}` };
994
+ }
995
+ }
996
+ /** Move one persona up/down: the list order IS the priority order. */
997
+ async movePersona(input) {
998
+ try {
999
+ return this.runtime.move(input);
1000
+ }
1001
+ catch (error) {
1002
+ return { error: `movePersona failed: ${String((error && error.stack) || error)}` };
1003
+ }
1004
+ }
1005
+ /** Copy one persona (created disabled, so a copy changes nothing). */
1006
+ async duplicatePersona(input) {
1007
+ try {
1008
+ return this.runtime.duplicate(input);
1009
+ }
1010
+ catch (error) {
1011
+ return { error: `duplicatePersona failed: ${String((error && error.stack) || error)}` };
1012
+ }
1013
+ }
1014
+ /** Explain which persona a given cwd/session would get, and why. */
1015
+ /** The persona view: which persona a session uses, and the prompt it last sent. */
1016
+ async sessionPrompt(input) {
1017
+ try {
1018
+ return await this.runtime.sessionPrompt(input);
1019
+ }
1020
+ catch (error) {
1021
+ return { error: `sessionPrompt failed: ${String((error && error.stack) || error)}` };
1022
+ }
1023
+ }
1024
+ async sessionHistory(input) {
1025
+ try {
1026
+ return await this.runtime.history(input);
1027
+ }
1028
+ catch (error) {
1029
+ return { error: `sessionHistory failed: ${String((error && error.stack) || error)}` };
1030
+ }
1031
+ }
1032
+ async reorderPersona(input) {
1033
+ try {
1034
+ return this.runtime.reorder(input);
1035
+ }
1036
+ catch (error) {
1037
+ return { error: `reorderPersona failed: ${String((error && error.stack) || error)}` };
1038
+ }
1039
+ }
1040
+ async listTargets() {
1041
+ try {
1042
+ return await this.runtime.targets();
1043
+ }
1044
+ catch (error) {
1045
+ return { error: `listTargets failed: ${String((error && error.stack) || error)}` };
1046
+ }
1047
+ }
1048
+ /** Rewrite a persona with the configured model; returns a proposal, saves nothing. */
1049
+ async tunePersona(input) {
1050
+ try {
1051
+ return await this.runtime.tune(input);
1052
+ }
1053
+ catch (error) {
1054
+ return { error: `tunePersona failed: ${String((error && error.stack) || error)}` };
1055
+ }
1056
+ }
1057
+ /** Models the tuning panel may target (host model catalog, best effort). */
1058
+ async listModels() {
1059
+ try {
1060
+ return await this.runtime.models();
1061
+ }
1062
+ catch (error) {
1063
+ return { error: `listModels failed: ${String((error && error.stack) || error)}`, models: [] };
1064
+ }
1065
+ }
1066
+ }
1067
+ const MARKER_INITIALIZERS = [];
1068
+ /** Attach `@Remote` markers without decorator syntax (see AGENTS.md §11). */
1069
+ const markRemote = (target, methods) => {
1070
+ for (const method of methods) {
1071
+ Remote(target.prototype[method], {
1072
+ kind: 'method',
1073
+ name: method,
1074
+ static: false,
1075
+ private: false,
1076
+ addInitializer: (initializer) => MARKER_INITIALIZERS.push(initializer),
1077
+ });
1078
+ }
1079
+ };
1080
+ markRemote(WorkspacePersonaService, [...RPC_ENDPOINTS]);
1081
+ /** The Connection RPC channel the browser half calls into (see the plugin standard). */
1082
+ // ─────────────────────────────────────────────────────────── plugin
1083
+ /** Mount the prompt section, the store-backed service, and the load heartbeat. */
1084
+ export function apply(ctx, config = {}) {
1085
+ const logger = ctx.logger ?? console;
1086
+ const warn = (message, error) => logger.warn?.(`[agent-persona] ${message}${error === undefined ? '' : `: ${String(error)}`}`);
1087
+ const info = (message) => logger.info?.(`[agent-persona] ${message}`);
1088
+ // Where this instance keeps its data. `storePath` in the row config overrides
1089
+ // the default (`$DSH_HOME/dsh-agent-persona/personas.json`).
1090
+ const storePath = typeof config?.storePath === 'string' && config.storePath !== ''
1091
+ ? resolve(config.storePath)
1092
+ : STORE_FILE;
1093
+ try {
1094
+ mkdirSync(dirname(storePath), { recursive: true });
1095
+ }
1096
+ catch (error) {
1097
+ warn(`could not create ${dirname(storePath)}`, error);
1098
+ }
1099
+ // Re-read the file whenever its stamp changes, so an edit applies to the next
1100
+ // request even from an instance that is already running.
1101
+ let store = loadStore(storePath, warn);
1102
+ let storeStamp = stampOf();
1103
+ function stampOf() {
1104
+ try {
1105
+ const stat = statSync(storePath, { bigint: true });
1106
+ return `${stat.mtimeNs}:${stat.size}`;
1107
+ }
1108
+ catch {
1109
+ return 'missing';
1110
+ }
1111
+ }
1112
+ const reload = () => {
1113
+ const stamp = stampOf();
1114
+ if (stamp !== storeStamp) {
1115
+ store = loadStore(storePath, warn);
1116
+ storeStamp = stamp;
1117
+ }
1118
+ return store;
1119
+ };
1120
+ const currentStore = () => (storeStamp === stampOf() ? store : reload());
1121
+ // ── model resolution for AI tuning: never bake a vendor default into the code.
1122
+ // Explicit UI choice wins; then the plugin row's config; then the host's own
1123
+ // `agentDefaultModel` service (the same selection new Agents get).
1124
+ const configuredProvider = typeof config?.tuneProvider === 'string' && config.tuneProvider !== '' ? config.tuneProvider : undefined;
1125
+ const configuredModel = typeof config?.tuneModel === 'string' && config.tuneModel !== '' ? config.tuneModel : undefined;
1126
+ const hostDefaultModel = () => {
1127
+ try {
1128
+ const selection = ctx.get('agentDefaultModel')?.currentSelection?.();
1129
+ if (typeof selection?.provider === 'string' && typeof selection?.model === 'string') {
1130
+ return { provider: selection.provider, model: selection.model };
1131
+ }
1132
+ }
1133
+ catch {
1134
+ /* service not mounted in this deployment */
1135
+ }
1136
+ return undefined;
1137
+ };
1138
+ const pickModel = (input) => {
1139
+ if (typeof input?.provider === 'string' && input.provider !== '' && typeof input?.model === 'string' && input.model !== '') {
1140
+ return { provider: input.provider, model: input.model, source: 'ui' };
1141
+ }
1142
+ if (configuredModel !== undefined) {
1143
+ const provider = configuredProvider ?? hostDefaultModel()?.provider;
1144
+ if (provider !== undefined)
1145
+ return { provider, model: configuredModel, source: 'config' };
1146
+ }
1147
+ const host = hostDefaultModel();
1148
+ return host === undefined ? undefined : { ...host, source: 'host-default' };
1149
+ };
1150
+ // `replace` mode: after assembly, drop the deployment's own persona line so
1151
+ // the persona in our section stands alone. Another author's persona (an agent
1152
+ // preset or subagent that shadowed the section) is never overwritten.
1153
+ const deploymentPrefix = () => {
1154
+ for (const read of [
1155
+ () => ctx.systemPrompt?.config?.personaPrefix,
1156
+ () => ctx.systemPrompt?.ctx?.config?.personaPrefix,
1157
+ () => ctx.get('systemPrompt')?.config?.personaPrefix,
1158
+ ]) {
1159
+ try {
1160
+ const value = read();
1161
+ if (typeof value === 'string')
1162
+ return value;
1163
+ }
1164
+ catch {
1165
+ /* try the next accessor */
1166
+ }
1167
+ }
1168
+ return undefined;
1169
+ };
1170
+ let replaceGuardWarned = false;
1171
+ ctx.on('system-prompt/assemble', async (assembly, context, next) => {
1172
+ try {
1173
+ // Waterfall rule: every listener calls next() and wraps its result. Skipping it
1174
+ // would veto the whole downstream chain, which is not what a persona does.
1175
+ const downstream = await next();
1176
+ const persona = resolvePersona(currentStore().personas, subjectOf(context?.agent)).persona;
1177
+ if (persona === undefined || persona.mode !== 'replace')
1178
+ return downstream;
1179
+ const own = deploymentPrefix();
1180
+ if (own === undefined && !replaceGuardWarned) {
1181
+ replaceGuardWarned = true;
1182
+ warn('cannot read the deployment persona; replace mode will drop the section as-is');
1183
+ }
1184
+ return applyPersonaMode(downstream, { mode: 'replace', deploymentPrefix: own });
1185
+ }
1186
+ catch (error) {
1187
+ warn('could not apply the replace mode', error);
1188
+ return next();
1189
+ }
1190
+ });
1191
+ const order = ctx.systemPrompt.getSectionOrder('DEPLOYMENT_PERSONA_PREFIX') + 1;
1192
+ try {
1193
+ ctx.effect(() => ctx.systemPrompt.section({
1194
+ name: SECTION_NAME,
1195
+ order,
1196
+ text: (assembly) => personaTextFor(currentStore().personas, assembly?.agent, warn),
1197
+ }), 'agent-persona.section');
1198
+ info(`section "${SECTION_NAME}" registered at order ${order} (store: ${storePath})`);
1199
+ }
1200
+ catch (error) {
1201
+ warn(`section "${SECTION_NAME}" is already registered; keeping the live one`, error);
1202
+ }
1203
+ /** Target decorated with the flags the page needs to explain itself. */
1204
+ const describeTarget = (target) => ({
1205
+ ...target,
1206
+ invalid: target.value.trim() === '' || (target.match === 'regex' && compile(target.value) === undefined),
1207
+ });
1208
+ const view = () => {
1209
+ const current = reload();
1210
+ const personas = current.personas.map((persona, index) => ({
1211
+ ...clone(persona),
1212
+ index,
1213
+ chars: persona.text.length,
1214
+ targets: persona.targets.map(describeTarget),
1215
+ noTargets: persona.targets.length === 0,
1216
+ fallback: persona.fallback === true,
1217
+ }));
1218
+ return {
1219
+ version: STORE_VERSION,
1220
+ storePath,
1221
+ prefs: normalizePrefs(reload().prefs),
1222
+ sectionName: SECTION_NAME,
1223
+ sectionOrder: order,
1224
+ allowedVariables: [...ALLOWED_VARIABLES],
1225
+ personaModes: PERSONA_MODES,
1226
+ targetKinds: TARGET_KINDS,
1227
+ targetMatches: TARGET_MATCHES,
1228
+ tuneModes: [...TUNE_MODES],
1229
+ counts: {
1230
+ total: personas.length,
1231
+ enabled: personas.filter((persona) => persona.enabled).length,
1232
+ configured: personas.filter((persona) => persona.enabled && persona.text.trim() !== '').length,
1233
+ },
1234
+ personas,
1235
+ };
1236
+ };
1237
+ const normalizeInput = (input) => {
1238
+ const text = typeof input?.text === 'string' ? input.text : '';
1239
+ if (Buffer.byteLength(text, 'utf8') > MAX_TEXT_BYTES)
1240
+ throw new Error(`人设文本过长(上限 ${MAX_TEXT_BYTES} 字节)`);
1241
+ const targets = (Array.isArray(input?.targets) ? input.targets : [])
1242
+ .map(normalizeTarget)
1243
+ .filter((target) => target.value.trim() !== '');
1244
+ return {
1245
+ id: typeof input?.id === 'string' && input.id !== '' ? input.id : undefined,
1246
+ name: typeof input?.name === 'string' && input.name.trim() !== '' ? input.name.trim() : '未命名人设',
1247
+ enabled: input?.enabled !== false,
1248
+ // 默认人设:没有位置命中时使用它(优先级最低的保底),同一时刻只允许一条。
1249
+ fallback: input?.fallback === true,
1250
+ mode: input?.mode === 'replace' ? 'replace' : 'append',
1251
+ text,
1252
+ targets,
1253
+ };
1254
+ };
1255
+ const runtime = {
1256
+ view,
1257
+ /** Merge preferences into the store; every field is optional so one switch can be saved. */
1258
+ savePrefs(input) {
1259
+ const current = reload();
1260
+ current.prefs = normalizePrefs({ ...current.prefs, ...(input ?? {}) });
1261
+ persist(current, storePath);
1262
+ info(`preferences saved: ${JSON.stringify(current.prefs)}`);
1263
+ return view();
1264
+ },
1265
+ save(input) {
1266
+ const next = normalizeInput(input);
1267
+ const current = reload();
1268
+ let persona;
1269
+ if (next.id === undefined) {
1270
+ persona = { id: newId(), name: next.name, enabled: next.enabled, fallback: next.fallback, text: next.text, mode: next.mode, targets: next.targets };
1271
+ current.personas.push(persona);
1272
+ info(`created persona "${next.name}" (${current.personas.length} total)`);
1273
+ }
1274
+ else {
1275
+ persona = current.personas.find((item) => item.id === next.id);
1276
+ if (persona === undefined)
1277
+ throw new Error('人设不存在(可能已被删除)');
1278
+ persona.name = next.name;
1279
+ persona.enabled = next.enabled;
1280
+ // A payload that omits the key keeps whatever is stored: a partial update must not silently
1281
+ // clear a flag it never mentioned (that is how the default persona kept turning itself off).
1282
+ const mentionsFallback = input !== null && typeof input === 'object' && 'fallback' in input;
1283
+ persona.fallback = mentionsFallback ? next.fallback : persona.fallback;
1284
+ persona.text = next.text;
1285
+ persona.mode = next.mode;
1286
+ persona.targets = next.targets;
1287
+ info(`saved persona "${next.name}"`);
1288
+ }
1289
+ const moved = persona.enabled ? takeOverClaims(current, persona) : [];
1290
+ // Only one persona can be the default: claiming it releases whoever held it.
1291
+ const released = [];
1292
+ if (persona.fallback === true) {
1293
+ for (const other of current.personas) {
1294
+ if (other === persona || other.fallback !== true)
1295
+ continue;
1296
+ other.fallback = false;
1297
+ released.push(other.name);
1298
+ }
1299
+ }
1300
+ persist(current, storePath);
1301
+ const result = view();
1302
+ if (released.length > 0) {
1303
+ result.notice = `「${persona.name}」现在是默认人设;「${released.join('」「')}」已取消默认`;
1304
+ info(`default persona moved from: ${released.join(', ')}`);
1305
+ return result;
1306
+ }
1307
+ if (moved.length > 0) {
1308
+ result.notice = `这些位置原来属于「${moved.join('」「')}」,已转给这条人设`;
1309
+ info(`took over claims from: ${moved.join(', ')}`);
1310
+ }
1311
+ return result;
1312
+ },
1313
+ remove(input) {
1314
+ const current = reload();
1315
+ const id = typeof input?.id === 'string' ? input.id : '';
1316
+ const before = current.personas.length;
1317
+ current.personas = current.personas.filter((persona) => persona.id !== id);
1318
+ if (current.personas.length === before)
1319
+ throw new Error('人设不存在(可能已被删除)');
1320
+ persist(current, storePath);
1321
+ info(`deleted persona ${id}`);
1322
+ return view();
1323
+ },
1324
+ move(input) {
1325
+ const current = reload();
1326
+ const id = typeof input?.id === 'string' ? input.id : '';
1327
+ const delta = input?.delta === 1 ? 1 : input?.delta === -1 ? -1 : 0;
1328
+ const from = current.personas.findIndex((persona) => persona.id === id);
1329
+ if (from < 0)
1330
+ throw new Error('人设不存在(可能已被删除)');
1331
+ const to = Math.min(current.personas.length - 1, Math.max(0, from + delta));
1332
+ if (to !== from) {
1333
+ const [moved] = current.personas.splice(from, 1);
1334
+ current.personas.splice(to, 0, moved);
1335
+ persist(current, storePath);
1336
+ }
1337
+ return view();
1338
+ },
1339
+ duplicate(input) {
1340
+ const current = reload();
1341
+ const id = typeof input?.id === 'string' ? input.id : '';
1342
+ const from = current.personas.findIndex((persona) => persona.id === id);
1343
+ if (from < 0)
1344
+ throw new Error('人设不存在(可能已被删除)');
1345
+ const source = current.personas[from];
1346
+ // Copies start disabled: adding one must never change what any session gets.
1347
+ current.personas.splice(from + 1, 0, {
1348
+ id: newId(),
1349
+ name: `${source.name} 副本`,
1350
+ enabled: false,
1351
+ text: source.text,
1352
+ targets: clone(source.targets),
1353
+ });
1354
+ persist(current, storePath);
1355
+ info(`duplicated persona "${source.name}" (copy starts disabled)`);
1356
+ return view();
1357
+ },
1358
+ /** Drop a persona at an absolute position (what a drag-and-drop emits). */
1359
+ reorder(input) {
1360
+ const id = typeof input?.id === 'string' ? input.id : '';
1361
+ const toIndex = Number.isFinite(input?.toIndex) ? Math.trunc(input.toIndex) : -1;
1362
+ const current = reload();
1363
+ if (!current.personas.some((persona) => persona.id === id))
1364
+ throw new Error('人设不存在(可能已被删除)');
1365
+ if (reorderPersonas(current, id, toIndex)) {
1366
+ persist(current, storePath);
1367
+ info(`moved persona "${id}" to position ${Math.max(0, Math.min(current.personas.length - 1, toIndex)) + 1}`);
1368
+ }
1369
+ return view();
1370
+ },
1371
+ async targets() {
1372
+ return collectTargets(ctx, { personas: currentStore().personas, warn });
1373
+ },
1374
+ async sessionPrompt(input) {
1375
+ return sessionPromptFrom(ctx, input?.id, currentStore().personas);
1376
+ },
1377
+ async history(input) {
1378
+ return collectSessionHistory(ctx, input?.id, historyOptions(input));
1379
+ },
1380
+ async models() {
1381
+ const models = [];
1382
+ try {
1383
+ const catalog = ctx.get('modelCatalog');
1384
+ const groups = (await catalog?.list?.()) ?? catalog?.groups ?? [];
1385
+ for (const group of groups) {
1386
+ for (const model of group?.models ?? []) {
1387
+ models.push({ provider: group.id ?? 'unknown', model: model.id, name: model.name ?? model.id });
1388
+ }
1389
+ }
1390
+ }
1391
+ catch (error) {
1392
+ warn('model catalog unavailable; falling back to the host default model', error);
1393
+ }
1394
+ const host = hostDefaultModel();
1395
+ if (models.length === 0 && host !== undefined)
1396
+ models.push({ provider: host.provider, model: host.model, name: host.model });
1397
+ if (models.length === 0 && configuredModel !== undefined) {
1398
+ models.push({ provider: configuredProvider ?? 'unknown', model: configuredModel, name: configuredModel });
1399
+ }
1400
+ return {
1401
+ models,
1402
+ hostDefault: host ?? null,
1403
+ configured: configuredModel === undefined ? null : { provider: configuredProvider ?? null, model: configuredModel },
1404
+ };
1405
+ },
1406
+ async tune(input) {
1407
+ const mode = TUNE_MODES.has(input?.mode) ? input.mode : 'polish';
1408
+ const text = typeof input?.text === 'string' ? input.text : '';
1409
+ const instruction = typeof input?.instruction === 'string' ? input.instruction.trim() : '';
1410
+ if (text.trim() === '' && mode !== 'draft')
1411
+ throw new Error('请先写一点人设内容再让 AI 调优(或改用「按描述生成」)');
1412
+ if (mode === 'draft' && instruction === '')
1413
+ throw new Error('「按描述生成」需要填写要求');
1414
+ let llm;
1415
+ try {
1416
+ llm = ctx.get('llm');
1417
+ }
1418
+ catch {
1419
+ llm = undefined;
1420
+ }
1421
+ if (llm?.stream === undefined)
1422
+ throw new Error('宿主未提供 llm 服务,无法调用模型');
1423
+ const route = pickModel(input);
1424
+ if (route === undefined) {
1425
+ throw new Error('没有可用模型:请在设置页选择模型,或在 profile 里给 agent-persona 这一行配置 tuneProvider / tuneModel');
1426
+ }
1427
+ const provider = route.provider;
1428
+ const model = route.model;
1429
+ const guide = {
1430
+ polish: '润色现有文本:保持信息与意图不变,去掉冗余、统一语气、让约束更可执行。',
1431
+ expand: '补充现有文本:在保持原意的前提下补齐缺失的边界、工作方式与验收要求,可适度增加结构。',
1432
+ compress: '精简现有文本:只保留最有约束力的条款,删除重复与空话,目标长度不超过原文的一半。',
1433
+ draft: '按用户描述从零起草一份人设,结构清晰、约束可执行。',
1434
+ }[mode];
1435
+ const user = [
1436
+ `任务:${guide}`,
1437
+ text.trim() === '' ? '当前人设:<空>' : `当前人设:\n"""\n${text}\n"""`,
1438
+ instruction === '' ? '' : `用户额外要求:${instruction}`,
1439
+ '硬性要求:只输出改写后的 Markdown 正文;不要解释、不要前言、不要用代码块包裹;'
1440
+ + '不要出现 {{...}} 这类模板变量;用第二人称对"你"下指令;控制在 400–1200 字。',
1441
+ ].filter((line) => line !== '').join('\n\n');
1442
+ let out = '';
1443
+ for await (const chunk of llm.stream({
1444
+ provider,
1445
+ model,
1446
+ system: '你是 DeepSeek Harness(DSH)的 system prompt 人设编辑专家,擅长把模糊的要求写成可执行的 agent 行为约束。',
1447
+ messages: [{ role: 'user', content: [{ type: 'text', text: user }] }],
1448
+ temperature: 0.4,
1449
+ })) {
1450
+ if (chunk?.type === 'text-delta' && typeof chunk.text === 'string')
1451
+ out += chunk.text;
1452
+ }
1453
+ const cleaned = out.replace(/^\s*```[a-zA-Z]*\s*\n?/, '').replace(/\n?```\s*$/, '').trim();
1454
+ if (cleaned === '')
1455
+ throw new Error('模型没有返回内容,稍后再试');
1456
+ return { text: cleaned, mode, provider, model, source: route.source };
1457
+ },
1458
+ };
1459
+ try {
1460
+ new WorkspacePersonaService(ctx, runtime);
1461
+ info('remote namespace "agentPersona" registered (v2: multi-persona)');
1462
+ }
1463
+ catch (error) {
1464
+ warn('could not register the agentPersona service', error);
1465
+ }
1466
+ try {
1467
+ mkdirSync(dirname(HEARTBEAT_FILE), { recursive: true });
1468
+ writeFileSync(HEARTBEAT_FILE, `${JSON.stringify({
1469
+ loadedAt: new Date().toISOString(),
1470
+ loadedModule: new URL(import.meta.url).pathname,
1471
+ storePath,
1472
+ storeVersion: store.version,
1473
+ personas: store.personas.length,
1474
+ sectionName: SECTION_NAME,
1475
+ sectionOrder: order,
1476
+ }, null, 2)}\n`, { mode: 0o600 });
1477
+ }
1478
+ catch (error) {
1479
+ warn('could not write the load heartbeat', error);
1480
+ }
1481
+ }