dsh-lifecard 0.1.2 → 0.2.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/store.js CHANGED
@@ -109,14 +109,38 @@ export async function openWorkspace(root, options = {}) {
109
109
  return { ok: true, root: abs, exists: true }
110
110
  }
111
111
 
112
+ /** 本地时间戳,给「覆盖前备份」这种文件名用。 */
113
+ function freeLocalStamp(now = new Date()) {
114
+ const pad = (n) => String(n).padStart(2, '0')
115
+ return `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}-${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`
116
+ }
117
+
112
118
  /** 读工作区配置;没有就给默认值(不写盘)。 */
113
119
  export async function readConfig(root) {
120
+ return (await readConfigState(root)).config
121
+ }
122
+
123
+ /**
124
+ * 读配置,并且**告诉调用方「是没有,还是读不出来」**。
125
+ *
126
+ * 以前只有 `readConfig` 一个口子:任何异常都 `return defaultConfig()`。
127
+ * 于是 config.json 一旦读不出来(权限、损坏、同步盘没挂上),
128
+ * 紧跟着的一次 `writeConfig` 会把整份配置按默认值重写回去 —— 用户的
129
+ * 「想要什么样的人」「看过介绍了没有」全部归零,而且没人知道。
130
+ */
131
+ export async function readConfigState(root) {
132
+ let text
133
+ try {
134
+ text = await fs.readFile(path.join(root, CONFIG_NAME), 'utf8')
135
+ } catch (error) {
136
+ if (error?.code === 'ENOENT') return { ok: true, exists: false, config: defaultConfig() }
137
+ return { ok: false, exists: false, config: defaultConfig(), error: '这个文件夹里的设置读不出来。检查一下文件权限,再试一次。' }
138
+ }
114
139
  try {
115
- const text = await fs.readFile(path.join(root, CONFIG_NAME), 'utf8')
116
140
  const json = JSON.parse(text)
117
- return { ...defaultConfig(), ...json }
141
+ return { ok: true, exists: true, config: { ...defaultConfig(), ...json } }
118
142
  } catch {
119
- return defaultConfig()
143
+ return { ok: false, exists: true, config: defaultConfig(), error: '这个文件夹里的设置文件坏了。这一版没敢动它,先跳过。' }
120
144
  }
121
145
  }
122
146
 
@@ -127,10 +151,19 @@ export async function readConfig(root) {
127
151
  * 界面拿到的是一句英文报错,人看不出「你还没选文件夹」。
128
152
  */
129
153
  export async function writeConfig(root, patch) {
130
- const current = await readConfig(root)
131
- const next = { ...current, ...patch }
132
- await fs.mkdir(root, { recursive: true })
133
- await fs.writeFile(path.join(root, CONFIG_NAME), JSON.stringify(next, null, 2) + '\n', 'utf8')
154
+ const state = await readConfigState(root)
155
+ // 「读不出来」不等于「还没有」:这时候照写会把整份设置按默认值重刷一遍。
156
+ if (state.ok !== true) return state.config
157
+ const next = { ...state.config, ...patch }
158
+ const abs = path.join(root, CONFIG_NAME)
159
+ const tmp = `${abs}.tmp-${process.pid}-${Math.random().toString(36).slice(2, 8)}`
160
+ try {
161
+ await fs.mkdir(root, { recursive: true })
162
+ await fs.writeFile(tmp, JSON.stringify(next, null, 2) + '\n', 'utf8')
163
+ await fs.rename(tmp, abs)
164
+ } catch {
165
+ await fs.rm(tmp, { force: true }).catch(() => {})
166
+ }
134
167
  return next
135
168
  }
136
169
 
@@ -275,7 +308,10 @@ export async function writeDraft(root, cardId, draft) {
275
308
  avatar_touched: draft?.avatarTouched === true,
276
309
  ...(draft?.avatarTouched === true ? { avatar: draft?.avatar ?? null } : {}),
277
310
  }
278
- await fs.writeFile(abs, JSON.stringify(payload, null, 2) + '\n', 'utf8')
311
+ // 草稿也是「写一半掉电就废」的那种文件,跟卡片一样走 tmp+rename。
312
+ const tmp = `${abs}.tmp-${process.pid}-${Math.random().toString(36).slice(2, 8)}`
313
+ await fs.writeFile(tmp, JSON.stringify(payload, null, 2) + '\n', 'utf8')
314
+ await fs.rename(tmp, abs)
279
315
  return { ok: true, draft: payload }
280
316
  }
