dsh-tiddlywiki 0.26.4 → 0.26.6

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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-tiddlywiki",
3
3
  "description": "TiddlyWiki 5 as the DSH persistent knowledge base: fifteen tiddlywiki_* agent tools (filtered search/recent/tags/batch/rewrite/trash/lint + git sync & conflict resolve), a full TiddlyWiki editor embedded in the GUI center column (same-origin proxy, works over Tailscale/LAN/domain), a right-side wiki tab, a per-session summary tab, a quick-note card with draft auto-save behind a single knowledge FAB, an optional clip bookmarklet that also saves selected images as attachments, and git-based sync with auto-commit. Mounts via the official dsh plugin system — no DSH source changes.",
4
- "version": "0.26.4",
4
+ "version": "0.26.6",
5
5
  "type": "module",
6
6
  "keywords": [
7
7
  "dsh",
@@ -1,128 +1,128 @@
1
- /**
2
- * CodeMirror 6 Markdown editor for the quick-note widget (replaces the
3
- * hand-rolled regex highlighter in the old markdown.ts — real Lezer syntax
4
- * tree, proper GFM coverage, undo/redo, and a native editing surface).
5
- *
6
- * The widget stays a plain-DOM product: CodeMirror is framework-agnostic, so
7
- * no React is pulled in. The editor is built into a wrapper `<div>`
8
- * (`.dsh-tw-note-editor`) that the widget can append, drag-drop onto, and
9
- * re-read via `getValue()`/`setValue()`.
10
- *
11
- * Theming follows the DSH design tokens (`--dsw-alias-*`) through a
12
- * `HighlightStyle` that maps Markdown tags onto the same palette the old
13
- * `.md-*` rules used, so light/dark both look right.
14
- *
15
- * @module dsh-tiddlywiki/client/markdown-editor
16
- */
17
- import { EditorView, keymap, placeholder, drawSelection } from '@codemirror/view'
18
- import { EditorState } from '@codemirror/state'
19
- import { markdown, markdownLanguage } from '@codemirror/lang-markdown'
20
- import { syntaxHighlighting, HighlightStyle } from '@codemirror/language'
21
- import { defaultKeymap, history, historyKeymap } from '@codemirror/commands'
22
- import { tags as t } from '@lezer/highlight'
23
-
24
- /** Design-token aliases (same values as styles.ts / the old .md-* rules). */
25
- const BRAND = 'var(--dsw-alias-brand-primary, #3e63dd)'
26
- const BRAND_DIM = 'color-mix(in srgb, var(--dsw-alias-brand-primary, #3e63dd) 55%, transparent)'
27
- const SECONDARY = 'var(--dsw-alias-label-secondary, #888)'
28
- const DIMMED = 'var(--dsw-alias-label-dimmed, #999)'
29
- const MONO = 'ui-monospace, "Cascadia Mono", Consolas, "SF Mono", Menlo, monospace'
30
- const CODE_BG = 'color-mix(in srgb, var(--dsw-alias-label-secondary, #888) 12%, transparent)'
31
- const CODE_BLOCK_BG = 'color-mix(in srgb, var(--dsw-alias-label-secondary, #888) 8%, transparent)'
32
-
33
- /** Markdown token → design-token style map (mirrors the old .md-* palette). */
34
- const mdHighlight = HighlightStyle.define([
35
- // Headings (# … ######) — brand + bold.
36
- { tag: [t.heading1, t.heading2, t.heading3, t.heading4, t.heading5, t.heading6], color: BRAND, fontWeight: '700' },
37
- // Markers: #, >, -, *, `, [ ] etc.
38
- { tag: t.processingInstruction, color: BRAND_DIM },
39
- // Inline code + fenced code text — monospace, tinted background.
40
- { tag: t.monospace, fontFamily: MONO, backgroundColor: CODE_BG, borderRadius: '4px', padding: '0 3px' },
41
- // Code block content (inside a fence) gets a slightly wider tint.
42
- { tag: t.content, fontFamily: MONO, backgroundColor: CODE_BLOCK_BG },
43
- { tag: t.strong, fontWeight: '700' },
44
- { tag: t.emphasis, fontStyle: 'italic' },
45
- { tag: t.strikethrough, textDecoration: 'line-through', opacity: '.75' },
46
- { tag: t.link, color: BRAND, textDecoration: 'underline' },
47
- { tag: t.url, color: BRAND_DIM, textDecoration: 'underline dotted' },
48
- { tag: t.quote, fontStyle: 'italic', color: SECONDARY },
49
- { tag: t.contentSeparator, color: DIMMED, textDecoration: 'line-through' },
50
- { tag: t.comment, color: DIMMED },
51
- ])
52
-
53
- /** Public editor surface the quick-note widget consumes. */
54
- export interface MarkdownEditor {
55
- /** Wrapper element (append/drag-target). */
56
- el: HTMLDivElement
57
- /** The CodeMirror EditorView (advanced use / future extensions). */
58
- view: EditorView
59
- getValue(): string
60
- setValue(value: string): void
61
- /** Insert a Markdown line at the current caret (used by file upload). */
62
- insertAtCaret(markdown: string): void
63
- focus(): void
64
- }
65
-
66
- export interface MarkdownEditorOptions {
67
- /** Placeholder text shown when the doc is empty. */
68
- placeholder?: string
69
- /** Called on Ctrl/Cmd+Enter (wired after save is defined by the widget). */
70
- onSave?: () => void
71
- /** Called after every doc change (used for draft auto-save). */
72
- onChange?: () => void
73
- }
74
-
75
- /**
76
- * Build a CodeMirror 6 Markdown editor inside `.dsh-tw-note-editor`.
77
- * GFM base language (strikethrough, tables, task lists, autolinks) — strictly
78
- * more coverage than the old regex highlighter.
79
- */
80
- export function buildMarkdownEditor(opts: MarkdownEditorOptions = {}): MarkdownEditor {
81
- const wrap = document.createElement('div')
82
- wrap.className = 'dsh-tw-note-editor'
83
-
84
- const view = new EditorView({
85
- parent: wrap,
86
- state: EditorState.create({
87
- doc: '',
88
- extensions: [
89
- EditorView.lineWrapping,
90
- history(),
91
- markdown({ base: markdownLanguage }),
92
- syntaxHighlighting(mdHighlight),
93
- drawSelection(),
94
- placeholder(opts.placeholder ?? ''),
95
- keymap.of([
96
- ...defaultKeymap,
97
- ...historyKeymap,
98
- { key: 'Mod-Enter', run: () => { opts.onSave?.(); return true } },
99
- ]),
100
- EditorView.updateListener.of((update) => {
101
- if (update.docChanged) opts.onChange?.()
102
- }),
103
- ],
104
- }),
105
- })
106
-
107
- return {
108
- el: wrap,
109
- view,
110
- getValue: () => view.state.doc.toString(),
111
- setValue(value: string) {
112
- view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: value } })
113
- },
114
- insertAtCaret(markdownLine: string) {
115
- const { from } = view.state.selection.main
116
- const line = view.state.doc.lineAt(from)
117
- const atLineStart = from === line.from
118
- const insert = `${atLineStart ? '' : '\n'}${markdownLine}\n`
119
- view.dispatch({
120
- changes: { from, insert },
121
- selection: { anchor: from + insert.length },
122
- scrollIntoView: true,
123
- })
124
- view.focus()
125
- },
126
- focus: () => view.focus(),
127
- }
128
- }
1
+ /**
2
+ * CodeMirror 6 Markdown editor for the quick-note widget (replaces the
3
+ * hand-rolled regex highlighter in the old markdown.ts — real Lezer syntax
4
+ * tree, proper GFM coverage, undo/redo, and a native editing surface).
5
+ *
6
+ * The widget stays a plain-DOM product: CodeMirror is framework-agnostic, so
7
+ * no React is pulled in. The editor is built into a wrapper `<div>`
8
+ * (`.dsh-tw-note-editor`) that the widget can append, drag-drop onto, and
9
+ * re-read via `getValue()`/`setValue()`.
10
+ *
11
+ * Theming follows the DSH design tokens (`--dsw-alias-*`) through a
12
+ * `HighlightStyle` that maps Markdown tags onto the same palette the old
13
+ * `.md-*` rules used, so light/dark both look right.
14
+ *
15
+ * @module dsh-tiddlywiki/client/markdown-editor
16
+ */
17
+ import { EditorView, keymap, placeholder, drawSelection } from '@codemirror/view'
18
+ import { EditorState } from '@codemirror/state'
19
+ import { markdown, markdownLanguage } from '@codemirror/lang-markdown'
20
+ import { syntaxHighlighting, HighlightStyle } from '@codemirror/language'
21
+ import { defaultKeymap, history, historyKeymap } from '@codemirror/commands'
22
+ import { tags as t } from '@lezer/highlight'
23
+
24
+ /** Design-token aliases (same values as styles.ts / the old .md-* rules). */
25
+ const BRAND = 'var(--dsw-alias-brand-primary, #3e63dd)'
26
+ const BRAND_DIM = 'color-mix(in srgb, var(--dsw-alias-brand-primary, #3e63dd) 55%, transparent)'
27
+ const SECONDARY = 'var(--dsw-alias-label-secondary, #888)'
28
+ const DIMMED = 'var(--dsw-alias-label-dimmed, #999)'
29
+ const MONO = 'ui-monospace, "Cascadia Mono", Consolas, "SF Mono", Menlo, monospace'
30
+ const CODE_BG = 'color-mix(in srgb, var(--dsw-alias-label-secondary, #888) 12%, transparent)'
31
+ const CODE_BLOCK_BG = 'color-mix(in srgb, var(--dsw-alias-label-secondary, #888) 8%, transparent)'
32
+
33
+ /** Markdown token → design-token style map (mirrors the old .md-* palette). */
34
+ const mdHighlight = HighlightStyle.define([
35
+ // Headings (# … ######) — brand + bold.
36
+ { tag: [t.heading1, t.heading2, t.heading3, t.heading4, t.heading5, t.heading6], color: BRAND, fontWeight: '700' },
37
+ // Markers: #, >, -, *, `, [ ] etc.
38
+ { tag: t.processingInstruction, color: BRAND_DIM },
39
+ // Inline code + fenced code text — monospace, tinted background.
40
+ { tag: t.monospace, fontFamily: MONO, backgroundColor: CODE_BG, borderRadius: '4px', padding: '0 3px' },
41
+ // Code block content (inside a fence) gets a slightly wider tint.
42
+ { tag: t.content, fontFamily: MONO, backgroundColor: CODE_BLOCK_BG },
43
+ { tag: t.strong, fontWeight: '700' },
44
+ { tag: t.emphasis, fontStyle: 'italic' },
45
+ { tag: t.strikethrough, textDecoration: 'line-through', opacity: '.75' },
46
+ { tag: t.link, color: BRAND, textDecoration: 'underline' },
47
+ { tag: t.url, color: BRAND_DIM, textDecoration: 'underline dotted' },
48
+ { tag: t.quote, fontStyle: 'italic', color: SECONDARY },
49
+ { tag: t.contentSeparator, color: DIMMED, textDecoration: 'line-through' },
50
+ { tag: t.comment, color: DIMMED },
51
+ ])
52
+
53
+ /** Public editor surface the quick-note widget consumes. */
54
+ export interface MarkdownEditor {
55
+ /** Wrapper element (append/drag-target). */
56
+ el: HTMLDivElement
57
+ /** The CodeMirror EditorView (advanced use / future extensions). */
58
+ view: EditorView
59
+ getValue(): string
60
+ setValue(value: string): void
61
+ /** Insert a Markdown line at the current caret (used by file upload). */
62
+ insertAtCaret(markdown: string): void
63
+ focus(): void
64
+ }
65
+
66
+ export interface MarkdownEditorOptions {
67
+ /** Placeholder text shown when the doc is empty. */
68
+ placeholder?: string
69
+ /** Called on Ctrl/Cmd+Enter (wired after save is defined by the widget). */
70
+ onSave?: () => void
71
+ /** Called after every doc change (used for draft auto-save). */
72
+ onChange?: () => void
73
+ }
74
+
75
+ /**
76
+ * Build a CodeMirror 6 Markdown editor inside `.dsh-tw-note-editor`.
77
+ * GFM base language (strikethrough, tables, task lists, autolinks) — strictly
78
+ * more coverage than the old regex highlighter.
79
+ */
80
+ export function buildMarkdownEditor(opts: MarkdownEditorOptions = {}): MarkdownEditor {
81
+ const wrap = document.createElement('div')
82
+ wrap.className = 'dsh-tw-note-editor'
83
+
84
+ const view = new EditorView({
85
+ parent: wrap,
86
+ state: EditorState.create({
87
+ doc: '',
88
+ extensions: [
89
+ EditorView.lineWrapping,
90
+ history(),
91
+ markdown({ base: markdownLanguage }),
92
+ syntaxHighlighting(mdHighlight),
93
+ drawSelection(),
94
+ placeholder(opts.placeholder ?? ''),
95
+ keymap.of([
96
+ ...defaultKeymap,
97
+ ...historyKeymap,
98
+ { key: 'Mod-Enter', run: () => { opts.onSave?.(); return true } },
99
+ ]),
100
+ EditorView.updateListener.of((update) => {
101
+ if (update.docChanged) opts.onChange?.()
102
+ }),
103
+ ],
104
+ }),
105
+ })
106
+
107
+ return {
108
+ el: wrap,
109
+ view,
110
+ getValue: () => view.state.doc.toString(),
111
+ setValue(value: string) {
112
+ view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: value } })
113
+ },
114
+ insertAtCaret(markdownLine: string) {
115
+ const { from } = view.state.selection.main
116
+ const line = view.state.doc.lineAt(from)
117
+ const atLineStart = from === line.from
118
+ const insert = `${atLineStart ? '' : '\n'}${markdownLine}\n`
119
+ view.dispatch({
120
+ changes: { from, insert },
121
+ selection: { anchor: from + insert.length },
122
+ scrollIntoView: true,
123
+ })
124
+ view.focus()
125
+ },
126
+ focus: () => view.focus(),
127
+ }
128
+ }
@@ -1,39 +1,39 @@
1
- /**
2
- * Tiny pub/sub panel state shared by the sidebar entry and the center panel.
3
- *
4
- * @module dsh-tiddlywiki/client/state
5
- */
6
-
7
- export type PanelListener = (open: boolean) => void
8
-
9
- export class PanelState {
10
- private open = false
11
- private readonly listeners = new Set<PanelListener>()
12
-
13
- isOpen(): boolean {
14
- return this.open
15
- }
16
-
17
- toggle(): void {
18
- this.set(!this.open)
19
- }
20
-
21
- openPanel(): void {
22
- this.set(true)
23
- }
24
-
25
- closePanel(): void {
26
- this.set(false)
27
- }
28
-
29
- set(value: boolean): void {
30
- if (this.open === value) return
31
- this.open = value
32
- for (const listener of [...this.listeners]) listener(value)
33
- }
34
-
35
- subscribe(listener: PanelListener): () => void {
36
- this.listeners.add(listener)
37
- return () => { this.listeners.delete(listener) }
38
- }
39
- }
1
+ /**
2
+ * Tiny pub/sub panel state shared by the sidebar entry and the center panel.
3
+ *
4
+ * @module dsh-tiddlywiki/client/state
5
+ */
6
+
7
+ export type PanelListener = (open: boolean) => void
8
+
9
+ export class PanelState {
10
+ private open = false
11
+ private readonly listeners = new Set<PanelListener>()
12
+
13
+ isOpen(): boolean {
14
+ return this.open
15
+ }
16
+
17
+ toggle(): void {
18
+ this.set(!this.open)
19
+ }
20
+
21
+ openPanel(): void {
22
+ this.set(true)
23
+ }
24
+
25
+ closePanel(): void {
26
+ this.set(false)
27
+ }
28
+
29
+ set(value: boolean): void {
30
+ if (this.open === value) return
31
+ this.open = value
32
+ for (const listener of [...this.listeners]) listener(value)
33
+ }
34
+
35
+ subscribe(listener: PanelListener): () => void {
36
+ this.listeners.add(listener)
37
+ return () => { this.listeners.delete(listener) }
38
+ }
39
+ }
@@ -1,22 +1,22 @@
1
- /**
2
- * One-shot transient toast, shared by the quick-note widget and the settings
3
- * page. Reuses a single body-level element (`.dsh-tw-toast`) with a show
4
- * class; the element is created lazily and styled by styles.ts.
5
- *
6
- * @module dsh-tiddlywiki/client/toast
7
- */
8
-
9
- export function toast(message: string): void {
10
- let el = document.querySelector<HTMLElement>('.dsh-tw-toast')
11
- if (el === null) {
12
- el = document.createElement('div')
13
- el.className = 'dsh-tw-toast'
14
- document.body.append(el)
15
- }
16
- el.textContent = message
17
- el.classList.add('dsh-tw-toast-show')
18
- clearTimeout((el as HTMLElement & { __twToastTimer?: number }).__twToastTimer)
19
- ;(el as HTMLElement & { __twToastTimer?: number }).__twToastTimer = window.setTimeout(() => {
20
- el?.classList.remove('dsh-tw-toast-show')
21
- }, 2_500)
22
- }
1
+ /**
2
+ * One-shot transient toast, shared by the quick-note widget and the settings
3
+ * page. Reuses a single body-level element (`.dsh-tw-toast`) with a show
4
+ * class; the element is created lazily and styled by styles.ts.
5
+ *
6
+ * @module dsh-tiddlywiki/client/toast
7
+ */
8
+
9
+ export function toast(message: string): void {
10
+ let el = document.querySelector<HTMLElement>('.dsh-tw-toast')
11
+ if (el === null) {
12
+ el = document.createElement('div')
13
+ el.className = 'dsh-tw-toast'
14
+ document.body.append(el)
15
+ }
16
+ el.textContent = message
17
+ el.classList.add('dsh-tw-toast-show')
18
+ clearTimeout((el as HTMLElement & { __twToastTimer?: number }).__twToastTimer)
19
+ ;(el as HTMLElement & { __twToastTimer?: number }).__twToastTimer = window.setTimeout(() => {
20
+ el?.classList.remove('dsh-tw-toast-show')
21
+ }, 2_500)
22
+ }
package/src/host/tools.ts CHANGED
@@ -27,6 +27,7 @@ import {
27
27
  assertNoConflict,
28
28
  buildWriteTiddler,
29
29
  flattenTiddlerFields,
30
+ normalizeFieldsArg,
30
31
  normalizeTagArg,
31
32
  } from './write-policy.ts'
