dsh-tiddlywiki 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,254 @@
1
+ /**
2
+ * The five `tiddlywiki_*` agent tools (design doc §11, D8) plus the extension
3
+ * point: `registerTiddlywikiTools(ctx, deps)` registers tools list-style, so a
4
+ * new tool is just one more `defineTool` in the array — index.ts never changes.
5
+ *
6
+ * RENDER CONTRACT (design doc §4.3): the registry feeds `output.render(args,
7
+ * value)` into the loop — the model sees ONLY the rendered text, never the raw
8
+ * JSON `value`. Every render must carry the complete facts an agent needs to
9
+ * act (titles, tags, snippets, git state); a terse UI summary starves it.
10
+ *
11
+ * @module dsh-tiddlywiki/host/tools
12
+ */
13
+ import { defineTool } from '../sdk.ts'
14
+ import type { TiddlyWebClient, Tiddler } from './tw-api.ts'
15
+ import type { GitFace } from './git.ts'
16
+
17
+ /** Structural tool-registry face (subset of the dsh tools service). */
18
+ export interface ToolsCtx {
19
+ tools: { register(tool: unknown): () => void }
20
+ }
21
+
22
+ export interface ToolsDeps {
23
+ /** Lazy TW client — undefined while the service is not up. */
24
+ wiki: () => TiddlyWebClient | undefined
25
+ git: GitFace
26
+ wikiPath: () => string
27
+ noteTag: () => string
28
+ /** Debounced auto-commit touch (fires after our writes). */
29
+ autoCommit: () => void
30
+ }
31
+
32
+ function snippetOf(text: string, max = 160): string {
33
+ const flat = text.replace(/\s+/g, ' ').trim()
34
+ return flat.length <= max ? flat : `${flat.slice(0, max)}…`
35
+ }
36
+
37
+ /** Strip dsh-tiddlywiki internal fields from a tiddler for the model. */
38
+ function pickFields(t: Tiddler): Record<string, unknown> {
39
+ const out: Record<string, unknown> = {}
40
+ for (const [k, v] of Object.entries(t)) {
41
+ if (k === 'title' || k === 'text' || k === 'tags') continue
42
+ out[k] = v
43
+ }
44
+ return out
45
+ }
46
+
47
+ export function registerTiddlywikiTools(ctx: ToolsCtx, deps: ToolsDeps): Array<() => void> {
48
+ const disposers: Array<() => void> = []
49
+ const register = (tool: unknown): void => { disposers.push(ctx.tools.register(tool)) }
50
+
51
+ // ── tiddlywiki_search ────────────────────────────────────────────────────
52
+ register(defineTool({
53
+ name: 'tiddlywiki_search',
54
+ description: '检索 TiddlyWiki 持久知识库:按关键词(可选 tag 精确匹配)搜索非系统 tiddler,返回标题、标签与摘要片段。',
55
+ parameters: {
56
+ query: { type: 'string', description: '搜索关键词(大小写不敏感,子串匹配)', required: true },
57
+ tag: { type: 'string', description: '可选:只返回带该 tag 的 tiddler' },
58
+ },
59
+ output: {
60
+ schema: { type: 'json' },
61
+ render: (_args, value: SearchResult) => {
62
+ const lines = [`TiddlyWiki 搜索「${value.query}」${value.tag !== null ? ` (tag=${value.tag})` : ''}:命中 ${value.count} 条。`]
63
+ if (value.results.length === 0) lines.push('没有匹配的 tiddler。')
64
+ for (const r of value.results) {
65
+ const tags = r.tags.length > 0 ? ` [${r.tags.join(', ')}]` : ''
66
+ lines.push(`- ${r.title}${tags}`)
67
+ if (r.snippet.length > 0) lines.push(` ${r.snippet}`)
68
+ }
69
+ if (value.count > value.results.length) lines.push(`(另有 ${value.count - value.results.length} 条未展开,可用 tiddlywiki_get 读取具体标题)`)
70
+ return [{ type: 'text', text: lines.join('\n') }]
71
+ },
72
+ },
73
+ execute: async (args: { query: string; tag?: string }): Promise<SearchResult> => {
74
+ const wiki = deps.wiki()
75
+ if (wiki === undefined) throw new Error('TiddlyWiki 服务未运行(tiddlywiki_status 可查)')
76
+ const results = await wiki.search(args.query, args.tag)
77
+ return {
78
+ query: args.query,
79
+ tag: args.tag ?? null,
80
+ count: results.length,
81
+ results: results.map((t) => ({ title: t.title, tags: t.tags ?? [], snippet: snippetOf(t.text ?? '') })),
82
+ }
83
+ },
84
+ }))
85
+
86
+ // ── tiddlywiki_get ───────────────────────────────────────────────────────
87
+ register(defineTool({
88
+ name: 'tiddlywiki_get',
89
+ description: '读取一个 TiddlyWiki tiddler 的完整内容(标题、全文、标签、自定义字段)。',
90
+ parameters: {
91
+ title: { type: 'string', description: 'tiddler 标题(精确匹配)', required: true },
92
+ },
93
+ output: {
94
+ schema: { type: 'json' },
95
+ render: (_args, value: GetResult) => {
96
+ if (value.notFound) return [{ type: 'text', text: `tiddler「${value.title}」不存在。可用 tiddlywiki_search 检索,或用 tiddlywiki_put 新建。` }]
97
+ const lines = [`tiddler「${value.title}」`]
98
+ if (value.tags.length > 0) lines.push(`标签: ${value.tags.join(', ')}`)
99
+ const fields = Object.entries(value.fields)
100
+ if (fields.length > 0) lines.push(`字段: ${fields.map(([k, v]) => `${k}=${String(v)}`).join(', ')}`)
101
+ lines.push('--- 全文 ---')
102
+ lines.push(value.text.length > 0 ? value.text : '(空)')
103
+ return [{ type: 'text', text: lines.join('\n') }]
104
+ },
105
+ },
106
+ execute: async (args: { title: string }): Promise<GetResult> => {
107
+ const wiki = deps.wiki()
108
+ if (wiki === undefined) throw new Error('TiddlyWiki 服务未运行(tiddlywiki_status 可查)')
109
+ const t = await wiki.get(args.title)
110
+ if (t === undefined) return { notFound: true, title: args.title, text: '', tags: [], fields: {} }
111
+ return { notFound: false, title: t.title, text: t.text ?? '', tags: t.tags ?? [], fields: pickFields(t) }
112
+ },
113
+ }))
114
+
115
+ // ── tiddlywiki_put ───────────────────────────────────────────────────────
116
+ register(defineTool({
117
+ name: 'tiddlywiki_put',
118
+ description: '写入(新建或覆盖)一个 TiddlyWiki tiddler。同名覆盖;tags 为标签数组,fields 为附加自定义字段(json 对象,会写入 tiddler 字段)。写入后触发自动 commit。',
119
+ parameters: {
120
+ title: { type: 'string', description: 'tiddler 标题(精确匹配,覆盖同名)', required: true },
121
+ text: { type: 'string', description: 'tiddler 全文(wiki 文本)', required: true },
122
+ tags: { type: 'array', items: { type: 'string' }, description: '标签数组(可选)' },
123
+ fields: { type: 'json', description: '附加自定义字段,如 {"type":"meeting","date":"2026-09-02"}(可选)' },
124
+ },
125
+ output: {
126
+ schema: { type: 'json' },
127
+ render: (_args, value: PutResult) => {
128
+ const lines = [`已写入 tiddler「${value.title}」`]
129
+ if (value.tags.length > 0) lines.push(`标签: ${value.tags.join(', ')}`)
130
+ if (value.fields !== null) {
131
+ const entries = Object.entries(value.fields)
132
+ if (entries.length > 0) lines.push(`字段: ${entries.map(([k, v]) => `${k}=${String(v)}`).join(', ')}`)
133
+ }
134
+ return [{ type: 'text', text: lines.join('\n') }]
135
+ },
136
+ },
137
+ execute: async (args: { title: string; text: string; tags?: string[]; fields?: Record<string, unknown> }): Promise<PutResult> => {
138
+ const wiki = deps.wiki()
139
+ if (wiki === undefined) throw new Error('TiddlyWiki 服务未运行(tiddlywiki_status 可查)')
140
+ const tiddler: Tiddler = { title: args.title, text: args.text }
141
+ if (Array.isArray(args.tags) && args.tags.length > 0) tiddler.tags = args.tags
142
+ if (args.fields !== undefined && typeof args.fields === 'object' && args.fields !== null) Object.assign(tiddler, args.fields)
143
+ await wiki.put(tiddler)
144
+ deps.autoCommit()
145
+ return { ok: true, title: args.title, tags: args.tags ?? [], fields: args.fields ?? null }
146
+ },
147
+ }))
148
+
149
+ // ── tiddlywiki_delete ────────────────────────────────────────────────────
150
+ register(defineTool({
151
+ name: 'tiddlywiki_delete',
152
+ description: '删除一个 TiddlyWiki tiddler(不存在时是幂等空操作)。删除后触发自动 commit。',
153
+ parameters: {
154
+ title: { type: 'string', description: 'tiddler 标题(精确匹配)', required: true },
155
+ },
156
+ output: {
157
+ schema: { type: 'json' },
158
+ render: (_args, value: DeleteResult) => [{ type: 'text', text: `已删除 tiddler「${value.title}」。` }],
159
+ },
160
+ execute: async (args: { title: string }): Promise<DeleteResult> => {
161
+ const wiki = deps.wiki()
162
+ if (wiki === undefined) throw new Error('TiddlyWiki 服务未运行(tiddlywiki_status 可查)')
163
+ await wiki.delete(args.title)
164
+ deps.autoCommit()
165
+ return { ok: true, title: args.title }
166
+ },
167
+ }))
168
+
169
+ // ── tiddlywiki_git_sync ──────────────────────────────────────────────────
170
+ register(defineTool({
171
+ name: 'tiddlywiki_git_sync',
172
+ description: '对 TiddlyWiki 知识库的 git 仓库做同步:pull(拉取远端并 rebase 本地,冲突则 abort 并报文件)、push(推送本地提交到远端)、sync(pull → commit 本地改动 → push)。未配置 git.remote 时 push 会失败并提示。',
173
+ parameters: {
174
+ action: { type: 'string', enum: ['pull', 'push', 'sync'], description: '要执行的 git 操作', required: true },
175
+ message: { type: 'string', description: 'commit 信息(可选,仅 sync 的本地 commit 使用)' },
176
+ },
177
+ output: {
178
+ schema: { type: 'json' },
179
+ render: (_args, value: SyncResult) => renderSync(value),
180
+ },
181
+ execute: async (args: { action: 'pull' | 'push' | 'sync'; message?: string }): Promise<SyncResult> => {
182
+ const dir = deps.wikiPath()
183
+ switch (args.action) {
184
+ case 'pull': {
185
+ const r = await deps.git.pull(dir)
186
+ return { action: args.action, ok: r.ok, message: r.message, ...(r.conflictFiles !== undefined ? { conflictFiles: r.conflictFiles } : {}) }
187
+ }
188
+ case 'push': {
189
+ const r = await deps.git.push(dir)
190
+ return { action: args.action, ok: r.ok, message: r.message }
191
+ }
192
+ case 'sync': {
193
+ const pulled = await deps.git.pull(dir)
194
+ if (!pulled.ok) return { action: args.action, ok: false, message: pulled.message, ...(pulled.conflictFiles !== undefined ? { conflictFiles: pulled.conflictFiles } : {}) }
195
+ const committed = await deps.git.commit(dir, args.message ?? `sync ${new Date().toISOString()}`)
196
+ const pushed = await deps.git.push(dir)
197
+ const status = await deps.git.status(dir)
198
+ return {
199
+ action: args.action,
200
+ ok: pushed.ok,
201
+ message: pushed.ok ? '同步完成' : pushed.message,
202
+ pull: 'ok',
203
+ commit: committed.message,
204
+ push: pushed.message,
205
+ status,
206
+ }
207
+ }
208
+ }
209
+ },
210
+ }))
211
+
212
+ return disposers
213
+ }
214
+
215
+ // ── tool result shapes + renders ───────────────────────────────────────────
216
+
217
+ interface SearchHit { title: string; tags: string[]; snippet: string }
218
+ interface SearchResult { query: string; tag: string | null; count: number; results: SearchHit[] }
219
+ interface GetResult { notFound: boolean; title: string; text: string; tags: string[]; fields: Record<string, unknown> }
220
+ interface PutResult { ok: boolean; title: string; tags: string[]; fields: Record<string, unknown> | null }
221
+ interface DeleteResult { ok: boolean; title: string }
222
+ interface SyncResult {
223
+ action: string
224
+ ok: boolean
225
+ message: string
226
+ conflictFiles?: string[]
227
+ pull?: string
228
+ commit?: string
229
+ push?: string
230
+ status?: { branch: string; dirty: boolean; dirtyFiles: string[]; remote: string; lastCommit?: string; ahead?: number; behind?: number }
231
+ }
232
+
233
+ function renderSync(value: SyncResult): Array<{ type: 'text'; text: string }> {
234
+ const lines = [`git ${value.action}: ${value.ok ? '成功' : '失败'}`]
235
+ lines.push(` ${value.message}`)
236
+ if (value.conflictFiles !== undefined && value.conflictFiles.length > 0) {
237
+ lines.push(`冲突文件(rebase 已 abort,勿自动覆盖):`)
238
+ for (const f of value.conflictFiles) lines.push(` - ${f}`)
239
+ lines.push('处理方式:git checkout --ours <file> 保留本地,或人工编辑后 git add + git rebase --continue;也可以直接让用户处理。')
240
+ }
241
+ if (value.commit !== undefined) lines.push(`本地 commit: ${value.commit}`)
242
+ if (value.push !== undefined) lines.push(`远端 push: ${value.push}`)
243
+ if (value.status !== undefined) {
244
+ const s = value.status
245
+ const bits = [`分支 ${s.branch}`]
246
+ if (s.ahead !== undefined) bits.push(`领先 ${s.ahead}`)
247
+ if (s.behind !== undefined) bits.push(`落后 ${s.behind}`)
248
+ if (s.dirty) bits.push(`工作区有 ${s.dirtyFiles.length} 个未提交改动`)
249
+ if (s.lastCommit !== undefined) bits.push(`最近提交 ${s.lastCommit}`)
250
+ lines.push(`状态: ${bits.join(' · ')}`)
251
+ if (s.dirty && s.dirtyFiles.length > 0) lines.push(` 未提交: ${s.dirtyFiles.join(', ')}`)
252
+ }
253
+ return [{ type: 'text', text: lines.join('\n') }]
254
+ }
@@ -0,0 +1,157 @@
1
+ /**
2
+ * TiddlyWeb REST client (design doc §5) — the ONLY way every writer reaches
3
+ * the wiki (quick notes, agent tools, editor saves all go through the TW
4
+ * service, D1), so there is never a second write path.
5
+ *
6
+ * ROUTES ARE EMPIRICALLY VERIFIED against tiddlywiki 5.4.1's core-server
7
+ * (`core-server/server/routes/`):
8
+ * GET /recipes/default/tiddlers.json[?exclude=...] list (skinny)
9
+ * GET /recipes/default/tiddlers/<title> read one (404 absent)
10
+ * PUT /recipes/default/tiddlers/<title> write one (204)
11
+ * DELETE /bags/default/tiddlers/<title> delete one (204)
12
+ * Writes require the `X-Requested-With: TiddlyWiki` header (TW CSRF), which
13
+ * this client always sends. Tags arrive as a whitespace-joined STRING and are
14
+ * normalized to arrays here.
15
+ *
16
+ * SEARCH (R2): the server blocks arbitrary `filter=` queries with 403 unless
17
+ * the exact filter is whitelisted in $:/config/Server/ExternalFilters. So
18
+ * `search()` fetches the default listing WITH text (`?exclude=` a sentinel)
19
+ * and matches locally — one request, no 403, no per-tiddler round-trips.
20
+ *
21
+ * @module dsh-tiddlywiki/host/tw-api
22
+ */
23
+
24
+ /** A tiddler's readable fields (loose on purpose). */
25
+ export interface Tiddler {
26
+ title: string
27
+ text?: string
28
+ tags?: string[]
29
+ type?: string
30
+ created?: string
31
+ modified?: string
32
+ /** Extra custom fields returned by the server are folded under `fields`. */
33
+ fields?: Record<string, unknown>
34
+ [key: string]: unknown
35
+ }
36
+
37
+ const REQUEST_TIMEOUT_MS = 10_000
38
+
39
+ /** TW's CSRF gate: writes must carry this header (TW's own UI always does). */
40
+ const CSRF_HEADER = { 'x-requested-with': 'TiddlyWiki' }
41
+
42
+ /** Sentinel `exclude` value: excludes nothing, so `text` stays in the list. */
43
+ const LIST_WITH_TEXT_EXCLUDE = '__dsh_tw_none__'
44
+
45
+ /** Split TW's whitespace-joined tags string into an array. */
46
+ function normalizeTags(tags: unknown): string[] | undefined {
47
+ if (tags === undefined) return undefined
48
+ if (Array.isArray(tags)) return tags.map(String)
49
+ if (typeof tags === 'string') {
50
+ const parts = tags.trim().split(/\s+/).filter(Boolean)
51
+ return parts.length > 0 ? parts : []
52
+ }
53
+ return []
54
+ }
55
+
56
+ /** Normalize a raw server tiddler (tags string → array, unknown fields nested). */
57
+ function normalizeTiddler(raw: Record<string, unknown>): Tiddler {
58
+ const out = { ...raw } as Tiddler
59
+ const tags = normalizeTags(raw.tags)
60
+ if (tags !== undefined) out.tags = tags
61
+ return out
62
+ }
63
+
64
+ export class TiddlyWebClient {
65
+ constructor(private readonly baseUrl: string) {}
66
+
67
+ private async request(path: string, init?: RequestInit): Promise<Response> {
68
+ return fetch(`${this.baseUrl}${path}`, {
69
+ ...init,
70
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
71
+ })
72
+ }
73
+
74
+ /** GET /status → { username, anonymous, space, tiddlywiki_version, ... }. */
75
+ async status(): Promise<Record<string, unknown>> {
76
+ const res = await this.request('/status')
77
+ if (!res.ok) throw new Error(`TiddlyWeb /status HTTP ${res.status}`)
78
+ return res.json() as Promise<Record<string, unknown>>
79
+ }
80
+
81
+ /** Read one tiddler; undefined when it does not exist (404). */
82
+ async get(title: string): Promise<Tiddler | undefined> {
83
+ const res = await this.request(`/recipes/default/tiddlers/${encodeURIComponent(title)}`)
84
+ if (res.status === 404) return undefined
85
+ if (!res.ok) throw new Error(`TiddlyWeb GET /recipes/default/tiddlers/${title} HTTP ${res.status}`)
86
+ return normalizeTiddler((await res.json()) as Record<string, unknown>)
87
+ }
88
+
89
+ /** Write (create or overwrite) one tiddler via PUT (204 on success). */
90
+ async put(tiddler: Tiddler): Promise<Tiddler> {
91
+ const title = tiddler.title
92
+ const res = await this.request(`/recipes/default/tiddlers/${encodeURIComponent(title)}`, {
93
+ method: 'PUT',
94
+ headers: { 'content-type': 'application/json', ...CSRF_HEADER },
95
+ body: JSON.stringify(tiddler),
96
+ })
97
+ if (!res.ok) {
98
+ const detail = await res.text().catch(() => '')
99
+ throw new Error(`TiddlyWeb PUT /recipes/default/tiddlers/${title} HTTP ${res.status}: ${detail.slice(0, 300)}`)
100
+ }
101
+ return tiddler
102
+ }
103
+
104
+ /** Delete one tiddler via the bags route (204); a missing one is a no-op. */
105
+ async delete(title: string): Promise<void> {
106
+ const res = await this.request(`/bags/default/tiddlers/${encodeURIComponent(title)}`, {
107
+ method: 'DELETE',
108
+ headers: CSRF_HEADER,
109
+ })
110
+ if (res.status === 404) return
111
+ if (!res.ok) throw new Error(`TiddlyWeb DELETE /bags/default/tiddlers/${title} HTTP ${res.status}`)
112
+ }
113
+
114
+ /**
115
+ * List tiddlers via the default server filter. Arbitrary `filter=` queries
116
+ * are blocked by the server (403) unless whitelisted, so callers needing a
117
+ * subset should use search(); a supplied filter that is 403-blocked falls
118
+ * back to the default listing.
119
+ */
120
+ async list(filter?: string, includeText = false): Promise<Tiddler[]> {
121
+ const params = new URLSearchParams()
122
+ if (includeText) params.set('exclude', LIST_WITH_TEXT_EXCLUDE)
123
+ if (filter !== undefined && filter.length > 0) params.set('filter', filter)
124
+ const query = params.toString()
125
+ let res = await this.request(`/recipes/default/tiddlers.json${query.length > 0 ? `?${query}` : ''}`)
126
+ if (!res.ok && res.status === 403 && filter !== undefined && filter.length > 0) {
127
+ // Filter not whitelisted → refetch with the default filter.
128
+ const retry = new URLSearchParams()
129
+ if (includeText) retry.set('exclude', LIST_WITH_TEXT_EXCLUDE)
130
+ const retryQuery = retry.toString()
131
+ res = await this.request(`/recipes/default/tiddlers.json${retryQuery.length > 0 ? `?${retryQuery}` : ''}`)
132
+ }
133
+ if (!res.ok) throw new Error(`TiddlyWeb recipe list HTTP ${res.status}`)
134
+ const data = (await res.json()) as Array<Record<string, unknown>> | { tiddlers?: Array<Record<string, unknown>> }
135
+ const items = Array.isArray(data) ? data : (data.tiddlers ?? [])
136
+ return items.map(normalizeTiddler)
137
+ }
138
+
139
+ /**
140
+ * Search non-system tiddlers: one request (default listing with text) plus
141
+ * local case-insensitive substring matching on title + text, optional exact
142
+ * tag, capped at `limit`. Robust against the server's external-filter 403.
143
+ */
144
+ async search(query: string, tag?: string, limit = 30): Promise<Tiddler[]> {
145
+ const items = await this.list(undefined, true)
146
+ const needle = query.toLowerCase()
147
+ const matched = items.filter((t) => {
148
+ if (!t.title.toLowerCase().includes(needle) && !(t.text ?? '').toLowerCase().includes(needle)) return false
149
+ if (tag !== undefined && tag.length > 0) {
150
+ const tags = t.tags ?? []
151
+ if (!tags.some((t2) => t2.toLowerCase() === tag.toLowerCase())) return false
152
+ }
153
+ return true
154
+ })
155
+ return matched.slice(0, limit)
156
+ }
157
+ }