281
317
 
@@ -473,8 +509,17 @@ export async function readCard(root, relPath) {
473
509
  let text
474
510
  try {
475
511
  text = await fs.readFile(abs, 'utf8')
476
- } catch {
477
- return { ok: false, path: relPath, error: '文件不存在' }
512
+ } catch (error) {
513
+ // 「没有这个文件」和「有,但读不出来」是两件事。以前一律翻成「文件不存在」,
514
+ // 上层于是把权限问题、同步盘没挂上当「还没有这张卡」——`card.save` 的兜底
515
+ // 会退化成空小节,`applyProposal` 会另建一张同名的卡。
516
+ if (error?.code === 'ENOENT') return { ok: false, path: relPath, error: '文件不存在' }
517
+ return {
518
+ ok: false,
519
+ path: relPath,
520
+ code: String(error?.code ?? ''),
521
+ error: '这个文件读不出来(可能被占用,或者没有读取权限)。关掉占着它的程序,或者检查一下权限。',
522
+ }
478
523
  }
479
524
  const redactions = await readRedactions(root)
480
525
  const hit = hitsRedaction(redactions, relPath, text)
@@ -549,7 +594,17 @@ export async function writeCard(root, relPath, data, sections, options = {}) {
549
594
  }
550
595
  }
551
596
  const text = core.stringifyCard(data, sections)
552
- await fs.writeFile(abs, text, 'utf8')
597
+ // 落盘要走「临时文件 + 改名」。以前这里是 fs.writeFile 直接截断写:
598
+ // 写一半掉电(或宿主把进程杀掉),留下的就是半张卡 —— parseCard 读不出来,
599
+ // 下次打开是「这个文件读不出来」。卡是全库最要紧的那份数据,
600
+ // 别的存档层(tavern/novel/game/presets/career)早就都改了,只有它漏着。
601
+ try {
602
+ const tmp = `${abs}.tmp-${process.pid}-${Math.random().toString(36).slice(2, 8)}`
603
+ await fs.writeFile(tmp, text, 'utf8')
604
+ await fs.rename(tmp, abs)
605
+ } catch {
606
+ return { ok: false, path: relPath, error: '这一版没能存进文件里。检查一下磁盘是不是满了,再试一次。' }
607
+ }
553
608
  return { ok: true, path: relPath, text, validation }
554
609
  }
555
610
 
@@ -761,8 +816,20 @@ export async function appendMaterial(root, name, content, options = {}) {
761
816
  // 但有些材料是「重新摘一遍」而不是「再补一条」——比如从记忆里重新提取
762
817
  // 某个人。那种情况必须覆盖,否则同一个人每点一次就多一份重复的旧摘录,
763
818
  // 而 AI 读的时候会把两份都当真,同一个事实被引用两次。
764
- if (options?.replace === true) await fs.writeFile(abs, body, 'utf8')
765
- else await fs.appendFile(abs, body, 'utf8')
819
+ if (options?.replace === true) {
820
+ // 材料是全库唯一**没有快照**的存档层(删了就真没了)。覆盖之前先留一份
821
+ // `.bak-<时间>`,至少用户自己手改过的那一版还能找回来。
822
+ const had = await fs.stat(abs).then((s) => s.isFile()).catch(() => false)
823
+ if (had) {
824
+ const bak = `${abs}.bak-${freeLocalStamp()}`
825
+ await fs.copyFile(abs, bak).catch(() => {})
826
+ }
827
+ const tmp = `${abs}.tmp-${process.pid}-${Math.random().toString(36).slice(2, 8)}`
828
+ await fs.writeFile(tmp, body, 'utf8')
829
+ await fs.rename(tmp, abs)
830
+ } else {
831
+ await fs.appendFile(abs, body, 'utf8')
832
+ }
766
833
  const full = await fs.readFile(abs, 'utf8')
767
834
  return { ok: true, path: rel, sha256: sha256(full) }
768
835
  }
