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/LICENSE +21 -21
- package/README.md +6 -1
- package/cordis.patch.yml +13 -13
- package/lib/index.js +148 -27
- package/lib/index.js.map +1 -1
- package/package.json +1 -1
- package/src/client/markdown-editor.ts +128 -128
- package/src/client/state.ts +39 -39
- package/src/client/toast.ts +22 -22
- package/src/host/tools.ts +90 -18
- package/src/host/write-policy.ts +72 -2
- package/src/index.ts +1 -0
- package/src/sdk.ts +14 -2
- package/docs/code-review-2026-09-17.md +0 -151
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
|
+
"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
|
+
}
|
package/src/client/state.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/client/toast.ts
CHANGED
|
@@ -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`
|
|
370
|
-
*
|
|
371
|
-
*
|
|
372
|
-
*
|
|
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
|
-
|
|
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(
|
|
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
|
|
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}
|
|
397
|
-
return
|
|
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: '
|
|
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: '
|
|
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
|
|
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: '
|
|
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
|
-
|
|
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
|
-
|
|
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 }
|