dsh-tacit 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/store.js ADDED
@@ -0,0 +1,219 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Copyright (c) 2026 hackernotfound — https://github.com/hackernotfound/dsh-tacit
3
+ /**
4
+ * dsh-tacit — local storage (plugin-owned only).
5
+ *
6
+ * Everything lives under $DSH_HOME/storages/tacit/:
7
+ * config.patch.json UI-written config fields (loader/YAML config is the base)
8
+ * profile.json persistent user mistake profile
9
+ * reports/<sessionId>/<turn>.json analysis reports
10
+ *
11
+ * Safety rules (hard constraints):
12
+ * - writes are atomic (temp file + rename) and never truncate an existing
13
+ * file in place;
14
+ * - the ONLY deletion this plugin ever performs is `clearReports()`, which
15
+ * unlinks files matching /^\d+\.json$/ inside its own reports directory
16
+ * (then removes that directory only if empty) — nothing else on disk;
17
+ * - session ids are sanitized before touching the filesystem (no traversal).
18
+ */
19
+
20
+ import fs from 'node:fs'
21
+ import path from 'node:path'
22
+
23
+ export function emptyProfile() {
24
+ return {
25
+ analyzedCount: 0,
26
+ patterns: [],
27
+ updatedAt: 0,
28
+ styleRules: [],
29
+ feedbackLog: [],
30
+ pendingDistill: 0,
31
+ directives: [],
32
+ analysesSinceDirectives: 0,
33
+ }
34
+ }
35
+
36
+ export class CoachStore {
37
+ constructor(root) {
38
+ this.root = root
39
+ }
40
+
41
+ ensureDir(dir) {
42
+ fs.mkdirSync(dir, { recursive: true })
43
+ }
44
+
45
+ readJson(file, fallback) {
46
+ try {
47
+ return JSON.parse(fs.readFileSync(file, 'utf8'))
48
+ } catch {
49
+ return fallback
50
+ }
51
+ }
52
+
53
+ writeJsonAtomic(file, value) {
54
+ this.ensureDir(path.dirname(file))
55
+ const tmp = `${file}.tmp-${process.pid}-${Date.now()}`
56
+ fs.writeFileSync(tmp, JSON.stringify(value, null, 2), 'utf8')
57
+ fs.renameSync(tmp, file)
58
+ }
59
+
60
+ /** Filesystem-safe session id (the browser id never reaches a path verbatim). */
61
+ safeSessionId(sessionId) {
62
+ const value = String(sessionId ?? '').replace(/[^A-Za-z0-9._-]/g, '_').slice(0, 128)
63
+ return value.length > 0 ? value : 'unknown'
64
+ }
65
+
66
+ configPatch() {
67
+ const value = this.readJson(path.join(this.root, 'config.patch.json'), {})
68
+ return value !== null && typeof value === 'object' && !Array.isArray(value) ? value : {}
69
+ }
70
+
71
+ saveConfigPatch(patch) {
72
+ this.writeJsonAtomic(path.join(this.root, 'config.patch.json'), patch)
73
+ return patch
74
+ }
75
+
76
+ profile() {
77
+ const value = this.readJson(path.join(this.root, 'profile.json'), emptyProfile())
78
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
79
+ ? { ...emptyProfile(), ...value }
80
+ : emptyProfile()
81
+ }
82
+
83
+ saveProfile(profile) {
84
+ this.writeJsonAtomic(path.join(this.root, 'profile.json'), profile)
85
+ }
86
+
87
+ /** {date: 'YYYY-MM-DD', count} of automatic analyses spent today. */
88
+ autoLedger(date) {
89
+ const value = this.readJson(path.join(this.root, 'auto.json'), null)
90
+ if (value !== null && typeof value === 'object' && value.date === date && typeof value.count === 'number') {
91
+ return { date, count: Math.max(0, Math.round(value.count)) }
92
+ }
93
+ return { date, count: 0 }
94
+ }
95
+
96
+ bumpAuto(date) {
97
+ const ledger = this.autoLedger(date)
98
+ const next = { date, count: ledger.count + 1 }
99
+ this.writeJsonAtomic(path.join(this.root, 'auto.json'), next)
100
+ return next
101
+ }
102
+
103
+ reportFile(sessionId, turn) {
104
+ return path.join(this.root, 'reports', this.safeSessionId(sessionId), `${turn}.json`)
105
+ }
106
+
107
+ report(sessionId, turn) {
108
+ const value = this.readJson(this.reportFile(sessionId, turn), null)
109
+ return value !== null && typeof value === 'object' && !Array.isArray(value) ? value : null
110
+ }
111
+
112
+ saveReport(sessionId, turn, report) {
113
+ this.writeJsonAtomic(this.reportFile(sessionId, turn), report)
114
+ }
115
+
116
+ /**
117
+ * Latest analysis reports across EVERY session (for the settings/sidebar
118
+ * panel), newest first, capped at `limit`. Only the plugin's own
119
+ * /^\d+\.json$/ files are read.
120
+ */
121
+ listAllReports(limit = 50) {
122
+ const root = path.join(this.root, 'reports')
123
+ let sessionDirs = []
124
+ try {
125
+ sessionDirs = fs.readdirSync(root)
126
+ } catch {
127
+ return []
128
+ }
129
+ const entries = []
130
+ for (const name of sessionDirs) {
131
+ if (!/^[A-Za-z0-9._-]{1,128}$/.test(name)) continue
132
+ const sessionDir = path.join(root, name)
133
+ let files = []
134
+ try {
135
+ files = fs.readdirSync(sessionDir)
136
+ } catch {
137
+ continue
138
+ }
139
+ for (const file of files) {
140
+ const match = /^(\d+)\.json$/.exec(file)
141
+ if (match === null) continue
142
+ const report = this.readJson(path.join(sessionDir, file), null)
143
+ if (report === null || typeof report !== 'object' || Array.isArray(report)) continue
144
+ entries.push({
145
+ sessionId: name,
146
+ turn: Number(match[1]),
147
+ time: typeof report.time === 'number' ? report.time : 0,
148
+ model: typeof report.model === 'string' ? report.model : '',
149
+ promptExcerpt: typeof report.promptExcerpt === 'string' ? report.promptExcerpt : '',
150
+ improvedPrompt: typeof report.improvedPrompt === 'string' ? report.improvedPrompt : '',
151
+ trigger: typeof report.trigger === 'string' ? report.trigger : 'manual',
152
+ })
153
+ }
154
+ }
155
+ entries.sort((a, b) => b.time - a.time)
156
+ return entries.slice(0, Math.max(0, Math.min(500, Math.floor(Number(limit) || 50))))
157
+ }
158
+
159
+ listReports(sessionId) {
160
+ const dir = path.join(this.root, 'reports', this.safeSessionId(sessionId))
161
+ let names = []
162
+ try {
163
+ names = fs.readdirSync(dir)
164
+ } catch {
165
+ return []
166
+ }
167
+ const out = []
168
+ for (const name of names) {
169
+ const match = /^(\d+)\.json$/.exec(name)
170
+ if (match === null) continue
171
+ const report = this.readJson(path.join(dir, name), null)
172
+ if (report !== null && typeof report === 'object' && !Array.isArray(report)) {
173
+ out.push({ turn: Number(match[1]), report })
174
+ }
175
+ }
176
+ return out
177
+ }
178
+
179
+ /**
180
+ * Remove only plugin-created report files (strict /^\d+\.json$/ naming)
181
+ * inside the plugin's own reports directory; the per-session directory is
182
+ * removed only when empty. Returns the number of files removed.
183
+ */
184
+ clearReports() {
185
+ const root = path.join(this.root, 'reports')
186
+ let removed = 0
187
+ let sessionDirs = []
188
+ try {
189
+ sessionDirs = fs.readdirSync(root)
190
+ } catch {
191
+ return 0
192
+ }
193
+ for (const name of sessionDirs) {
194
+ if (!/^[A-Za-z0-9._-]{1,128}$/.test(name)) continue
195
+ const sessionDir = path.join(root, name)
196
+ let files = []
197
+ try {
198
+ files = fs.readdirSync(sessionDir)
199
+ } catch {
200
+ continue
201
+ }
202
+ for (const file of files) {
203
+ if (!/^\d+\.json$/.test(file)) continue
204
+ try {
205
+ fs.unlinkSync(path.join(sessionDir, file))
206
+ removed += 1
207
+ } catch {
208
+ // Keep going: one unreadable file must not block the rest.
209
+ }
210
+ }
211
+ try {
212
+ fs.rmdirSync(sessionDir)
213
+ } catch {
214
+ // Only removed when empty; any leftover means the directory stays.
215
+ }
216
+ }
217
+ return removed
218
+ }
219
+ }
package/package.json ADDED
@@ -0,0 +1,104 @@
1
+ {
2
+ "name": "dsh-tacit",
3
+ "version": "0.2.0",
4
+ "description": "Tacit learns what you leave unsaid in your prompts — from messy turns and your own corrections, with zero clicks — and tells the agent how to compensate, on every turn, via a system-prompt section you can read and edit.",
5
+ "author": "hackernotfound",
6
+ "license": "MIT",
7
+ "type": "module",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/hackernotfound/dsh-tacit.git"
11
+ },
12
+ "homepage": "https://github.com/hackernotfound/dsh-tacit#readme",
13
+ "bugs": {
14
+ "url": "https://github.com/hackernotfound/dsh-tacit/issues"
15
+ },
16
+ "engines": {
17
+ "node": ">=22"
18
+ },
19
+ "main": "lib/index.js",
20
+ "exports": {
21
+ ".": {
22
+ "default": "./lib/index.js"
23
+ },
24
+ "./client": {
25
+ "default": "./client/client.js"
26
+ },
27
+ "./package.json": "./package.json"
28
+ },
29
+ "files": [
30
+ "lib",
31
+ "client",
32
+ "cordis.patch.yml",
33
+ "README.md",
34
+ "README.zh.md",
35
+ "LICENSE"
36
+ ],
37
+ "scripts": {
38
+ "test": "node --test",
39
+ "smoke": "node scripts/smoke.mjs"
40
+ },
41
+ "publishConfig": {
42
+ "access": "public"
43
+ },
44
+ "dsh": {
45
+ "bundle": {
46
+ "patch": "./cordis.patch.yml"
47
+ },
48
+ "client": {
49
+ "platform": "web"
50
+ }
51
+ },
52
+ "dshhub": {
53
+ "schemaVersion": 1,
54
+ "displayName": "Tacit",
55
+ "summary": "Learns what you leave unsaid (auto-analysis of messy turns and your corrections) and steers the agent for you through an editable system-prompt section. Optional ✨ Improve rewrite and opt-in pre-send context.",
56
+ "categories": [
57
+ "prompt",
58
+ "workflow",
59
+ "web-ui"
60
+ ],
61
+ "surfaces": [
62
+ "host",
63
+ "web"
64
+ ],
65
+ "capabilities": {
66
+ "provides": [
67
+ "service:tacit"
68
+ ]
69
+ },
70
+ "compatibility": {
71
+ "dsh": ">=0.1.1-rc.1",
72
+ "node": ">=22"
73
+ },
74
+ "permissions": {
75
+ "network": [
76
+ "https://api.deepseek.com"
77
+ ],
78
+ "filesystem": [
79
+ "filesystem:read",
80
+ "filesystem:write"
81
+ ]
82
+ }
83
+ },
84
+ "dependencies": {
85
+ "@deepseek-ai/dsh-home-paths": "^0.1.1-rc.2",
86
+ "@deepseek-ai/dsh-llm": "^0.1.1-rc.2",
87
+ "zod": "^4.4.3"
88
+ },
89
+ "devDependencies": {
90
+ "react": "^18.3.1",
91
+ "react-dom": "^18.3.1"
92
+ },
93
+ "keywords": [
94
+ "deepseek",
95
+ "deepseek-harness",
96
+ "dsh",
97
+ "dsh-plugin",
98
+ "prompt",
99
+ "prompt-engineering",
100
+ "system-prompt",
101
+ "personalization",
102
+ "tacit"
103
+ ]
104
+ }