@@ -1278,6 +1345,11 @@ export async function applyProposal(root, jsonRel, options = {}) {
1278
1345
  if (!read.ok) return read
1279
1346
  const aboutId = read.record?.about?.id
1280
1347
  if (typeof aboutId !== 'string' || aboutId === '') return { ok: false, error: '提案里没有 about.id' }
1348
+ // 「一条都没勾」要在建卡之前判掉。以前是先建卡再判,于是点了采纳却又
1349
+ // 什么都没勾时,工作区里会多出一张空卡,界面上只看到一句「还没有勾选任何一条」——
1350
+ // 用户根本不知道刚才那次点击留下了一张空壳。
1351
+ const chosenFirst = read.items.filter((i) => i.checked)
1352
+ if (chosenFirst.length === 0) return { ok: false, error: '还没有勾选任何一条。先点一下要留下的条目。' }
1281
1353
  let target = await findCardByRelOrId(root, aboutId)
1282
1354
  if (target === null) {
1283
1355
  // 卡还不存在——这正是「说一大段话,让它整理成一张卡」的正常情况。
@@ -1298,8 +1370,7 @@ export async function applyProposal(root, jsonRel, options = {}) {
1298
1370
  }
1299
1371
  const card = await readCard(root, target)
1300
1372
  if (!card.ok) return card
1301
- const chosen = read.items.filter((i) => i.checked)
1302
- if (chosen.length === 0) return { ok: false, error: '还没有勾选任何一条。先点一下要留下的条目。' }
1373
+ const chosen = chosenFirst
1303
1374
 
1304
1375
  // 写进卡之前,引文要对着**现在**的材料再验一遍。
1305
1376
  //
package/lib/tavern.js CHANGED
@@ -347,7 +347,7 @@ export function pickWorldEntries(entries, recentText) {
347
347
  * 刻意写死几句约束:不替对方说话、不跳出角色解释、不加括号注释。
348
348
  * 这三样是角色扮演最容易崩的地方,不写清楚模型每次都会犯。
349
349
  */
350
- export function buildRoleplaySystem({ cardName, persona, worldNotes, memory }) {
350
+ export function buildRoleplaySystem({ cardName, persona, worldNotes, memory, preset }) {
351
351
  const name = cardName === '' ? '这个人' : cardName
352
352
  const lines = [
353
353
  `你正在演一个角色扮演对话。你只演「${name}」这一个人,对方是「我」。`,
@@ -376,6 +376,10 @@ export function buildRoleplaySystem({ cardName, persona, worldNotes, memory }) {
376
376
  '',
377
377
  '直接写你要说的话,不要写「' + name + ':」这样的前缀。',
378
378
  )
379
+ // 演法预设排在最后:它是「这一次想怎么演」,比上面那些通用约束更具体,
380
+ // 放前面会被一长串规则冲淡。
381
+ const flavour = String(preset ?? '').trim()
382
+ if (flavour !== '') lines.push('', '【这一次的演法】', flavour)
379
383
  return lines.join('\n')
380
384
  }
381
385
 
@@ -425,6 +429,56 @@ export function buildSummaryPrompt({ cardName, turns }) {
425
429
  return { system, user }
426
430
  }
427
431
 
432
+ /**
433
+ * 「把这一场里最能代表 TA 的几句挑出来」要发给模型的话。
434
+ *
435
+ * 挑出来之后会写进这张卡的「对话示例」小节。这一小节是下次演 TA 时
436
+ * 最有效的材料之一:比「性格:沉默寡言」有用得多,因为它是真的话。
437
+ */
438
+ export function buildExamplePrompt({ cardName, turns }) {
439
+ const name = cardName === '' ? '这个人' : cardName
440
+ const lines = []
441
+ for (const turn of Array.isArray(turns) ? turns : []) {
442
+ const who = String(turn?.who ?? '').trim()
443
+ const text = String(turn?.text ?? '').trim()
444
+ if (who === '' || text === '') continue
445
+ lines.push(`${who}:${text}`)
446
+ }
447
+ const system = [
448
+ `你要从一段对话里挑出最能代表「${name}」怎么说话的几句。`,
449
+ '挑的是**TA 说的话**,不是「我」说的。',
450
+ '挑四到八句。短句优先。要能看出这个人说话的节奏、用词习惯、回避什么、什么时候会突然多说两句。',
451
+ '每句原样抄下来,一个字都不要改,不要加解释,不要加编号。',
452
+ '一行一句,前面写「TA:」。挑不出来就只输出一个「无」。',
453
+ ].join('\n')
454
+ const user = `扮演的是「${name}」,对面是「我」。下面是这一段对话:\n\n${lines.join('\n')}\n\n请挑出最能代表 TA 说话方式的几句。`
455
+ return { system, user }
456
+ }
457
+
458
+ /**
459
+ * 把模型挑出来的那几行收干净。
460
+ *
461
+ * 它会不老实:加编号、加「TA:」以外的前缀、加解释、或者干脆用引号包起来。
462
+ * 这里只认「TA:」后面的内容,别的都丢掉;同一句重复的只留一次。
463
+ */
464
+ export function cleanExamples(text, limit = 8) {
465
+ const out = []
466
+ const seen = new Set()
467
+ for (const raw of String(text ?? '').split(/\r?\n/)) {
468
+ let line = raw.trim()
469
+ if (line === '' || line === '无' || line === '沒有' || line === '没有') continue
470
+ line = line.replace(/^[-*•\d.、.)\s]+/, '')
471
+ line = line.replace(/^(TA|ta|Ta|她|他)\s*[::]\s*/, '')
472
+ line = line.replace(/^[「“"']|[」”"']$/g, '').trim()
473
+ if (line === '' || line.length < 2) continue
474
+ if (seen.has(line)) continue
475
+ seen.add(line)
476
+ out.push(line)
477
+ if (out.length >= limit) break
478
+ }
479
+ return out
480
+ }
481
+
428
482
  export function recentContextText(turns) {
429
483
  return (Array.isArray(turns) ? turns : [])
430
484
  .slice(-6)
@@ -442,6 +496,7 @@ export default {
442
496
  MEMORY_TITLE,
443
497
  MIN_TURNS_TO_SUMMARIZE,
444
498
  buildSummaryPrompt,
499
+ buildExamplePrompt, cleanExamples,
445
500
  chatRelPath,
446
501
  newChatId,
447
502
  renderChatMarkdown,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-lifecard",
3
- "version": "0.1.2",
3
+ "version": "0.2.0",
4
4
  "description": "Life Cards — 把你认识的人和虚构角色写成档案。在对话里问出来,或导入酒馆角色卡;数据是你自己文件夹里的一组 Markdown 文件。",
5
5
  "type": "module",
6
6
  "icon": "icon.svg",
@@ -33,7 +33,10 @@
33
33
  "lib/jobs-store.js",
34
34
  "lib/jobs-interview.js",
35
35
  "lib/tavern.js",
36
- "lib/novel.js"
36
+ "lib/novel.js",
37
+ "lib/career.js",
38
+ "lib/presets.js",
39
+ "lib/game.js"
37
40
  ],
38
41
  "dsh": {
39
42
  "bundle": {