dsh-custom-mode 2.2.6 → 2.3.1
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 +55 -22
- package/README.zh.md +55 -22
- package/client.js +508 -40
- package/composition.mjs +22 -5
- package/index.mjs +171 -36
- package/locales.mjs +88 -2
- package/package.json +2 -2
- package/preset-backend/declarative.mjs +38 -15
- package/preset-backend/parse-composition.mjs +51 -3
package/composition.mjs
CHANGED
|
@@ -24,6 +24,18 @@
|
|
|
24
24
|
|
|
25
25
|
import { readFileSync, existsSync, readdirSync } from 'node:fs'
|
|
26
26
|
import { join } from 'node:path'
|
|
27
|
+
import { AsyncLocalStorage } from 'node:async_hooks'
|
|
28
|
+
import { decodeJsExpression, evaluateDisabledPredicate } from './preset-backend/parse-composition.mjs'
|
|
29
|
+
|
|
30
|
+
// Capabilities belong to the request that observed them, never to a module-global
|
|
31
|
+
// host context. Store only known booleans, not service objects or arbitrary ctx access.
|
|
32
|
+
const predicateCapabilities = new AsyncLocalStorage()
|
|
33
|
+
export function withPredicateCapabilities(snapshot, work) {
|
|
34
|
+
const capabilities = typeof snapshot?.profileContext === 'boolean'
|
|
35
|
+
? Object.freeze({ profileContext: snapshot.profileContext })
|
|
36
|
+
: Object.freeze({})
|
|
37
|
+
return predicateCapabilities.run(capabilities, work)
|
|
38
|
+
}
|
|
27
39
|
|
|
28
40
|
// 出厂组成「从哪来」是两条 dsh 线唯一还没收进后端的一处:旧线是文件,0.1.7 是
|
|
29
41
|
// agentPresets.readDocument()。解析链整个搬到了 base-composition.mjs(含它的理由与实测),
|
|
@@ -460,7 +472,7 @@ function setDisabled(segmentText, disabled) {
|
|
|
460
472
|
const shipped = lines[index].trim().replace(/^disabled:\s*/, '')
|
|
461
473
|
if (disabled) {
|
|
462
474
|
lines[index] = `${' '.repeat(indent)}disabled: true${carriage}`
|
|
463
|
-
} else if (shipped.startsWith('!!js') && evalDisabledExpression(shipped)
|
|
475
|
+
} else if (shipped.startsWith('!!js') && evalDisabledExpression(shipped) === false) {
|
|
464
476
|
// 显式打开,但出厂那行是平台表达式、且它在本机求值就是"开":这不是覆盖,而是**撤销覆盖** ——
|
|
465
477
|
// 保持出厂表达式原样,这一行就回到"跟随平台"。(原先这里删掉整行,结果是文件既不是出厂原样、
|
|
466
478
|
// 也不是显式覆盖,页面却显示"未拨过" —— 文件与显示同时失真。)
|
|
@@ -496,9 +508,8 @@ function setDisabled(segmentText, disabled) {
|
|
|
496
508
|
function evalDisabledExpression(expression) {
|
|
497
509
|
if (typeof expression !== 'string' || !expression.startsWith('!!js')) return undefined
|
|
498
510
|
try {
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
return fn(process) === true
|
|
511
|
+
const source = decodeJsExpression(expression.slice(4))
|
|
512
|
+
return evaluateDisabledPredicate(source, predicateCapabilities.getStore())
|
|
502
513
|
} catch {
|
|
503
514
|
// 求值失败与「表达式不存在」都是 undefined。调用方若已经确认这是 `!!js`,
|
|
504
515
|
// 必须把 undefined 当成关闭:挂载侧同样按关闭处理,两边不能一个开一个关。
|
|
@@ -1020,13 +1031,19 @@ export function overridesOf(text, modeId) {
|
|
|
1020
1031
|
// 某个字面量一致 —— 只看求值结果,会把"表达式被换成字面量"误判成"没动过"。
|
|
1021
1032
|
const sameForm = baseRow !== undefined
|
|
1022
1033
|
&& baseRow.disabled === row.disabled
|
|
1023
|
-
&& baseRow.disabledExpression === row.disabledExpression
|
|
1034
|
+
&& predicateForm(baseRow.disabledExpression) === predicateForm(row.disabledExpression)
|
|
1024
1035
|
if (sameForm) continue
|
|
1025
1036
|
overrides[id] = row.disabled !== true
|
|
1026
1037
|
}
|
|
1027
1038
|
return overrides
|
|
1028
1039
|
}
|
|
1029
1040
|
|
|
1041
|
+
/** YAML quoting is transport syntax, while literal versus expression remains significant. */
|
|
1042
|
+
function predicateForm(expression) {
|
|
1043
|
+
if (typeof expression !== 'string') return expression
|
|
1044
|
+
try { return decodeJsExpression(expression.slice(4)) } catch { return expression }
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1030
1047
|
/**
|
|
1031
1048
|
* 按允许名单生成显式开关。
|
|
1032
1049
|
*
|
package/index.mjs
CHANGED
|
@@ -55,7 +55,8 @@ import {
|
|
|
55
55
|
effectiveRosterRows,
|
|
56
56
|
} from './preset-backend/index.mjs'
|
|
57
57
|
import { existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
|
58
|
-
import {
|
|
58
|
+
import { createHash } from 'node:crypto'
|
|
59
|
+
import { basename, dirname, join } from 'node:path'
|
|
59
60
|
import { PROMPT_PATH, COMPOSITION_PATH, ROUTE_PATH, PRESET_DIR, dshHome } from './paths.mjs'
|
|
60
61
|
import { ensureUnsetDefaultPreset } from './defaults.mjs'
|
|
61
62
|
import {
|
|
@@ -76,6 +77,7 @@ import {
|
|
|
76
77
|
setBaseCompositions,
|
|
77
78
|
setShippedPresetsDir,
|
|
78
79
|
unresolvableRows,
|
|
80
|
+
withPredicateCapabilities,
|
|
79
81
|
} from './composition.mjs'
|
|
80
82
|
import { presetMetaText, readPresetMeta, writePresetMeta, presetMetaPath, PRESET_META_PATH } from './meta.mjs'
|
|
81
83
|
import { listHistory, readVersion, recordExternalChange, recordPrompt, HISTORY_SOURCE } from './journal.mjs'
|
|
@@ -373,11 +375,42 @@ export function brokenRowIds(message) {
|
|
|
373
375
|
return [...new Set(ids)]
|
|
374
376
|
}
|
|
375
377
|
|
|
378
|
+
/**
|
|
379
|
+
* An opaque content revision of the complete editable state. Length framing and
|
|
380
|
+
* an explicit missing marker distinguish absent files from empty ones. History
|
|
381
|
+
* is deliberately excluded: losing an audit write must not disable conflict checks.
|
|
382
|
+
*/
|
|
383
|
+
export function stateRevision(directory) {
|
|
384
|
+
return revisionWithEntries(directory)
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function revisionWithEntries(directory, entries = []) {
|
|
388
|
+
const replacements = new Map(entries.map(([file, text]) => [basename(file), Buffer.from(text, 'utf8')]))
|
|
389
|
+
const hash = createHash('sha256')
|
|
390
|
+
for (const name of ['prompt.md', 'preset.yml', 'agent.cordis.yml']) {
|
|
391
|
+
hash.update(name + '\0')
|
|
392
|
+
let bytes
|
|
393
|
+
try {
|
|
394
|
+
bytes = replacements.has(name) ? replacements.get(name) : readFileSync(join(directory, name))
|
|
395
|
+
} catch (error) {
|
|
396
|
+
if (error.code !== 'ENOENT') throw error
|
|
397
|
+
hash.update('missing\0')
|
|
398
|
+
continue
|
|
399
|
+
}
|
|
400
|
+
hash.update('present:' + String(bytes.length) + '\0')
|
|
401
|
+
hash.update(bytes)
|
|
402
|
+
}
|
|
403
|
+
return hash.digest('hex')
|
|
404
|
+
}
|
|
405
|
+
|
|
376
406
|
export function readState(rows, id, options = {}) {
|
|
377
407
|
const directory = assistantDir(rows, id)
|
|
378
408
|
if (directory === undefined) return unknownAssistant(id)
|
|
379
409
|
const recovery = restoreBackups(directory)
|
|
380
410
|
if (recovery.ok !== true) return { ok: false, code: 'recoveryRequired', params: { path: directory, detail: recovery.detail }, error: '助手文件需要恢复:' + recovery.detail }
|
|
411
|
+
// Take the token before reading the displayed fields. A later external edit
|
|
412
|
+
// can then cause a conservative conflict, never bless an older displayed value.
|
|
413
|
+
const revision = stateRevision(directory)
|
|
381
414
|
const composition = compositionFile(directory)
|
|
382
415
|
if (!existsSync(composition)) return { ok: false, code: 'compositionMissing', params: { path: composition }, error: '找不到组成文件:' + composition }
|
|
383
416
|
const text = readFileSync(composition, 'utf8')
|
|
@@ -423,6 +456,7 @@ export function readState(rows, id, options = {}) {
|
|
|
423
456
|
return {
|
|
424
457
|
ok: true,
|
|
425
458
|
id,
|
|
459
|
+
revision,
|
|
426
460
|
mode,
|
|
427
461
|
modes: BASE_MODES,
|
|
428
462
|
rows: rowTree,
|
|
@@ -468,7 +502,7 @@ export function readState(rows, id, options = {}) {
|
|
|
468
502
|
}
|
|
469
503
|
|
|
470
504
|
/**
|
|
471
|
-
* The list payload:
|
|
505
|
+
* The list payload: managed assistants, their root, and the running plugin version.
|
|
472
506
|
*
|
|
473
507
|
* @param {Array<object>} rows - the current roster.
|
|
474
508
|
*/
|
|
@@ -478,7 +512,7 @@ export function readList(rows) {
|
|
|
478
512
|
const list = assistantsFromRoster(rows).map((item) =>
|
|
479
513
|
typeof item.broken === 'string' ? { ...item, brokenRows: brokenRowIds(item.broken) } : item,
|
|
480
514
|
)
|
|
481
|
-
return { ok: true, assistants: list, postures: publicPostures(), root: userPresetRoot(rows) }
|
|
515
|
+
return { ok: true, assistants: list, postures: publicPostures(), root: userPresetRoot(rows), version: PLUGIN_VERSION }
|
|
482
516
|
}
|
|
483
517
|
|
|
484
518
|
/**
|
|
@@ -503,33 +537,75 @@ const writeChains = new Map()
|
|
|
503
537
|
/**
|
|
504
538
|
* 打开页面之后、按下保存之前,`prompt.md` 可能已被会话工具或手工改过。
|
|
505
539
|
*
|
|
506
|
-
*
|
|
507
|
-
*
|
|
508
|
-
*
|
|
540
|
+
* 新页面用 baseRevision 比较三个编辑文件的原始内容。旧页面的 JSON 数字
|
|
541
|
+
* baseN 仍比较提示词历史;没有 token 的旧 API 保留覆盖语义。
|
|
542
|
+
* 冲突判定与审计写入隔离:历史不可写时也不能放行过期草稿。
|
|
509
543
|
*
|
|
510
544
|
* @param {string} directory
|
|
511
545
|
* @param {string} prompt - the text this save wants to write.
|
|
512
546
|
* @param {unknown} baseN
|
|
547
|
+
* @param {unknown} baseRevision - opaque revision returned when the draft was loaded.
|
|
513
548
|
* @returns {object|null} a `conflict` result, or null when the write may proceed.
|
|
514
549
|
*/
|
|
515
|
-
function
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
550
|
+
function refuseStaleState(directory, prompt, baseN, baseRevision) {
|
|
551
|
+
const diskPrompt = readPrompt(directory)
|
|
552
|
+
let stale = false
|
|
553
|
+
if (typeof baseRevision === 'string') {
|
|
554
|
+
try {
|
|
555
|
+
stale = stateRevision(directory) !== baseRevision
|
|
556
|
+
} catch {
|
|
557
|
+
// An unreadable editable file cannot prove the loaded baseline is current.
|
|
558
|
+
stale = true
|
|
559
|
+
}
|
|
560
|
+
} else if (typeof baseN === 'number') {
|
|
561
|
+
stale = diskPrompt.ok !== true || (diskPrompt.text !== prompt && readVersion(directory, baseN) !== diskPrompt.text)
|
|
562
|
+
}
|
|
563
|
+
if (diskPrompt.ok === true && diskPrompt.text !== prompt) {
|
|
564
|
+
try {
|
|
565
|
+
recordExternalChange(directory, diskPrompt.text)
|
|
566
|
+
} catch {
|
|
567
|
+
/* 审计留痕失败不改变上面已经完成的冲突判定 */
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
return stale ? {
|
|
571
|
+
ok: false,
|
|
572
|
+
code: 'conflict',
|
|
573
|
+
params: typeof baseRevision === 'string' ? {} : { baseN },
|
|
574
|
+
error: '磁盘上的助手文件已经变了或无法核对,这次保存没有写入。',
|
|
575
|
+
note: '磁盘上的助手文件已经变了或无法核对,这次保存没有写入。',
|
|
576
|
+
} : null
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
/** Reordering writes every assistant's metadata, so validate all held baselines first. */
|
|
580
|
+
function reorderState(rows, input) {
|
|
581
|
+
const baselines = input?.baseRevisions
|
|
582
|
+
if (baselines !== null && typeof baselines === 'object') {
|
|
583
|
+
for (const [id, revision] of Object.entries(baselines)) {
|
|
584
|
+
const directory = assistantDir(rows, id)
|
|
585
|
+
if (directory === undefined || typeof revision !== 'string') {
|
|
586
|
+
return { ok: false, code: 'conflict', params: {}, error: '助手列表或文件已经变了,这次排序没有写入。' }
|
|
527
587
|
}
|
|
588
|
+
const conflict = refuseStaleState(directory, readPrompt(directory).text ?? '', undefined, revision)
|
|
589
|
+
if (conflict !== null) return conflict
|
|
528
590
|
}
|
|
529
|
-
} catch {
|
|
530
|
-
/* 审计留痕失败不挡住这次保存 */
|
|
531
591
|
}
|
|
532
|
-
|
|
592
|
+
const ids = new Map(assistantsFromRoster(rows).map(({ id }) => [assistantDir(rows, id), id]))
|
|
593
|
+
const revisions = {}
|
|
594
|
+
const result = reorderAssistant(rows, input, (name, description, directory, options) => {
|
|
595
|
+
const rendered = presetMetaText(name, description, directory, options)
|
|
596
|
+
if (rendered.ok !== true) return rendered
|
|
597
|
+
try {
|
|
598
|
+
const entries = [[presetMetaPath(directory), rendered.text]]
|
|
599
|
+
const revision = revisionWithEntries(directory, entries)
|
|
600
|
+
writeAtomic(presetMetaPath(directory), rendered.text)
|
|
601
|
+
revisions[ids.get(directory)] = revision
|
|
602
|
+
return { ok: true, name: rendered.name }
|
|
603
|
+
} catch (error) {
|
|
604
|
+
return { ok: false, code: 'metaWriteFailed', params: { detail: describe(error) }, error: '写入 preset.yml 失败:' + describe(error) }
|
|
605
|
+
}
|
|
606
|
+
})
|
|
607
|
+
// Failed reorders can roll back earlier writes; none of their tokens may be adopted.
|
|
608
|
+
return result.ok === true ? { ...result, revisions } : result
|
|
533
609
|
}
|
|
534
610
|
|
|
535
611
|
function serializedWrite(key, work) {
|
|
@@ -549,13 +625,15 @@ function serializedWrite(key, work) {
|
|
|
549
625
|
* 的行集合也就不变,这是所有选项里破坏性最小的一个。
|
|
550
626
|
*
|
|
551
627
|
* @param {string} directory - the assistant's preset directory.
|
|
552
|
-
* @param {{id: string, mode: string, prompt: string, name: string, description: string, displayName?: string, baseN?: number}} input
|
|
628
|
+
* @param {{id: string, mode: string, prompt: string, name: string, description: string, displayName?: string, baseN?: number, baseRevision?: string}} input
|
|
553
629
|
* - the validated save; `name` decides whether `preset.yml` is written, `displayName` is what the response
|
|
554
630
|
* reports (the disk name when the request omitted one — issue #9). `baseN` is the same stale-save
|
|
555
631
|
* token as {@link saveState}: a JSON number only.
|
|
556
632
|
* @returns {object} the shape `saveState` returns, with `code: 'savedPromptOnly'`.
|
|
557
633
|
*/
|
|
558
|
-
export function savePromptOnly(directory, { id, mode, prompt, name, description, displayName, baseN }) {
|
|
634
|
+
export function savePromptOnly(directory, { id, mode, prompt, name, description, displayName, baseN, baseRevision }) {
|
|
635
|
+
const recovery = restoreBackups(directory)
|
|
636
|
+
if (recovery.ok !== true) return { ok: false, code: 'recoveryRequired', params: { path: directory, detail: recovery.detail }, error: '助手文件需要恢复:' + recovery.detail }
|
|
559
637
|
// 与正常保存同一条纪律:提示词与 preset.yml 一起换名,失败则两个都不动。
|
|
560
638
|
let metaText = null
|
|
561
639
|
if (name !== '') {
|
|
@@ -563,13 +641,18 @@ export function savePromptOnly(directory, { id, mode, prompt, name, description,
|
|
|
563
641
|
if (rendered.ok !== true) return rendered
|
|
564
642
|
metaText = rendered.text
|
|
565
643
|
}
|
|
566
|
-
const conflict =
|
|
644
|
+
const conflict = refuseStaleState(directory, prompt, baseN, baseRevision)
|
|
567
645
|
if (conflict !== null) return conflict
|
|
646
|
+
let revision
|
|
568
647
|
try {
|
|
569
|
-
|
|
648
|
+
const entries = [
|
|
570
649
|
[promptFile(directory), prompt],
|
|
571
650
|
...metaText === null ? [] : [[presetMetaPath(directory), metaText]],
|
|
572
|
-
]
|
|
651
|
+
]
|
|
652
|
+
// Return our exact submitted generation, without a post-commit read that
|
|
653
|
+
// could fail or accidentally adopt a different writer's subsequent change.
|
|
654
|
+
revision = revisionWithEntries(directory, entries)
|
|
655
|
+
writeAtomicPair(entries)
|
|
573
656
|
} catch (error) {
|
|
574
657
|
return { ok: false, code: 'writeFailed', params: { detail: describe(error) }, error: '写入失败:' + describe(error) }
|
|
575
658
|
}
|
|
@@ -583,6 +666,7 @@ export function savePromptOnly(directory, { id, mode, prompt, name, description,
|
|
|
583
666
|
id,
|
|
584
667
|
mode,
|
|
585
668
|
code: 'savedPromptOnly',
|
|
669
|
+
revision,
|
|
586
670
|
// 响应里的名字用**磁盘上的那个**(issue #9):请求省略 name 时不该把 params.name 报成裸目录 id。
|
|
587
671
|
params: { name: (displayName === undefined || displayName === '' ? name : displayName) === '' ? id : (displayName === undefined || displayName === '' ? name : displayName), mode },
|
|
588
672
|
note: '已保存系统提示词(本机取不到基础模式的出厂组成,插件开关与基础模式未改动)。下一步组装时会重读这个文件。',
|
|
@@ -669,6 +753,7 @@ export function saveState(rows, input) {
|
|
|
669
753
|
description,
|
|
670
754
|
displayName: effectiveName,
|
|
671
755
|
baseN: input !== null && typeof input === 'object' ? input.baseN : undefined,
|
|
756
|
+
baseRevision: input !== null && typeof input === 'object' ? input.baseRevision : undefined,
|
|
672
757
|
})
|
|
673
758
|
}
|
|
674
759
|
return {
|
|
@@ -701,14 +786,17 @@ export function saveState(rows, input) {
|
|
|
701
786
|
if (rendered.ok !== true) return rendered
|
|
702
787
|
metaText = rendered.text
|
|
703
788
|
}
|
|
704
|
-
const conflict =
|
|
789
|
+
const conflict = refuseStaleState(directory, prompt, input?.baseN, input?.baseRevision)
|
|
705
790
|
if (conflict !== null) return conflict
|
|
791
|
+
let revision
|
|
706
792
|
try {
|
|
707
|
-
|
|
793
|
+
const entries = [
|
|
708
794
|
[compositionFile(directory), composition],
|
|
709
795
|
[promptFile(directory), prompt],
|
|
710
796
|
...metaText === null ? [] : [[presetMetaPath(directory), metaText]],
|
|
711
|
-
]
|
|
797
|
+
]
|
|
798
|
+
revision = revisionWithEntries(directory, entries)
|
|
799
|
+
writeAtomicPair(entries)
|
|
712
800
|
} catch (error) {
|
|
713
801
|
return { ok: false, code: 'writeFailed', params: { detail: describe(error) }, error: '写入失败:' + describe(error) }
|
|
714
802
|
}
|
|
@@ -732,6 +820,7 @@ export function saveState(rows, input) {
|
|
|
732
820
|
id,
|
|
733
821
|
mode,
|
|
734
822
|
code: 'savedWithExclusiveRows',
|
|
823
|
+
revision,
|
|
735
824
|
// `rows` 用行 id:页面自己按 `row.<id>.label` 渲染,英文界面不会因为这条掉回中文。
|
|
736
825
|
params: { name: displayName, mode, rows: autoOff.join(', ') },
|
|
737
826
|
note:
|
|
@@ -744,6 +833,7 @@ export function saveState(rows, input) {
|
|
|
744
833
|
id,
|
|
745
834
|
mode,
|
|
746
835
|
code: 'saved',
|
|
836
|
+
revision,
|
|
747
837
|
params: { name: displayName, mode },
|
|
748
838
|
note: '已保存(' + displayName + ',基础模式 ' + mode + ')。下一步组装时会重读已保存的提示词。',
|
|
749
839
|
}
|
|
@@ -976,6 +1066,8 @@ export function repairComposition(rows, input) {
|
|
|
976
1066
|
const id = input !== null && typeof input === 'object' && typeof input.id === 'string' ? input.id : ''
|
|
977
1067
|
const directory = assistantDir(rows, id)
|
|
978
1068
|
if (directory === undefined) return unknownAssistant(id)
|
|
1069
|
+
const conflict = refuseStaleState(directory, readPrompt(directory).text ?? '', undefined, input?.baseRevision)
|
|
1070
|
+
if (conflict !== null) return conflict
|
|
979
1071
|
const file = compositionFile(directory)
|
|
980
1072
|
if (!existsSync(file)) return { ok: false, code: 'compositionMissing', params: { path: file }, error: '找不到组成文件:' + file }
|
|
981
1073
|
const text = readFileSync(file, 'utf8')
|
|
@@ -1006,7 +1098,9 @@ export function repairComposition(rows, input) {
|
|
|
1006
1098
|
}
|
|
1007
1099
|
// **就地**关闭那几行,不重渲染:重渲染会顺手丢掉 base 之外的自有行,而用户的数据不该被这样动。
|
|
1008
1100
|
const rendered = disableRowsInPlace(text, bad.map((row) => row.id))
|
|
1101
|
+
let revision
|
|
1009
1102
|
try {
|
|
1103
|
+
revision = revisionWithEntries(directory, [[file, rendered]])
|
|
1010
1104
|
writeAtomic(file, rendered)
|
|
1011
1105
|
} catch (error) {
|
|
1012
1106
|
return { ok: false, code: 'writeFailed', params: { detail: describe(error) }, error: '写入失败:' + describe(error) }
|
|
@@ -1016,6 +1110,7 @@ export function repairComposition(rows, input) {
|
|
|
1016
1110
|
ok: true,
|
|
1017
1111
|
id,
|
|
1018
1112
|
code: 'repaired',
|
|
1113
|
+
revision,
|
|
1019
1114
|
// `ids` 是给人看的一串;`repairedIds` 是给**页面**用的数组 —— 页面必须把这几行写进它自己的
|
|
1020
1115
|
// 草稿,否则下一次保存会按草稿重渲染,把刚才的修复原样撤销(issue #8 实测:
|
|
1021
1116
|
// 修复写进了磁盘,但草稿里那几行仍是启用的,于是保存后 ghost 行又回来了)。
|
|
@@ -1222,6 +1317,9 @@ export function apply(ctx) {
|
|
|
1222
1317
|
backendVerdict.id === BACKEND_DECLARATIVE
|
|
1223
1318
|
? createDeclarativeBackend({ scope, log: console.log, warn: console.error })
|
|
1224
1319
|
: null
|
|
1320
|
+
if (declarative !== null) {
|
|
1321
|
+
scope.effect(() => () => declarative.disposeAll(), 'custom-mode.presets')
|
|
1322
|
+
}
|
|
1225
1323
|
|
|
1226
1324
|
/**
|
|
1227
1325
|
* 删除入口,按后端选。新线上服务**没有** `remove()`(唯一手段是 `register()` 的 disposer),
|
|
@@ -1363,6 +1461,26 @@ export function apply(ctx) {
|
|
|
1363
1461
|
* 写操作之后的重同步由 `resyncPresets` 负责,不该让每个请求都等一遍。
|
|
1364
1462
|
*/
|
|
1365
1463
|
let startupSync = null
|
|
1464
|
+
let latestSync = { ok: true, failures: [] }
|
|
1465
|
+
const synchronize = async () => {
|
|
1466
|
+
const outcome = await doSync()
|
|
1467
|
+
latestSync = outcome
|
|
1468
|
+
return outcome
|
|
1469
|
+
}
|
|
1470
|
+
// Public synchronization evidence contains only bounded preset identifiers and
|
|
1471
|
+
// typed codes. Raw registration errors stay in host logs.
|
|
1472
|
+
const registrationSync = (assistantId) => {
|
|
1473
|
+
const failures = assistantId === undefined ? latestSync.failures
|
|
1474
|
+
: latestSync.failures.filter(({ id }) => typeof id !== 'string' || id === '' || id === assistantId)
|
|
1475
|
+
if (latestSync.ok === true || failures.length === 0) return { ok: true, code: 'synchronized', ids: [] }
|
|
1476
|
+
return {
|
|
1477
|
+
ok: false,
|
|
1478
|
+
code: 'presetRegistrationSyncFailed',
|
|
1479
|
+
ids: [...new Set(failures.map(({ id }) => id)
|
|
1480
|
+
.filter((id) => typeof id === 'string' && /^[A-Za-z0-9_.-]{1,128}$/.test(id)))].slice(0, 32),
|
|
1481
|
+
restartSuggested: failures.some(({ code }) => code === 'registrationConflict' || code === 'registrationCleanupFailed'),
|
|
1482
|
+
}
|
|
1483
|
+
}
|
|
1366
1484
|
const roster = async () => {
|
|
1367
1485
|
if (startupSync !== null) await startupSync
|
|
1368
1486
|
return rawRoster()
|
|
@@ -1370,12 +1488,12 @@ export function apply(ctx) {
|
|
|
1370
1488
|
|
|
1371
1489
|
/** Called inside the write queue; readers also await the current synchronization. */
|
|
1372
1490
|
const resyncPresets = () => {
|
|
1373
|
-
startupSync =
|
|
1491
|
+
startupSync = synchronize()
|
|
1374
1492
|
return startupSync
|
|
1375
1493
|
}
|
|
1376
1494
|
|
|
1377
1495
|
// 启动即同步:新线不是"写盘即生效",第一次进来必须先注册。
|
|
1378
|
-
startupSync =
|
|
1496
|
+
startupSync = synchronize()
|
|
1379
1497
|
|
|
1380
1498
|
/**
|
|
1381
1499
|
* One Fetch-shaped handler for the whole surface.
|
|
@@ -1399,16 +1517,33 @@ export function apply(ctx) {
|
|
|
1399
1517
|
detail: outcome.failures.map((failure) => failure.error ?? failure.code ?? 'unknown').join('; '),
|
|
1400
1518
|
})
|
|
1401
1519
|
|
|
1402
|
-
const
|
|
1520
|
+
const capabilitySnapshot = () => {
|
|
1521
|
+
try {
|
|
1522
|
+
if (typeof scope.get === 'function') return { profileContext: Boolean(scope.get('profileContext')) }
|
|
1523
|
+
} catch {
|
|
1524
|
+
/* Optional host capability is unknown; keep predicate warning/fail-safe. */
|
|
1525
|
+
}
|
|
1526
|
+
return {}
|
|
1527
|
+
}
|
|
1528
|
+
const handle = (request, pathname) => withPredicateCapabilities(capabilitySnapshot(), async () => {
|
|
1403
1529
|
try {
|
|
1404
1530
|
const url = new URL(request.url)
|
|
1405
1531
|
if (request.method === 'GET' && pathname === ROUTE_PATH) {
|
|
1406
1532
|
await ensureShipped()
|
|
1407
|
-
|
|
1533
|
+
const rows = await roster()
|
|
1534
|
+
return json({ ...readList(rows), registrationSync: registrationSync() })
|
|
1408
1535
|
}
|
|
1409
1536
|
if (request.method === 'GET' && pathname === STATE_PATH) {
|
|
1410
1537
|
await ensureShipped()
|
|
1411
|
-
|
|
1538
|
+
const state = readState(await roster(), url.searchParams.get('id') ?? '', { factoryPrompt: packagedPrompt() })
|
|
1539
|
+
const synchronization = registrationSync(state.id)
|
|
1540
|
+
return json({
|
|
1541
|
+
...state,
|
|
1542
|
+
registrationSync: synchronization,
|
|
1543
|
+
...(state.ok === true && synchronization.ok === false
|
|
1544
|
+
? { warnings: [...state.warnings, synchronization.restartSuggested ? 'presetRegistrationRestartSuggested' : 'presetRegistrationSyncFailed'] }
|
|
1545
|
+
: {}),
|
|
1546
|
+
})
|
|
1412
1547
|
}
|
|
1413
1548
|
|
|
1414
1549
|
if (request.method === 'GET' && pathname === HISTORY_PATH) {
|
|
@@ -1456,7 +1591,7 @@ export function apply(ctx) {
|
|
|
1456
1591
|
const result = pathname === STATE_PATH ? saveState(rows, parsed)
|
|
1457
1592
|
: pathname === CREATE_PATH ? createAssistant(rows, parsed)
|
|
1458
1593
|
: pathname === REPAIR_PATH ? repairComposition(rows, parsed)
|
|
1459
|
-
: pathname === REORDER_PATH ?
|
|
1594
|
+
: pathname === REORDER_PATH ? reorderState(rows, parsed)
|
|
1460
1595
|
: await deleteAssistant(rows, parsed, presetRemover())
|
|
1461
1596
|
if (result.ok !== true) return json(result, 400)
|
|
1462
1597
|
const synced = await resyncPresets()
|
|
@@ -1475,7 +1610,7 @@ export function apply(ctx) {
|
|
|
1475
1610
|
// ——中文界面里会蹦出一句英文。带 code 后页面用自己的词典渲染,原始串只作为 `detail` 参数。
|
|
1476
1611
|
return json({ ok: false, code: 'internalError', params: { detail: describe(error) }, error: describe(error) }, 500)
|
|
1477
1612
|
}
|
|
1478
|
-
}
|
|
1613
|
+
})
|
|
1479
1614
|
|
|
1480
1615
|
// One registration per exact path — the platform's Fetch registry matches exact paths, not prefixes —
|
|
1481
1616
|
// each declaring the methods it owns. The method table doubles as the guarantee that a prefetched
|
package/locales.mjs
CHANGED
|
@@ -16,11 +16,49 @@
|
|
|
16
16
|
|
|
17
17
|
/** Simplified Chinese dictionary — the key source of truth. */
|
|
18
18
|
export const zh = {
|
|
19
|
+
'update.heading': '插件更新',
|
|
20
|
+
'update.orphanHint': '以下助手已不在当前列表,但本页仍保留其未保存草稿,因此暂不能离页。',
|
|
21
|
+
'update.orphanName': '{name}(ID:{id})',
|
|
22
|
+
'update.orphanView': '查看并保留草稿',
|
|
23
|
+
'update.orphanHide': '收起草稿',
|
|
24
|
+
'update.orphanCopy': '下方包含完整提示词、名称、描述、模式和覆盖项,可选中复制到本地文件保留。放弃仅移除本页内存草稿,不修改磁盘文件。',
|
|
25
|
+
'update.orphanDiscard': '放弃此草稿…',
|
|
26
|
+
'update.orphanConfirm': '确认放弃“{name}”(ID:{id})的未保存草稿?请先复制保留需要的内容。放弃后无法从本页恢复;磁盘文件不会改动。',
|
|
27
|
+
'update.orphanAcknowledge': '我已保留需要的内容,确认放弃后无法从本页恢复。',
|
|
28
|
+
'update.orphanChanged': '草稿或助手列表已变化,未放弃任何内容。请重新查看并确认。',
|
|
29
|
+
'update.orphanDiscarded': '已放弃所确认的本页草稿;磁盘文件未改动。',
|
|
30
|
+
'update.running': '运行中的插件版本:{version}',
|
|
31
|
+
'update.unknown': '未知',
|
|
32
|
+
'update.check': '检查更新',
|
|
33
|
+
'update.checking': '正在检查…',
|
|
34
|
+
'update.inPlugins': '在插件管理中更新',
|
|
35
|
+
'update.details': '打开插件详情',
|
|
36
|
+
'update.guide': '更新指引',
|
|
37
|
+
'update.idle': '仅在点击检查时查询宿主配置的包仓库;不会自动安装。',
|
|
38
|
+
'update.unavailable': '当前连接未提供原生版本检查能力。可打开插件详情或查看更新指引。',
|
|
39
|
+
'update.permission': '此操作缺少宿主授权,或权限服务暂不可用。',
|
|
40
|
+
'update.updatePermission': '可以查看版本,但当前连接没有更新权限。',
|
|
41
|
+
'update.available': '可用更新:{version}。安装由官方插件管理处理。',
|
|
42
|
+
'update.current': '此次检查未返回更高版本。',
|
|
43
|
+
'update.filtered': '发现较新版本,但没有符合官方默认条件的稳定兼容版本。',
|
|
44
|
+
'update.partial': '结果未覆盖全部新版本,暂未找到可推荐的更新。',
|
|
45
|
+
'update.omitted': '另有 {count} 个较新版本未列出。',
|
|
46
|
+
'update.disk': '磁盘安装版本:{version};它可能尚未在运行中加载。',
|
|
47
|
+
'update.managed': '此安装无法使用原生包仓库更新,请通过原来源或插件详情管理;不会自动切换来源。',
|
|
48
|
+
'update.notFound': '宿主插件列表中没有找到此插件,无法确认更新。',
|
|
49
|
+
'update.network': '宿主查询包仓库失败,请稍后重试;尚不能确认更新状态。',
|
|
50
|
+
'update.refused': '宿主未提供可用的版本列表,尚不能确认更新状态。',
|
|
51
|
+
'update.failed': '未能完成更新检查,请重试或打开插件详情查看。',
|
|
52
|
+
'update.finishOperation': '请等待当前操作完成后再离开此页。',
|
|
53
|
+
'update.finishRecovery': '请先完成重试读取或重试生效,再离开此页。',
|
|
54
|
+
'update.saveDrafts': '请先保存或主动放弃所有助手草稿及未提交的新建输入,再离开此页。',
|
|
55
|
+
'update.navigationFailed': '未能打开插件详情;草稿已保留,可稍后重试。',
|
|
19
56
|
'create.nextStep': '创建后可在上方继续编辑并保存;使用时,在新会话的模式菜单选择它。',
|
|
20
57
|
'btn.retryRead': '重试读取',
|
|
21
58
|
'btn.retrySync': '重试生效',
|
|
22
59
|
'api.savedButSyncFailed': '文件已保存,但这些助手尚未生效:{ids}。请点「重试生效」,无需重复原操作。',
|
|
23
60
|
'api.syncFailed': '这些助手仍未生效:{ids}。请重试;仍失败时查看宿主日志。',
|
|
61
|
+
'api.presetRegistrationSyncFailed': '尚未确认已保存的助手配置在宿主注册表中生效,请处理已有告警或查看宿主日志。',
|
|
24
62
|
'api.synchronized': '配置已生效。新会话请从模式菜单选择助手。',
|
|
25
63
|
'api.recoveryRequired': '助手文件需要恢复:{path}。请保留目录中的备份,并查看宿主日志中的恢复说明。',
|
|
26
64
|
'msg.completedReadFailed': '{result} 页面刷新失败。请点「重试读取」,无需重复原操作。',
|
|
@@ -63,6 +101,9 @@ export const zh = {
|
|
|
63
101
|
'assistant.brokenHint': '此模式在本机无法启动,不会出现在新会话菜单里。点「按本线修复」只关闭那些行。',
|
|
64
102
|
'assistant.switchHint': '切换助手保留各自的未保存草稿;列表中的「未保存」标记即为此。',
|
|
65
103
|
'btn.create': '新增助手',
|
|
104
|
+
'btn.newAssistant': '新建助手…',
|
|
105
|
+
'assistant.duplicateSavedConfirm': '复制使用磁盘上已保存的配置,不包含当前未保存的修改。继续复制已保存版本?当前草稿会保留。',
|
|
106
|
+
'prompt.replaceConfirm': '替换当前提示词草稿?尚未保存的提示词会被替换,无法从保存历史中恢复。请先复制保留需要的内容。',
|
|
66
107
|
'create.heading': '新建助手',
|
|
67
108
|
'btn.creating': '创建中…',
|
|
68
109
|
'btn.duplicate': '复制',
|
|
@@ -112,7 +153,7 @@ export const zh = {
|
|
|
112
153
|
'meta.version': '插件版本',
|
|
113
154
|
'meta.versionHint': '未钉版本时,可能装到较旧的发布。要换版本,按 README 钉版本重装,然后重启。',
|
|
114
155
|
'api.saved': '已保存({name},{mode})。下一步组装时会重读已保存的提示词。',
|
|
115
|
-
'api.conflict': '
|
|
156
|
+
'api.conflict': '磁盘上的助手配置已经变了,这次没有写入。请先导出或复制保留草稿,再重新读取并合并修改。',
|
|
116
157
|
'api.savedWithExclusiveRows': '已保存({name},{mode})。已关闭不能同时启用的行:{rows}。',
|
|
117
158
|
'api.created': '已创建「{name}」。可继续编辑;使用时请在新会话的模式菜单选择它。',
|
|
118
159
|
'api.savedPrompt': '已保存系统提示词({name})。下一步组装时会重读这个文件。',
|
|
@@ -150,6 +191,8 @@ export const zh = {
|
|
|
150
191
|
'warn.approvalGateMissing': '当前版本没有审批事件。会话内改写提示词不会请求确认。',
|
|
151
192
|
'warn.unresolvableRows': '有的行在本机无法启动,此模式不会出现在新会话菜单里。点「按本线修复」只关闭那些行。',
|
|
152
193
|
'warn.presetBroken': '此模式无法启动,不会出现在新会话菜单里。点「按本线修复」。原因见下一行。',
|
|
194
|
+
'warn.presetRegistrationSyncFailed': '尚未确认这位助手已保存的配置在宿主注册表中生效。请先处理已有告警或查看宿主日志;无需重复保存。',
|
|
195
|
+
'warn.presetRegistrationRestartSuggested': '尚未确认这位助手已保存的配置在宿主注册表中生效。如刚更新或恢复插件,建议重启 DeepSeek Harness 后重新检查;无需重复保存。',
|
|
153
196
|
'warn.badPromptVariable': '提示词里有不可用的变量。改掉后再保存,否则此模式的请求会失败。',
|
|
154
197
|
'warn.duplicateRowIds': '组成文件里有重复的行 id。开关只作用于第一处。',
|
|
155
198
|
'warn.predicateFailed': '有一行的平台条件无法计算,已按关闭处理。',
|
|
@@ -302,11 +345,49 @@ export const zh = {
|
|
|
302
345
|
|
|
303
346
|
/** English dictionary; the parity test keeps its key set equal to {@link zh}. */
|
|
304
347
|
export const en = {
|
|
348
|
+
'update.heading': 'Plugin updates',
|
|
349
|
+
'update.orphanHint': 'These assistants left the current list, but this page still holds their unsaved drafts. Resolve them before leaving.',
|
|
350
|
+
'update.orphanName': '{name} (ID: {id})',
|
|
351
|
+
'update.orphanView': 'View and keep draft',
|
|
352
|
+
'update.orphanHide': 'Hide draft',
|
|
353
|
+
'update.orphanCopy': 'The text below includes the full prompt, name, description, mode and overrides. Select and copy it to a local file to keep it. Discarding only removes this page\'s in-memory draft; files on disk are unchanged.',
|
|
354
|
+
'update.orphanDiscard': 'Discard this draft…',
|
|
355
|
+
'update.orphanConfirm': 'Discard the unsaved draft for "{name}" (ID: {id})? First copy any content you need to keep. This page cannot recover it after discarding; files on disk will remain unchanged.',
|
|
356
|
+
'update.orphanAcknowledge': 'I have kept the content I need and understand this page cannot recover the discarded draft.',
|
|
357
|
+
'update.orphanChanged': 'The draft or assistant list changed. Nothing was discarded. Review it again before confirming.',
|
|
358
|
+
'update.orphanDiscarded': 'The confirmed draft was discarded from this page; files on disk are unchanged.',
|
|
359
|
+
'update.running': 'Running plugin version: {version}',
|
|
360
|
+
'update.unknown': 'unknown',
|
|
361
|
+
'update.check': 'Check for updates',
|
|
362
|
+
'update.checking': 'Checking…',
|
|
363
|
+
'update.inPlugins': 'Update in Plugins',
|
|
364
|
+
'update.details': 'Open plugin details',
|
|
365
|
+
'update.guide': 'Update guide',
|
|
366
|
+
'update.idle': 'Only a click checks the registries configured by the host; installation is never automatic.',
|
|
367
|
+
'update.unavailable': 'This connection does not offer native version checks. Open plugin details or the update guide.',
|
|
368
|
+
'update.permission': 'Host permission is missing for this action, or the access service is unavailable.',
|
|
369
|
+
'update.updatePermission': 'You can view versions, but this connection does not have update permission.',
|
|
370
|
+
'update.available': 'Update available: {version}. Official plugin management handles installation.',
|
|
371
|
+
'update.current': 'This check returned no newer releases.',
|
|
372
|
+
'update.filtered': 'Newer releases exist, but none meet the official default conditions for a stable compatible version.',
|
|
373
|
+
'update.partial': 'The result omits some newer releases; no recommended update was found.',
|
|
374
|
+
'update.omitted': '{count} additional newer releases were omitted.',
|
|
375
|
+
'update.disk': 'Installed on disk: {version}; this version may not be loaded in the running host yet.',
|
|
376
|
+
'update.managed': 'Manage this installation through its original source or plugin details; it cannot use this native registry update path. The source will not be changed automatically.',
|
|
377
|
+
'update.notFound': 'The host did not list this plugin, so its update status could not be determined.',
|
|
378
|
+
'update.network': 'The host could not query the registry. Try again later; update status is unknown.',
|
|
379
|
+
'update.refused': 'The host did not provide a usable version list; update status is unknown.',
|
|
380
|
+
'update.failed': 'The update check could not finish. Retry or open plugin details.',
|
|
381
|
+
'update.finishOperation': 'Wait for the current operation to finish before leaving this page.',
|
|
382
|
+
'update.finishRecovery': 'Complete Retry refresh or Retry activation before leaving this page.',
|
|
383
|
+
'update.saveDrafts': 'Save or explicitly discard all assistant drafts and unsubmitted new-assistant input before leaving this page.',
|
|
384
|
+
'update.navigationFailed': 'Plugin details could not open. Your drafts are preserved; try again later.',
|
|
305
385
|
'create.nextStep': 'After creating, edit and save above. To use the assistant, choose it in the mode menu for a new session.',
|
|
306
386
|
'btn.retryRead': 'Retry refresh',
|
|
307
387
|
'btn.retrySync': 'Retry activation',
|
|
308
388
|
'api.savedButSyncFailed': 'Files are saved, but these assistants are not active yet: {ids}. Select Retry activation; you do not need to repeat the original action.',
|
|
309
389
|
'api.syncFailed': 'These assistants are still not active: {ids}. Retry, then check the host log if the problem continues.',
|
|
390
|
+
'api.presetRegistrationSyncFailed': 'The saved assistant configuration has not been confirmed in the host registry. Address existing warnings or check the host log.',
|
|
310
391
|
'api.synchronized': 'Configuration is active. Choose an assistant in the mode menu for a new session.',
|
|
311
392
|
'api.recoveryRequired': 'Assistant files need recovery: {path}. Keep the backups in that directory and check the host log for recovery details.',
|
|
312
393
|
'msg.completedReadFailed': '{result} The page could not refresh. Select Retry refresh; do not repeat the original action.',
|
|
@@ -347,6 +428,9 @@ export const en = {
|
|
|
347
428
|
'assistant.brokenHint': 'This mode cannot start here, so it will not appear for a new session. Fix for this line turns off only those rows.',
|
|
348
429
|
'assistant.switchHint': 'Switching assistants keeps each draft; the "Unsaved" marker in the list is exactly that.',
|
|
349
430
|
'btn.create': 'New assistant',
|
|
431
|
+
'btn.newAssistant': 'New assistant…',
|
|
432
|
+
'assistant.duplicateSavedConfirm': 'Copy the saved configuration from disk? Unsaved changes are not included, and your current draft will be kept.',
|
|
433
|
+
'prompt.replaceConfirm': 'Replace the current prompt draft? Unsaved prompt text will be replaced and cannot be recovered from saved history. Copy any content you need to keep first.',
|
|
350
434
|
'create.heading': 'New assistant',
|
|
351
435
|
'btn.creating': 'Creating…',
|
|
352
436
|
'btn.duplicate': 'Duplicate',
|
|
@@ -396,7 +480,7 @@ export const en = {
|
|
|
396
480
|
'meta.version': 'Plugin version',
|
|
397
481
|
'meta.versionHint': 'Without a pinned version, an older release may be installed. To change it, reinstall with the version pin from the README, then restart.',
|
|
398
482
|
'api.saved': 'Saved ({name}, {mode}). The next model step rereads the saved prompt.',
|
|
399
|
-
'api.conflict': 'The
|
|
483
|
+
'api.conflict': 'The assistant configuration on disk has changed. Nothing was written. Export or copy your draft, then reload and merge your changes.',
|
|
400
484
|
'api.savedWithExclusiveRows': 'Saved ({name}, {mode}). Turned off rows that cannot be on together: {rows}.',
|
|
401
485
|
'api.created': 'Created {name}. Continue editing here, then choose it in the mode menu for a new session.',
|
|
402
486
|
'api.savedPrompt': 'System prompt saved ({name}). The next model step rereads this file.',
|
|
@@ -437,6 +521,8 @@ export const en = {
|
|
|
437
521
|
'warn.approvalGateMissing': 'This version has no approval event. In-session prompt edits are not confirmed.',
|
|
438
522
|
'warn.unresolvableRows': 'Some rows cannot start here, so this mode is absent from the new-session menu. Fix for this line turns off only those rows.',
|
|
439
523
|
'warn.presetBroken': 'This mode cannot start, so it will not appear for a new session. Use Fix for this line. The reason is on the next line.',
|
|
524
|
+
'warn.presetRegistrationSyncFailed': 'This assistant\'s saved configuration has not been confirmed in the host registry. Address existing warnings or check the host log; you do not need to save again.',
|
|
525
|
+
'warn.presetRegistrationRestartSuggested': 'This assistant\'s saved configuration has not been confirmed in the host registry. If the plugin was just updated or restored, consider restarting DeepSeek Harness, then check again; you do not need to save again.',
|
|
440
526
|
'warn.badPromptVariable': 'The prompt uses a variable that is not available. Fix it before saving.',
|
|
441
527
|
'warn.duplicateRowIds': 'A row id is repeated. A switch applies only to the first one.',
|
|
442
528
|
'warn.predicateFailed': 'A platform condition could not be evaluated. That row is treated as off.',
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-custom-mode",
|
|
3
|
-
"version": "2.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "2.3.1",
|
|
4
|
+
"description": "Customize DeepSeek Harness assistants: edit system prompts, base modes, and plugin switches.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"author": "bowluna <ai@bowluna.com>",
|