32
33
  import { WORKSPACE_FIELD, WORKSPACE_TAG_PREFIX, workspaceMarkFromCwd } from './workspace.ts'
@@ -112,12 +113,37 @@ function workspaceMarkFor(deps: ToolsDeps, exec: unknown): { id: string; tag: st
112
113
  return workspaceMarkFromCwd(deps.workspaceName(sessionId))
113
114
  }
114
115
 
116
+ /**
117
+ * 工具参数的 `fields` 归一化(v0.26.5,见 `normalizeFieldsArg`)。
118
+ *
119
+ * 历史:`fields` 的参数 schema 曾是 `{ type: 'json' }`——编译后是**纯注解**,
120
+ * 模型收不到「这是个对象」的信息,于是把 JSON 当字符串发来,被
121
+ * `withWorkspaceMark` 的 `{ ...fields }` 拆成单字符字段。schema 已改成 object;
122
+ * 这里再兜一层,字符串能解析成对象就直接用,不必让模型重发一次。
123
+ * 解析不出对象 → 抛错(绝不静默丢字段,也不按字符展开)。
124
+ */
125
+ function normalizeFieldsInArgs(args: Record<string, unknown>): Record<string, unknown> {
126
+ if (!('fields' in args)) return args
127
+ return { ...args, fields: normalizeFieldsArg(args.fields) }
128
+ }
129
+
130
+ /** batch_put 专用:逐条目归一化 `fields`(非对象条目原样留给逐条校验报错)。 */
131
+ function normalizeBatchItemsFields(items: unknown): unknown {
132
+ if (!Array.isArray(items)) return items
133
+ return items.map((item) => (item !== null && typeof item === 'object' && !Array.isArray(item))
134
+ ? normalizeFieldsInArgs(item as Record<string, unknown>)
135
+ : item)
136
+ }
137
+
115
138
  /**
116
139
  * Merge the workspace marker into a NEW note's explicit tags and fields.
117
140
  *
118
141
  * ADDITIVE, never replacing: the caller's tags are kept in front and the caller's
119
142
  * own `workspace` field (if it set one) wins, so an explicit value is never
120
143
  * silently overwritten. Returns the inputs untouched when there is no marker.
144
+ *
145
+ * ⚠️ `fields` 必须已经过 `normalizeFieldsArg()`(v0.26.5):下面这行 `{ ...fields }`
146
+ * 对字符串会按字符展开成 `{0:'{',1:'"',…}`——那正是 fields bug 的现场。
121
147
  */
