@maci0/dsh-caveman 0.16.2
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 +22 -0
- package/README.md +192 -0
- package/cordis.patch.yml +13 -0
- package/icon.svg +6 -0
- package/lib/client.js +488 -0
- package/lib/compress-detect.js +98 -0
- package/lib/compress-files.js +155 -0
- package/lib/compress-pipeline.js +109 -0
- package/lib/compress-rules.js +308 -0
- package/lib/compress-validate.js +227 -0
- package/lib/frontmatter.js +347 -0
- package/lib/host.js +15 -0
- package/lib/index.js +616 -0
- package/lib/modes.js +126 -0
- package/lib/skills.js +177 -0
- package/lib/types/compress-detect.d.ts +18 -0
- package/lib/types/compress-files.d.ts +76 -0
- package/lib/types/compress-pipeline.d.ts +32 -0
- package/lib/types/compress-rules.d.ts +65 -0
- package/lib/types/compress-validate.d.ts +36 -0
- package/lib/types/frontmatter.d.ts +57 -0
- package/lib/types/host.d.ts +201 -0
- package/lib/types/index.d.ts +87 -0
- package/lib/types/modes.d.ts +102 -0
- package/lib/types/skills.d.ts +56 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +112 -0
- package/scripts/sync-upstream.mjs +158 -0
- package/skills/cavecrew/SKILL.md +91 -0
- package/skills/cavecrew/cavecrew-builder.md +46 -0
- package/skills/cavecrew/cavecrew-investigator.md +56 -0
- package/skills/cavecrew/cavecrew-reviewer.md +47 -0
- package/skills/caveman/SKILL.md +103 -0
- package/skills/caveman-commit/SKILL.md +63 -0
- package/skills/caveman-compress/SKILL.md +105 -0
- package/skills/caveman-explore/SKILL.md +42 -0
- package/skills/caveman-help/SKILL.md +68 -0
- package/skills/caveman-review/SKILL.md +53 -0
- package/skills/caveman-stats/SKILL.md +30 -0
- package/skills/investigate-first/SKILL.md +16 -0
- package/skills/lean-build/SKILL.md +18 -0
- package/skills/migration/SKILL.md +17 -0
- package/skills/safe-refactor/SKILL.md +16 -0
- package/skills/surgical-patch/SKILL.md +16 -0
- package/skills/verify-and-stop/SKILL.md +16 -0
- package/sync.manifest.json +25 -0
package/lib/client.js
ADDED
|
@@ -0,0 +1,488 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-caveman browser half.
|
|
3
|
+
*
|
|
4
|
+
* Two surfaces read one settings namespace:
|
|
5
|
+
*
|
|
6
|
+
* - the Caveman card on the Plugins page, keyed on the
|
|
7
|
+
* `caveman` namespace the host half registers; and
|
|
8
|
+
* - a read-only level chip in the composer tool row (`conversation.input.left`),
|
|
9
|
+
* so the active level is visible without opening Settings.
|
|
10
|
+
*
|
|
11
|
+
* Both read `ctx.configForms`, so they cannot disagree, and both take their
|
|
12
|
+
* copy from the `caveman` locale namespace registered here (en/zh), which is
|
|
13
|
+
* also why every label has an English and a Chinese entry.
|
|
14
|
+
*
|
|
15
|
+
* Chrome is a stylesheet, not inline style objects: the module system claims
|
|
16
|
+
* every `<style>` tag a factory appends while it materializes and removes it
|
|
17
|
+
* when the package unloads, so the tags cost nothing to own. It also keeps
|
|
18
|
+
* state changes out of React's inline-style diffing, which is what silently
|
|
19
|
+
* blanked a deselected pill's border (a longhand removed against a set
|
|
20
|
+
* shorthand decomposes the shorthand).
|
|
21
|
+
*
|
|
22
|
+
* This file is plain JavaScript on purpose. The client module system serves a
|
|
23
|
+
* package's `exports["./client"]` artifact as a lazy-CJS factory registered on
|
|
24
|
+
* `window.__ModuleLoader__`, and that is the whole format: an out-of-tree
|
|
25
|
+
* plugin can author it directly instead of reproducing the repository's tsdown
|
|
26
|
+
* client preset. `react` is provided by the module system; nothing else is
|
|
27
|
+
* required here.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
window.__ModuleLoader__.load({
|
|
31
|
+
id: '@maci0/dsh-caveman',
|
|
32
|
+
|
|
33
|
+
factory: (require) => {
|
|
34
|
+
var module = { exports: {} }
|
|
35
|
+
var exports = module.exports
|
|
36
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' })
|
|
37
|
+
|
|
38
|
+
const React = require('react')
|
|
39
|
+
|
|
40
|
+
/** Settings namespace shared with the host half; also this card's slot key. */
|
|
41
|
+
const NAMESPACE = 'caveman'
|
|
42
|
+
|
|
43
|
+
/** Locale namespace for this plugin's copy: both surfaces read one dictionary. */
|
|
44
|
+
const LOCALE_NS = 'caveman'
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The host half's route reporting the level in use and its source. Same
|
|
48
|
+
* origin, so the session cookie rides along.
|
|
49
|
+
*/
|
|
50
|
+
const LEVEL_ROUTE = '/caveman/level'
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* How often a mounted surface re-reads the route. A session-local level
|
|
54
|
+
* changes without a settings write, so no snapshot change announces it.
|
|
55
|
+
*/
|
|
56
|
+
const LEVEL_POLL_MS = 5000
|
|
57
|
+
|
|
58
|
+
/** Sources the card labels; `settings` and `default` need no note. */
|
|
59
|
+
const SOURCE_NOTES = {
|
|
60
|
+
env: { inline: 'fromEnv', hint: 'sourceEnv' },
|
|
61
|
+
'config-file': { inline: 'fromConfigFile', hint: 'sourceConfigFile' },
|
|
62
|
+
session: { inline: 'fromSession', hint: 'sourceSession' },
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Every class is `dc-`-prefixed: the sheet lands in the page's own document. */
|
|
66
|
+
const CSS = [
|
|
67
|
+
'.dc-page{display:flex;flex-direction:column;gap:10px}',
|
|
68
|
+
'.dc-name{font-size:15px;font-weight:600;line-height:1.4;color:var(--dsw-alias-label-primary)}',
|
|
69
|
+
'.dc-body{border-top:0.5px solid var(--dsw-alias-border-l2);margin:0 16px;padding:12px 0 8px;display:flex;flex-direction:column;gap:10px}',
|
|
70
|
+
'.dc-row{display:flex;flex-wrap:wrap;gap:8px}',
|
|
71
|
+
'.dc-pill{appearance:none;font:inherit;font-size:13px;line-height:1.5;padding:5px 14px;cursor:pointer;color:var(--dsw-alias-label-secondary);background:none;border:1px solid var(--dsw-alias-border-l2);border-radius:999px}',
|
|
72
|
+
'.dc-pill-selected{color:var(--dsw-alias-label-primary);border-color:var(--dsw-alias-label-primary);background:var(--dsw-alias-bg-layer-4)}',
|
|
73
|
+
'.dc-pill:disabled{cursor:default;opacity:.5}',
|
|
74
|
+
'.dc-hint{font-size:12px;line-height:1.5;color:var(--dsw-alias-label-tertiary)}',
|
|
75
|
+
'.dc-status{display:flex;align-items:center;gap:8px;font-size:12px;line-height:1.5;color:var(--dsw-alias-label-tertiary)}',
|
|
76
|
+
'.dc-reset{appearance:none;font:inherit;font-size:12px;line-height:1.5;padding:3px 10px;cursor:pointer;color:var(--dsw-alias-label-secondary);background:none;border:1px solid var(--dsw-alias-border-l2);border-radius:8px}',
|
|
77
|
+
'.dc-error{font-size:12px;line-height:1.5;color:var(--dsw-alias-label-error)}',
|
|
78
|
+
'.dc-input{font:inherit;font-size:13px;line-height:1.5;padding:5px 12px;color:var(--dsw-alias-label-primary);background:var(--dsw-alias-bg-layer-4);border:1px solid var(--dsw-alias-border-l2);border-radius:8px;width:100%;box-sizing:border-box}',
|
|
79
|
+
'.dc-input:disabled{cursor:default;opacity:.5}',
|
|
80
|
+
'.dc-field{display:flex;flex-direction:column;gap:6px}',
|
|
81
|
+
'.dc-chip{display:inline-flex;align-items:center;height:24px;padding:0 10px;max-width:180px;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;font-size:12px;line-height:1.4;color:var(--dsw-alias-label-secondary);background:var(--dsw-alias-bg-module-platform);border:1px solid var(--dsw-alias-border-l2);border-radius:999px}',
|
|
82
|
+
].join('')
|
|
83
|
+
|
|
84
|
+
// Appended while the factory materializes: the module system claims the tag
|
|
85
|
+
// for this package and disposes it on unload. Guarded because the node unit
|
|
86
|
+
// tests evaluate this file without a DOM.
|
|
87
|
+
if (typeof document !== 'undefined') {
|
|
88
|
+
const style = document.createElement('style')
|
|
89
|
+
style.textContent = CSS
|
|
90
|
+
document.head.append(style)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const en = {
|
|
94
|
+
description: 'Terse-talk mode, level: {level}{note}.',
|
|
95
|
+
overridden: ' (overridden)',
|
|
96
|
+
fromEnv: ' (from CAVEMAN_DEFAULT_MODE)',
|
|
97
|
+
fromConfigFile: ' (from ~/.config/caveman/config.json)',
|
|
98
|
+
fromSession: ' (this session only)',
|
|
99
|
+
sourceEnv: 'Set by CAVEMAN_DEFAULT_MODE. Choosing a level here overrides it.',
|
|
100
|
+
sourceConfigFile: 'Set by ~/.config/caveman/config.json. Choosing a level here overrides it.',
|
|
101
|
+
sourceSession: 'Held for this session only: the settings document did not take it.',
|
|
102
|
+
levelGroup: 'Caveman level',
|
|
103
|
+
indicator: 'Caveman: {level}',
|
|
104
|
+
levelOff: 'Off',
|
|
105
|
+
levelLite: 'Lite',
|
|
106
|
+
levelFull: 'Full',
|
|
107
|
+
levelUltra: 'Ultra',
|
|
108
|
+
levelWenyanLite: 'Wenyan-Lite',
|
|
109
|
+
levelWenyanFull: 'Wenyan-Full',
|
|
110
|
+
levelWenyanUltra: 'Wenyan-Ultra',
|
|
111
|
+
hintOff: 'No ruleset injected. Normal behavior.',
|
|
112
|
+
hintLite: 'Drop filler. Keep sentence structure.',
|
|
113
|
+
hintFull: 'Drop articles, filler, pleasantries. Fragments OK. Default.',
|
|
114
|
+
hintUltra: 'Extreme compression. Bare fragments.',
|
|
115
|
+
hintWenyanLite: 'Classical Chinese style, light compression.',
|
|
116
|
+
hintWenyanFull: 'Full 文言文. Maximum classical terseness.',
|
|
117
|
+
hintWenyanUltra: 'Extreme. Ancient scholar on a budget.',
|
|
118
|
+
persists: 'Applies to every request and persists in your settings.',
|
|
119
|
+
readOnly: 'Read-only: settings are not persisted in this deployment.',
|
|
120
|
+
reset: 'Reset',
|
|
121
|
+
refused: 'The Host refused the change. Try again.',
|
|
122
|
+
compressGroup: 'Memory-file compression (local rules, no model call)',
|
|
123
|
+
compressHint: 'Rewrites .md/.txt memory files in place; original kept out-of-tree.',
|
|
124
|
+
sizeLabel: 'Largest file to compress (bytes)',
|
|
125
|
+
sizeHint: 'A bigger file is refused with its size instead of being rewritten.',
|
|
126
|
+
sizeSave: 'Save',
|
|
127
|
+
sizeSaved: 'Saved. The next compress call uses it.',
|
|
128
|
+
sizeInvalid: 'Must be a whole number of bytes, at least 1.',
|
|
129
|
+
sizeRejected: 'The Host refused the write; the previous cap is still in effect.',
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const zh = {
|
|
133
|
+
description: '简言模式,级别:{level}{note}。',
|
|
134
|
+
overridden: '(已覆盖)',
|
|
135
|
+
fromEnv: '(来自 CAVEMAN_DEFAULT_MODE)',
|
|
136
|
+
fromConfigFile: '(来自 ~/.config/caveman/config.json)',
|
|
137
|
+
fromSession: '(仅本会话)',
|
|
138
|
+
sourceEnv: '由 CAVEMAN_DEFAULT_MODE 设定。在此选择级别会覆盖它。',
|
|
139
|
+
sourceConfigFile: '由 ~/.config/caveman/config.json 设定。在此选择级别会覆盖它。',
|
|
140
|
+
sourceSession: '仅在本会话生效:设置文档未能保存该级别。',
|
|
141
|
+
levelGroup: 'Caveman 级别',
|
|
142
|
+
indicator: 'Caveman:{level}',
|
|
143
|
+
levelOff: '关闭',
|
|
144
|
+
levelLite: '轻量',
|
|
145
|
+
levelFull: '完整',
|
|
146
|
+
levelUltra: '极端',
|
|
147
|
+
levelWenyanLite: '文言-轻量',
|
|
148
|
+
levelWenyanFull: '文言-完整',
|
|
149
|
+
levelWenyanUltra: '文言-极端',
|
|
150
|
+
hintOff: '不注入任何规则集,行为如常。',
|
|
151
|
+
hintLite: '去 filler,保留句子结构。',
|
|
152
|
+
hintFull: '去冠词、filler、客套。片段可。默认。',
|
|
153
|
+
hintUltra: '极致压缩。 bare 片段。',
|
|
154
|
+
hintWenyanLite: '文言风格,轻压缩。',
|
|
155
|
+
hintWenyanFull: '全文言文。极致简练。',
|
|
156
|
+
hintWenyanUltra: '极端。惜字如金的古学者。',
|
|
157
|
+
persists: '对每个请求生效,并保存在你的设置中。',
|
|
158
|
+
readOnly: '只读:此部署不会持久化设置。',
|
|
159
|
+
reset: '重置',
|
|
160
|
+
refused: 'Host 拒绝了更改,请重试。',
|
|
161
|
+
compressGroup: '记忆文件压缩(本地规则,不调用模型)',
|
|
162
|
+
compressHint: '就地重写 .md/.txt 记忆文件;原文件在目录外备份。',
|
|
163
|
+
sizeLabel: '可压缩的最大文件(字节)',
|
|
164
|
+
sizeHint: '超过该值的文件会被拒绝并列出其大小,而不会改写。',
|
|
165
|
+
sizeSave: '保存',
|
|
166
|
+
sizeSaved: '已保存。下一次压缩调用将使用该值。',
|
|
167
|
+
sizeInvalid: '必须是至少为 1 的整数字节数。',
|
|
168
|
+
sizeRejected: 'Host 拒绝了写入;原先的上限仍然有效。',
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Every caveman level persists, so the card offers all seven. */
|
|
172
|
+
const LEVELS = [
|
|
173
|
+
{ value: 'off', label: 'levelOff', hint: 'hintOff' },
|
|
174
|
+
{ value: 'lite', label: 'levelLite', hint: 'hintLite' },
|
|
175
|
+
{ value: 'full', label: 'levelFull', hint: 'hintFull' },
|
|
176
|
+
{ value: 'ultra', label: 'levelUltra', hint: 'hintUltra' },
|
|
177
|
+
{ value: 'wenyan-lite', label: 'levelWenyanLite', hint: 'hintWenyanLite' },
|
|
178
|
+
{ value: 'wenyan-full', label: 'levelWenyanFull', hint: 'hintWenyanFull' },
|
|
179
|
+
{ value: 'wenyan-ultra', label: 'levelWenyanUltra', hint: 'hintWenyanUltra' },
|
|
180
|
+
]
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Localized name of a level, falling back to the raw value for one this card
|
|
184
|
+
* does not offer (a hand-edited document, a future level).
|
|
185
|
+
* @param t - translate function bound to this plugin's namespace.
|
|
186
|
+
* @param mode - level value.
|
|
187
|
+
* @returns the display name.
|
|
188
|
+
*/
|
|
189
|
+
function levelLabel(t, mode) {
|
|
190
|
+
const level = LEVELS.filter((candidate) => candidate.value === mode)[0]
|
|
191
|
+
return level === undefined ? mode : t(level.label)
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Bind one scope to a React subscription.
|
|
196
|
+
* @param scope - a scope bound to the caveman settings namespace.
|
|
197
|
+
* @returns a hook reading that scope's current snapshot.
|
|
198
|
+
*/
|
|
199
|
+
function useScope(scope) {
|
|
200
|
+
const subscribe = (listener) => scope.subscribe(listener)
|
|
201
|
+
const getSnapshot = () => scope.getSnapshot()
|
|
202
|
+
return () => React.useSyncExternalStore(subscribe, getSnapshot)
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Read a snapshot's level. A namespace the deployment does not serve reports
|
|
207
|
+
* no value, which every surface renders as nothing.
|
|
208
|
+
* @param snapshot - the settings scope snapshot.
|
|
209
|
+
* @returns the active level value, or `undefined` when unreadable.
|
|
210
|
+
*/
|
|
211
|
+
function modeOf(snapshot) {
|
|
212
|
+
if (snapshot.status !== 'ready') return undefined
|
|
213
|
+
const value = snapshot.value !== null && typeof snapshot.value === 'object' ? snapshot.value : {}
|
|
214
|
+
return typeof value.defaultMode === 'string' ? value.defaultMode : 'full'
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Read the level the host half is using, re-reading on every settings
|
|
219
|
+
* change and on a timer. `null` until the route answers, and whenever it
|
|
220
|
+
* cannot (an older host half, a rejected request): the surfaces then fall
|
|
221
|
+
* back to the settings document.
|
|
222
|
+
* @param snapshot - the settings scope snapshot; a new one triggers a read.
|
|
223
|
+
* @returns `{ mode, source }` from the host, or `null`.
|
|
224
|
+
*/
|
|
225
|
+
function useHostLevel(snapshot) {
|
|
226
|
+
const [level, setLevel] = React.useState(null)
|
|
227
|
+
React.useEffect(() => {
|
|
228
|
+
let live = true
|
|
229
|
+
const controller = new AbortController()
|
|
230
|
+
const read = () => {
|
|
231
|
+
fetch(LEVEL_ROUTE, { headers: { accept: 'application/json' }, signal: controller.signal })
|
|
232
|
+
.then((response) => (response.ok ? response.json() : null))
|
|
233
|
+
.then((body) => { if (live) setLevel(hostLevelOf(body)) })
|
|
234
|
+
.catch(() => { if (live) setLevel(null) })
|
|
235
|
+
}
|
|
236
|
+
read()
|
|
237
|
+
const timer = setInterval(read, LEVEL_POLL_MS)
|
|
238
|
+
return () => {
|
|
239
|
+
live = false
|
|
240
|
+
controller.abort()
|
|
241
|
+
clearInterval(timer)
|
|
242
|
+
}
|
|
243
|
+
}, [snapshot])
|
|
244
|
+
return level
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Validate the route's answer: a JSON body is input from the wire.
|
|
249
|
+
* @param body - the parsed response.
|
|
250
|
+
* @returns `{ mode, source }` when both are strings, else `null`.
|
|
251
|
+
*/
|
|
252
|
+
function hostLevelOf(body) {
|
|
253
|
+
if (body === null || typeof body !== 'object') return null
|
|
254
|
+
if (typeof body.mode !== 'string' || typeof body.source !== 'string') return null
|
|
255
|
+
return { mode: body.mode, source: body.source }
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* The level a surface shows: the host's when it answered, else the
|
|
260
|
+
* settings document's. `undefined` while the namespace is not served.
|
|
261
|
+
* @param snapshot - the settings scope snapshot.
|
|
262
|
+
* @param host - the route's answer, or `null`.
|
|
263
|
+
* @returns the level value, or `undefined`.
|
|
264
|
+
*/
|
|
265
|
+
function shownMode(snapshot, host) {
|
|
266
|
+
const documented = modeOf(snapshot)
|
|
267
|
+
if (documented === undefined) return undefined
|
|
268
|
+
return host === null ? documented : host.mode
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Build the card component over one bound settings scope.
|
|
273
|
+
* @param scope - the scope bound to the caveman namespace.
|
|
274
|
+
* @param t - translate function bound to this plugin's locale namespace.
|
|
275
|
+
* @returns the component the slot renders.
|
|
276
|
+
*/
|
|
277
|
+
function createCard(scope, t) {
|
|
278
|
+
const useCaveman = useScope(scope)
|
|
279
|
+
|
|
280
|
+
return function CavemanCard(props) {
|
|
281
|
+
const snapshot = useCaveman()
|
|
282
|
+
const [error, setError] = React.useState(null)
|
|
283
|
+
// The size cap stages a draft the way a form does; a number cannot be a
|
|
284
|
+
// pill, and one write per Save keeps the document from seeing half-typed
|
|
285
|
+
// values.
|
|
286
|
+
const [sizeDraft, setSizeDraft] = React.useState(null)
|
|
287
|
+
const [sizeStatus, setSizeStatus] = React.useState(null)
|
|
288
|
+
const [pending, setPending] = React.useState(false)
|
|
289
|
+
const host = useHostLevel(snapshot)
|
|
290
|
+
|
|
291
|
+
const current = shownMode(snapshot, host)
|
|
292
|
+
// A namespace this deployment does not serve renders no trace of itself.
|
|
293
|
+
if (current === undefined) return null
|
|
294
|
+
|
|
295
|
+
const user = snapshot.user !== null && typeof snapshot.user === 'object' ? snapshot.user : {}
|
|
296
|
+
const overridden = Object.prototype.hasOwnProperty.call(user, 'defaultMode')
|
|
297
|
+
const sourceNote = host === null || !Object.hasOwn(SOURCE_NOTES, host.source) ? undefined : SOURCE_NOTES[host.source]
|
|
298
|
+
if (props != null && props.view === 'summary') {
|
|
299
|
+
return t('description', {
|
|
300
|
+
level: levelLabel(t, current),
|
|
301
|
+
note: sourceNote !== undefined ? t(sourceNote.inline) : overridden ? t('overridden') : '',
|
|
302
|
+
})
|
|
303
|
+
}
|
|
304
|
+
const disabled = !snapshot.writable || pending
|
|
305
|
+
const selected = LEVELS.filter((level) => level.value === current)[0] ?? LEVELS[2]
|
|
306
|
+
|
|
307
|
+
const report = (cause) => {
|
|
308
|
+
setError(cause instanceof Error ? cause.message : String(cause))
|
|
309
|
+
}
|
|
310
|
+
const write = (run) => {
|
|
311
|
+
if (disabled) return
|
|
312
|
+
setError(null)
|
|
313
|
+
setPending(true)
|
|
314
|
+
new Promise((resolve) => { resolve(run()) }).then((accepted) => {
|
|
315
|
+
if (accepted === false) setError(t('refused'))
|
|
316
|
+
}).catch(report).finally(() => { setPending(false) })
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/** Validate and write the staged size cap; the field is volatile, so the
|
|
320
|
+
* running plugin reads it on its next compress call. */
|
|
321
|
+
const saveSize = (current) => {
|
|
322
|
+
if (disabled) return
|
|
323
|
+
setError(null)
|
|
324
|
+
const parsed = Number(sizeDraft ?? current)
|
|
325
|
+
if (!Number.isInteger(parsed) || parsed < 1) {
|
|
326
|
+
setError(t('sizeInvalid'))
|
|
327
|
+
return
|
|
328
|
+
}
|
|
329
|
+
if (parsed === current) {
|
|
330
|
+
setSizeStatus(null)
|
|
331
|
+
return
|
|
332
|
+
}
|
|
333
|
+
write(() => scope.set('maxFileSize', parsed).then((accepted) => {
|
|
334
|
+
if (accepted === false) {
|
|
335
|
+
setError(t('sizeRejected'))
|
|
336
|
+
return
|
|
337
|
+
}
|
|
338
|
+
setSizeDraft(null)
|
|
339
|
+
setSizeStatus(t('sizeSaved'))
|
|
340
|
+
}))
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
return React.createElement(
|
|
344
|
+
'div',
|
|
345
|
+
{ className: 'dc-page' },
|
|
346
|
+
React.createElement(
|
|
347
|
+
'div',
|
|
348
|
+
{ className: 'dc-body' },
|
|
349
|
+
React.createElement(
|
|
350
|
+
'div',
|
|
351
|
+
{ className: 'dc-row', role: 'radiogroup', 'aria-label': t('levelGroup') },
|
|
352
|
+
LEVELS.map((level) => React.createElement(
|
|
353
|
+
'button',
|
|
354
|
+
{
|
|
355
|
+
key: level.value,
|
|
356
|
+
type: 'button',
|
|
357
|
+
role: 'radio',
|
|
358
|
+
'aria-checked': current === level.value,
|
|
359
|
+
disabled,
|
|
360
|
+
className: `dc-pill${current === level.value ? ' dc-pill-selected' : ''}`,
|
|
361
|
+
onClick: () => { write(() => scope.set('defaultMode', level.value)) },
|
|
362
|
+
},
|
|
363
|
+
t(level.label),
|
|
364
|
+
)),
|
|
365
|
+
),
|
|
366
|
+
React.createElement('div', { className: 'dc-hint' }, t(selected.hint)),
|
|
367
|
+
sourceNote === undefined ? null : React.createElement('div', { className: 'dc-hint' }, t(sourceNote.hint)),
|
|
368
|
+
React.createElement(
|
|
369
|
+
'div',
|
|
370
|
+
{ className: 'dc-hint', role: 'group', 'aria-label': t('compressGroup') },
|
|
371
|
+
t('compressGroup'),
|
|
372
|
+
),
|
|
373
|
+
React.createElement('div', { className: 'dc-hint' }, t('compressHint')),
|
|
374
|
+
React.createElement(
|
|
375
|
+
'div',
|
|
376
|
+
{ className: 'dc-field' },
|
|
377
|
+
React.createElement('span', { className: 'dc-name' }, t('sizeLabel')),
|
|
378
|
+
React.createElement(
|
|
379
|
+
'div',
|
|
380
|
+
{ className: 'dc-row' },
|
|
381
|
+
React.createElement('input', {
|
|
382
|
+
className: 'dc-input',
|
|
383
|
+
type: 'number',
|
|
384
|
+
min: 1,
|
|
385
|
+
value: String(sizeDraft ?? snapshot.value?.maxFileSize ?? ''),
|
|
386
|
+
disabled,
|
|
387
|
+
'aria-label': t('sizeLabel'),
|
|
388
|
+
onChange: (event) => {
|
|
389
|
+
setSizeStatus(null)
|
|
390
|
+
setSizeDraft(event.target.value)
|
|
391
|
+
},
|
|
392
|
+
}),
|
|
393
|
+
React.createElement(
|
|
394
|
+
'button',
|
|
395
|
+
{
|
|
396
|
+
type: 'button',
|
|
397
|
+
className: 'dc-reset',
|
|
398
|
+
disabled,
|
|
399
|
+
onClick: () => { saveSize(snapshot.value?.maxFileSize) },
|
|
400
|
+
},
|
|
401
|
+
t('sizeSave'),
|
|
402
|
+
),
|
|
403
|
+
),
|
|
404
|
+
React.createElement('span', { className: 'dc-hint' }, sizeStatus ?? t('sizeHint')),
|
|
405
|
+
),
|
|
406
|
+
React.createElement(
|
|
407
|
+
'div',
|
|
408
|
+
{ className: 'dc-status' },
|
|
409
|
+
snapshot.writable ? t('persists') : t('readOnly'),
|
|
410
|
+
overridden
|
|
411
|
+
? React.createElement(
|
|
412
|
+
'button',
|
|
413
|
+
{
|
|
414
|
+
type: 'button',
|
|
415
|
+
className: 'dc-reset',
|
|
416
|
+
disabled,
|
|
417
|
+
onClick: () => { write(() => scope.unset('defaultMode')) },
|
|
418
|
+
},
|
|
419
|
+
t('reset'),
|
|
420
|
+
)
|
|
421
|
+
: null,
|
|
422
|
+
),
|
|
423
|
+
error === null ? null : React.createElement('div', { className: 'dc-error' }, error),
|
|
424
|
+
),
|
|
425
|
+
)
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* Build the read-only level chip for the composer tool row.
|
|
431
|
+
*
|
|
432
|
+
* It reads the same settings scope the card edits, so the two surfaces can
|
|
433
|
+
* never disagree; it renders nothing while the namespace is unavailable or
|
|
434
|
+
* the level is `off`, where a chip would only say "nothing is injected".
|
|
435
|
+
* @param scope - the scope bound to the caveman namespace.
|
|
436
|
+
* @param t - translate function bound to this plugin's locale namespace.
|
|
437
|
+
* @returns the component the composer slot renders.
|
|
438
|
+
*/
|
|
439
|
+
function createIndicator(scope, t) {
|
|
440
|
+
const useCaveman = useScope(scope)
|
|
441
|
+
|
|
442
|
+
return function CavemanIndicator() {
|
|
443
|
+
const snapshot = useCaveman()
|
|
444
|
+
const current = shownMode(snapshot, useHostLevel(snapshot))
|
|
445
|
+
if (current === undefined || current === 'off') return null
|
|
446
|
+
|
|
447
|
+
const text = t('indicator', { level: levelLabel(t, current) })
|
|
448
|
+
return React.createElement('span', { className: 'dc-chip', title: text }, text)
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/**
|
|
453
|
+
* Mount both browser surfaces: the Plugins card and the composer chip.
|
|
454
|
+
* @param ctx - the browser plugin context.
|
|
455
|
+
*/
|
|
456
|
+
function apply(ctx) {
|
|
457
|
+
const t = ctx.locale.bind(LOCALE_NS)
|
|
458
|
+
ctx.effect(
|
|
459
|
+
() => ctx.locale.register(LOCALE_NS, { en, zh }),
|
|
460
|
+
'dsh-caveman: locale dictionary',
|
|
461
|
+
)
|
|
462
|
+
|
|
463
|
+
const scope = ctx.configForms.get(NAMESPACE)
|
|
464
|
+
const Card = createCard(scope, t)
|
|
465
|
+
const Indicator = createIndicator(scope, t)
|
|
466
|
+
|
|
467
|
+
// Each owner declares its own slot; injecting waits for it to exist, so
|
|
468
|
+
// these registrations do not depend on plugin load order. The card takes
|
|
469
|
+
// no injected props (it closes over its own bound scope), so the entry
|
|
470
|
+
// declares the documented `locale` namespace and no `inject`.
|
|
471
|
+
ctx.slots.inject('plugins.row.config', () => ctx.slots.register({
|
|
472
|
+
name: 'plugins.row.config',
|
|
473
|
+
key: '@maci0/dsh-caveman#caveman',
|
|
474
|
+
locale: LOCALE_NS,
|
|
475
|
+
}, Card))
|
|
476
|
+
|
|
477
|
+
ctx.slots.inject('conversation.input.left', () => ctx.slots.register({
|
|
478
|
+
name: 'conversation.input.left',
|
|
479
|
+
id: 'caveman-level',
|
|
480
|
+
order: 20,
|
|
481
|
+
}, Indicator))
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
exports.apply = apply
|
|
485
|
+
exports.inject = ['slots', 'configForms', 'locale']
|
|
486
|
+
return module.exports
|
|
487
|
+
},
|
|
488
|
+
})
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Detect whether a file is natural language (compressible) or code/config.
|
|
3
|
+
*
|
|
4
|
+
* TypeScript port of `skills/caveman-compress/scripts/detect.py` (MIT,
|
|
5
|
+
* © JuliusBrussee). The Python original is dropped: the ported pipeline runs
|
|
6
|
+
* in-process with no `python3` requirement.
|
|
7
|
+
*
|
|
8
|
+
* @module dsh-caveman/compress-detect
|
|
9
|
+
*/
|
|
10
|
+
/** Extensions that are natural language and compressible. */
|
|
11
|
+
const COMPRESSIBLE_EXTENSIONS = new Set([
|
|
12
|
+
'.md', '.mdc', '.txt', '.markdown', '.rst', '.typ', '.typst', '.tex',
|
|
13
|
+
]);
|
|
14
|
+
/** Well-known build/config basenames that carry no (or a misleading) extension. */
|
|
15
|
+
const KNOWN_CODE_FILENAMES = new Set([
|
|
16
|
+
'dockerfile', 'containerfile', 'makefile', 'gnumakefile', 'jenkinsfile',
|
|
17
|
+
'vagrantfile', 'rakefile', 'gemfile', 'justfile', 'procfile', 'brewfile',
|
|
18
|
+
'earthfile', 'fastfile', 'podfile',
|
|
19
|
+
'cmakelists.txt',
|
|
20
|
+
]);
|
|
21
|
+
/** Patterns that indicate a line is code. */
|
|
22
|
+
const CODE_PATTERNS = [
|
|
23
|
+
/^\s*(import |from .+ import |require\(|const |let |var )/,
|
|
24
|
+
/^\s*(def |class |function |async function |export )/,
|
|
25
|
+
/^\s*(if\s*\(|for\s*\(|while\s*\(|switch\s*\(|try\s*\{)/,
|
|
26
|
+
/^\s*[}\]\);]+\s*$/,
|
|
27
|
+
/^\s*@\w+/,
|
|
28
|
+
/^\s*"[^"]+"\s*:\s*/,
|
|
29
|
+
/^\s*\w+\s*=\s*[{[(["']/,
|
|
30
|
+
];
|
|
31
|
+
function isCodeLine(line) {
|
|
32
|
+
return CODE_PATTERNS.some((pattern) => pattern.test(line));
|
|
33
|
+
}
|
|
34
|
+
function isJsonContent(text) {
|
|
35
|
+
try {
|
|
36
|
+
JSON.parse(text);
|
|
37
|
+
return true;
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
function isYamlContent(lines) {
|
|
44
|
+
let indicators = 0;
|
|
45
|
+
for (const line of lines.slice(0, 30)) {
|
|
46
|
+
const stripped = line.trim();
|
|
47
|
+
if (stripped.startsWith('---'))
|
|
48
|
+
indicators += 1;
|
|
49
|
+
else if (/^\w[\w\s]*:\s/.test(stripped))
|
|
50
|
+
indicators += 1;
|
|
51
|
+
else if (stripped.startsWith('- ') && stripped.includes(':'))
|
|
52
|
+
indicators += 1;
|
|
53
|
+
}
|
|
54
|
+
const nonEmpty = lines.slice(0, 30).filter((line) => line.trim() !== '').length;
|
|
55
|
+
return nonEmpty > 0 && indicators / nonEmpty > 0.6;
|
|
56
|
+
}
|
|
57
|
+
function extensionOf(basename) {
|
|
58
|
+
const dot = basename.lastIndexOf('.');
|
|
59
|
+
return dot <= 0 ? '' : basename.slice(dot).toLowerCase();
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Classify a file as natural language, code, config, or unknown.
|
|
63
|
+
* @param basename - file basename (extension rules key off this).
|
|
64
|
+
* @param readText - reads the file when content sniffing is needed; throw to signal unreadable.
|
|
65
|
+
* @returns the classification.
|
|
66
|
+
*/
|
|
67
|
+
export function detectFileType(basename, readText) {
|
|
68
|
+
const name = basename.toLowerCase();
|
|
69
|
+
const ext = extensionOf(basename);
|
|
70
|
+
if (KNOWN_CODE_FILENAMES.has(name))
|
|
71
|
+
return 'code';
|
|
72
|
+
if (COMPRESSIBLE_EXTENSIONS.has(ext))
|
|
73
|
+
return 'natural_language';
|
|
74
|
+
if (ext === '') {
|
|
75
|
+
let text;
|
|
76
|
+
try {
|
|
77
|
+
if (readText === undefined)
|
|
78
|
+
return 'unknown';
|
|
79
|
+
text = readText();
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
return 'unknown';
|
|
83
|
+
}
|
|
84
|
+
const lines = text.split('\n').slice(0, 50);
|
|
85
|
+
if (text.startsWith('#!'))
|
|
86
|
+
return 'code';
|
|
87
|
+
if (isJsonContent(text.slice(0, 10000)))
|
|
88
|
+
return 'config';
|
|
89
|
+
if (isYamlContent(lines))
|
|
90
|
+
return 'config';
|
|
91
|
+
const nonEmpty = lines.filter((line) => line.trim() !== '');
|
|
92
|
+
const codeLines = nonEmpty.filter(isCodeLine).length;
|
|
93
|
+
if (nonEmpty.length > 0 && codeLines / nonEmpty.length > 0.4)
|
|
94
|
+
return 'code';
|
|
95
|
+
return 'natural_language';
|
|
96
|
+
}
|
|
97
|
+
return 'unknown';
|
|
98
|
+
}
|