122
148
  function withWorkspaceMark(
123
149
  mark: { id: string; tag: string } | undefined,
@@ -366,23 +392,47 @@ function rewriteRefs(text: string, oldTitle: string, newTitle: string): { text:
366
392
  }
367
393
 
368
394
  /**
369
- * Insert `addition` at the end of the section introduced by `heading` (Markdown
370
- * `#`/`##`… or wikitext `!`/`!!`…). Falls back to appending at the end of the
371
- * document when the heading is not found. Used by tiddlywiki_append so an agent
372
- * can add to one section of a long note without rewriting the whole file.
395
+ * Insert `addition` into the section introduced by `heading` (Markdown `#`/`##`…
396
+ * or wikitext `!`/`!!`…). Used by tiddlywiki_append so an agent can add to one
397
+ * section of a long note without rewriting the whole file.
398
+ *
399
+ * **落点语义**:插在「该标题之后、**下一个任意级别的标题**之前」。所以当标题后面
400
+ * 紧跟子标题时(`## 4. 定时任务` + `### 槽位表`),内容落在两者**之间**,也就是
401
+ * 「章节开头」而不是「整节末尾」——这一点以前只在 schema 里写成「段落的末尾」,
402
+ * 容易让调用方误会(v0.26.6 起描述与回执都写清楚了)。
403
+ *
404
+ * **返回 `matched`**:`false` = 没找到该标题,文本被追加到**文末**。调用方**必须**
405
+ * 把它透出去(v0.26.6 之前这个回退是静默的,回执照样打「段落「X」」,
406
+ * 于是一次定位失败看起来像成功——Agent 没有任何办法自检)。
407
+ *
408
+ * **CRLF(v0.26.6 修复)**:tiddler 正文可能是 CRLF(Windows 上写的/同步来的笔记),
409
+ * 而 `split('\n')` 会让每行结尾都留着 `\r`。JS 里 `(.*)` 不匹配 `\r`、`$` 也不匹配
410
+ * `\r` 之前的位置,于是 `/^#{1,6}\s+(.*)$/` 对**每一个**标题都匹配失败 ⇒ 所有带
411
+ * `heading` 的 append 都静默落到了文末。**只在 CRLF 笔记上复现**(纯 LF 笔记一切正常),
412
+ * 这就是它像「偶发」的原因。现在只在**匹配时**剥掉行尾 CR,原文行尾一个字节都不动;
413
+ * 并且新块用**文档自己的行尾**写入,避免 CRLF 笔记被越写越乱(混入 LF 块)。
373
414
  */
374
- function insertIntoSection(base: string, heading: string, addition: string): string {
415
+ function insertIntoSection(base: string, heading: string, addition: string): { text: string; matched: boolean } {
375
416
  const lines = base.split('\n')
376
417
  const headingText = (line: string): string | null => {
377
- const md = /^#{1,6}\s+(.*)$/.exec(line)
418
+ // 只在匹配时剥离行尾 CR:CRLF 笔记的每一行都会带上它。
419
+ const s = line.replace(/\r$/, '')
420
+ const md = /^#{1,6}\s+(.*)$/.exec(s)
378
421
  if (md !== null) return (md[1] ?? '').trim()
379
- const tw = /^!{1,6}\s*(.*)$/.exec(line)
422
+ const tw = /^!{1,6}\s*(.*)$/.exec(s)
380
423
  if (tw !== null) return (tw[1] ?? '').trim()
381
424
  return null
382
425
  }
383
426
  const start = lines.findIndex((line) => headingText(line) === heading)
427
+ // 新块跟随文档主流行尾:CRLF 文档里不要再塞 LF 块。
428
+ const eol = base.includes('\r\n') ? '\r\n' : '\n'
429
+ const adapt = (s: string): string => (eol === '\n' ? s.replace(/\r\n/g, '\n') : s.replace(/\r\n/g, '\n').split('\n').join('\r\n'))
430
+ const block = adapt(addition)
384
431
  if (start < 0) {
385
- return base.trim().length === 0 ? addition : `${base.replace(/\s+$/, '')}\n\n${addition}`
432
+ return {
433
+ text: base.trim().length === 0 ? block : `${base.replace(/\s+$/, '')}${eol}${eol}${block}`,
434
+ matched: false,
435
+ }
386
436
  }
387
437
  let end = lines.length
388
438
  for (let i = start + 1; i < lines.length; i++) {
@@ -393,8 +443,11 @@ function insertIntoSection(base: string, heading: string, addition: string): str
393
443
  }
394
444
  const before = lines.slice(0, end).join('\n').replace(/\s+$/, '')
395
445
  const after = lines.slice(end).join('\n')
396
- const head = `${before}\n\n${addition}`
397
- return after.trim().length === 0 ? head : `${head}\n\n${after.replace(/^\s+/, '')}`
446
+ const head = `${before}${eol}${eol}${block}`
447
+ return {
448
+ text: after.trim().length === 0 ? head : `${head}${eol}${eol}${after.replace(/^\s+/, '')}`,
449
+ matched: true,
450
+ }
398
451
  }
399
452
 
400
453
 
@@ -624,11 +677,14 @@ export function registerTiddlywikiTools(ctx: ToolsCtx, deps: ToolsDeps): Array<(
624
677
  title: { type: 'string', description: 'tiddler 标题(精确匹配,覆盖同名)', required: true },
625
678
  text: { type: 'string', description: 'tiddler 全文(默认按 Markdown 解析)', required: true },
626
679
  tags: { type: 'array', items: { type: 'string' }, description: '标签数组(可选)。不传 = 保留既有条目的原标签;传空数组 [] = 清空全部标签;有内容 = 整体替换' },
627
- fields: { type: 'json', description: '附加自定义字段,如 {"date":"2026-09-02"}(可选)。fields.type 是**改内容类型的正规入口**(如 {"type":"text/css"}):新建条目未指定时默认 text/markdown,覆盖既有条目时保留原类型。注意不要把业务分类值(如 "meeting")写进 type——业务分类请放 tags' },
680
+ fields: { type: 'object', additionalProperties: true, description: '附加自定义字段,如 {"date":"2026-09-02"}(可选)。fields.type 是**改内容类型的正规入口**(如 {"type":"text/css"}):新建条目未指定时默认 text/markdown,覆盖既有条目时保留原类型。注意不要把业务分类值(如 "meeting")写进 type——业务分类请放 tags' },
628
681
  expectedModified: { type: 'string', description: '可选:乐观并发保护。传 tiddlywiki_get 读到的 modified 值,若该条目已被他人改动则拒绝写入(避免覆盖人类在 TW 编辑器里的修改)' },
629
682
  expectedRevision: { type: 'integer', description: '可选:乐观并发保护的另一种令牌——传 tiddlywiki_get 返回字段里的 revision(刚写入、还没落盘的条目没有 modified,此时用 revision)' },
630
683
  force: { type: 'boolean', description: '可选:true 时忽略 expectedModified/expectedRevision 强制覆盖(默认 false)' },
631
684
  },
685
+ // v0.26.5: `fields` 是对象,但模型可能按 JSON 字符串发来(历史遗留),
686
+ // 归一化必须发生在 schema 预校验之前——见 normalizeFieldsArg。
687
+ normalizeArgs: normalizeFieldsInArgs,
632
688
  output: {
633
689
  render: (_args, value: PutResult) => {
634
690
  const lines = [`已写入 tiddler「${value.title}」`]
@@ -715,13 +771,14 @@ export function registerTiddlywikiTools(ctx: ToolsCtx, deps: ToolsDeps): Array<(
715
771
  title: { type: 'string', description: '标题(精确匹配,覆盖同名)' },
716
772
  text: { type: 'string', description: '全文(默认按 Markdown 解析)' },
717
773
  tags: { type: 'array', items: { type: 'string' }, description: '标签数组(可选)。不传 = 保留原标签;传空数组 [] = 清空全部标签' },
718
- fields: { type: 'json', description: '附加自定义字段(可选)。注意:fields.type 是 TW 内容类型(保留字段,默认已自动补 text/markdown),不要写业务分类值' },
774
+ fields: { type: 'object', additionalProperties: true, description: '附加自定义字段(可选)。注意:fields.type 是 TW 内容类型(保留字段,默认已自动补 text/markdown),不要写业务分类值' },
719
775
  expectedModified: { type: 'string', description: '可选(v0.25.0):该条目的乐观并发令牌,来自 tiddlywiki_get 的 modified。覆盖已存在条目时若已被改动,只让这一条失败并说明原因,其余条目照常' },
720
776
  },
721
777
  },
722
778
  },
723
779
  overwrite: { type: 'boolean', description: '可选:true=覆盖同名(默认),false=跳过已存在的标题' },
724
780
  },
781
+ normalizeArgs: (args) => ({ ...args, items: normalizeBatchItemsFields(args.items) }),
725
782
  output: {
726
783
  render: (_args, value: BatchResult) => {
727
784
  const lines = [`批量写入完成:成功 ${value.written},跳过 ${value.skipped},失败 ${value.failed},共 ${value.items.length} 条。`]
@@ -1070,22 +1127,30 @@ export function registerTiddlywikiTools(ctx: ToolsCtx, deps: ToolsDeps): Array<(
1070
1127
  // ── tiddlywiki_append ────────────────────────────────────────────────────
1071
1128
  register(defineTool({
1072
1129
  name: 'tiddlywiki_append',
1073
- description: '向已有 tiddler 追加/前插文本,或写入指定标题段落的末尾(无需先读全文、不会整篇覆盖)——适合日志、批注、清单的增量写入。条目不存在时默认新建(createIfMissing=false 则报错)。**写入既有条目走与 tiddlywiki_put 同一套写策略**:原有 tags、自定义字段与**内容类型**全部保留(不会把 Markdown 笔记改成 wikitext、也不会把 CSS 改成 Markdown);可用 fields 显式覆盖字段/类型。新建条目未指定内容类型时才默认 text/markdown(并像 tiddlywiki_put 一样自动补 agent-written 与 `ws/<项目名>` 工作区标记)。⚠️ 本工具内部是「读全文 → 整篇回写」,属于覆盖写入:改前请先 `tiddlywiki_get`,并把读到的 `modified` 作为 `expectedModified` 传回(v0.25.0 起支持),否则人类在 TW 编辑器里的并发修改会被静默回滚。',
1130
+ description: '向已有 tiddler 追加/前插文本,或写入指定标题段落的末尾(无需先读全文、不会整篇覆盖)——适合日志、批注、清单的增量写入。条目不存在时默认新建(createIfMissing=false 则报错)。**写入既有条目走与 tiddlywiki_put 同一套写策略**:原有 tags、自定义字段与**内容类型**全部保留(不会把 Markdown 笔记改成 wikitext、也不会把 CSS 改成 Markdown);可用 fields 显式覆盖字段/类型。新建条目未指定内容类型时才默认 text/markdown(并像 tiddlywiki_put 一样自动补 agent-written 与 `ws/<项目名>` 工作区标记)。⚠️ 本工具内部是「读全文 → 整篇回写」,属于覆盖写入:改前请先 `tiddlywiki_get`,并把读到的 `modified` 作为 `expectedModified` 传回(v0.25.0 起支持),否则人类在 TW 编辑器里的并发修改会被静默回滚。带 `heading` 时返回 `headingMatched`:`false` = 没找到该标题、文本已追加到**文末**(CRLF 行尾的笔记也能正确定位)。',
1074
1131
  parameters: {
1075
1132
  title: { type: 'string', description: 'tiddler 标题', required: true },
1076
1133
  text: { type: 'string', description: '要追加/前插的文本(默认按 Markdown 写;既有条目保持它自己的内容类型)', required: true },
1077
1134
  mode: { type: 'string', enum: ['append', 'prepend'], description: '可选:append(默认,追加到末尾)/ prepend(插到开头)' },
1078
- heading: { type: 'string', description: '可选:append 时改为插入到该标题(Markdown # 或 wikitext ! 标题,按标题文本匹配)对应段落的末尾' },
1135
+ heading: { type: 'string', description: '可选:append 时改为写入指定标题的位置(Markdown # 或 wikitext ! 标题,按标题文本匹配、忽略标题级别)。**落点 = 该标题之后、下一个任意级别标题之前**:若该标题后紧跟子标题,内容插在两者之间(即章节开头),**不是整节末尾**。标题不存在时不报错,改为追加到文末,并在回执与返回的 headingMatched=false 里明确说明(别把「没命中」当「写进去了」)' },
1079
1136
  createIfMissing: { type: 'boolean', description: '可选:条目不存在时是否新建(默认 true)' },
1080
1137
  tags: { type: 'array', items: { type: 'string' }, description: '可选:标签(不传则保留既有条目的原标签,传空数组 [] 表示清空全部标签;新建条目会额外自动补 agent-written)' },
1081
- fields: { type: 'json', description: '可选:显式覆盖的自定义字段(如 {"type":"text/css"})。不传则保留既有条目的原字段与内容类型' },
1138
+ fields: { type: 'object', additionalProperties: true, description: '可选:显式覆盖的自定义字段(如 {"type":"text/css"})。不传则保留既有条目的原字段与内容类型' },
1082
1139
  expectedModified: { type: 'string', description: '可选:乐观并发保护。传 tiddlywiki_get 读到的 modified;若条目在你读取之后被改动(人类在 TW 编辑器里改过)则拒绝写入——本工具是整篇回写,这一步能防止吞掉别人的修改' },
1083
1140
  expectedRevision: { type: 'integer', description: '可选:乐观并发保护的另一种令牌——传 tiddlywiki_get 返回字段里的 revision(注意 revision 是 TW 的内存计数器,重启后会复位,长时间跨度请用 expectedModified)' },
1084
1141
  force: { type: 'boolean', description: '可选:true 时忽略 expectedModified/expectedRevision 强制写入(默认 false)' },
1085
1142
  },
1143
+ normalizeArgs: normalizeFieldsInArgs,
1086
1144
  output: {
1087
1145
  render: (_args, value: AppendResult) => {
1088
- const lines = [`${value.created ? '已新建并写入' : '已增量写入'} tiddler「${value.title}」(${value.mode}${value.heading !== null ? ` · 段落「${value.heading}」` : ''}):新增 ${value.added} 字符,现共 ${value.total} 字符。`]
1146
+ // v0.26.6:标题没定位到时必须说出来。以前回执无条件打「段落「X」」,
1147
+ // 于是「静默追加到文末」看起来跟「精确写进那一节」一模一样。
1148
+ const headingNote = value.heading === null
1149
+ ? ''
1150
+ : value.headingMatched === false
1151
+ ? ` · ⚠️ 未找到标题「${value.heading}」,已改为追加到文末`
1152
+ : ` · 段落「${value.heading}」`
1153
+ const lines = [`${value.created ? '已新建并写入' : '已增量写入'} tiddler「${value.title}」(${value.mode}${headingNote}):新增 ${value.added} 字符,现共 ${value.total} 字符。`]
1089
1154
  if (value.type !== null) lines.push(`类型: ${value.type}${value.typeDefaulted === true ? '(新建且未指定,已默认 markdown)' : ''}`)
1090
1155
  if (value.typeChanged !== undefined) lines.push(`⚠️ 内容类型已从 ${value.typeChanged.from} 改为 ${value.typeChanged.to}`)
1091
1156
  if (value.workspace !== undefined) lines.push(`已自动标记工作区: ${WORKSPACE_TAG_PREFIX}${value.workspace}(字段 ${WORKSPACE_FIELD})`)
@@ -1114,8 +1179,11 @@ export function registerTiddlywikiTools(ctx: ToolsCtx, deps: ToolsDeps): Array<(
1114
1179
  const base = existing?.text ?? ''
1115
1180
  const addition = args.text
1116
1181
  let next: string
1182
+ let headingMatched: boolean | undefined
1117
1183
  if (mode === 'append' && typeof args.heading === 'string' && args.heading.trim().length > 0) {
1118
- next = insertIntoSection(base, args.heading.trim(), addition)
1184
+ const placed = insertIntoSection(base, args.heading.trim(), addition)
1185
+ next = placed.text
1186
+ headingMatched = placed.matched
1119
1187
  } else if (mode === 'prepend') {
1120
1188
  next = base.trim().length === 0 ? addition : `${addition}\n\n${base}`
1121
1189
  } else {
@@ -1146,6 +1214,10 @@ export function registerTiddlywikiTools(ctx: ToolsCtx, deps: ToolsDeps): Array<(
1146
1214
  heading: mode === 'append' && typeof args.heading === 'string' && args.heading.trim().length > 0
1147
1215
  ? args.heading.trim()
1148
1216
  : null,
1217
+ // v0.26.6:给了 heading 时它到底有没有定位成功。false = 没找到该标题、文本被
1218
+ // 追加到了**文末**。此前这个回退是静默的,回执还照打「段落「X」」,
1219
+ // 导致一次定位失败看起来完全像成功(Agent 无法自检)。
1220
+ ...(headingMatched !== undefined ? { headingMatched } : {}),
1149
1221
  created: existing === undefined,
1150
1222
  added: addition.length,
1151
1223
  total: next.length,
@@ -1685,7 +1757,7 @@ interface RenameResult { ok: boolean; from: string; to: string; refsUpdated: num
1685
1757
  interface DeleteResult { ok: boolean; title: string; /** true = moved to the trash (recoverable). */ trashed?: boolean; trashTitle?: string }
1686
1758
  interface TrashItem { title: string; at?: string; of?: string }
1687
1759
  interface TrashResult { action: string; message: string; items?: TrashItem[] }
1688
- interface AppendResult { ok: boolean; title: string; mode: 'append' | 'prepend'; heading: string | null; created: boolean; added: number; total: number; type?: string | null; typeDefaulted?: boolean; typeChanged?: { from: string; to: string }; /** 新建时自动打上的工作区标记(v0.24.0)。 */ workspace?: string }
1760
+ interface AppendResult { ok: boolean; title: string; mode: 'append' | 'prepend'; heading: string | null; /** v0.26.6:给了 heading 时该标题是否真的定位成功(false = 未找到,文本已追加到文末)。 */ headingMatched?: boolean; created: boolean; added: number; total: number; type?: string | null; typeDefaulted?: boolean; typeChanged?: { from: string; to: string }; /** 新建时自动打上的工作区标记(v0.24.0)。 */ workspace?: string }
1689
1761
  interface BacklinkHit { title: string; refs: number; via: 'link' | 'tag'; modified: string | null }
1690
1762
  interface BacklinkResult { title: string; total: number; linkCount: number; tagCount: number; items: BacklinkHit[] }
1691
1763
  interface AttachResult { ok: boolean; title: string; mime: string; bytes: number; chars: number; source: string | null; embedInto: string | null }