dsh-file-activity 0.4.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/lib/state.js ADDED
@@ -0,0 +1,140 @@
1
+ /**
2
+ * State model + persistence for file activity.
3
+ *
4
+ * State (recent history + per-file counts) is kept per session and persisted
5
+ * to $DSH_HOME/file-activity.json (atomic tmp+rename, debounced). Loading is
6
+ * defensive: missing / corrupt / wrong-version files degrade to fresh state.
7
+ */
8
+ import { readFile } from 'node:fs/promises'
9
+ import { homedir } from 'node:os'
10
+
11
+ /** How many recent entries to keep per session (LRU: one entry per path). */
12
+ const RECENT_LIMIT = 5
13
+
14
+ /** State file: $DSH_HOME/file-activity.json (fallback: ~/.dsh/file-activity.json). */
15
+ export function stateFile() {
16
+ const home = process.env.DSH_HOME
17
+ if (typeof home === 'string' && home !== '') return `${home}/file-activity.json`
18
+ return `${homedir()}/.dsh/file-activity.json`
19
+ }
20
+
21
+ /** Empty state document. */
22
+ export function createState() {
23
+ return { version: 1, sessions: {} }
24
+ }
25
+
26
+ /** Load persisted state (missing/corrupt file → fresh state). */
27
+ export async function loadState(file) {
28
+ try {
29
+ const raw = await readFile(file, 'utf8')
30
+ const parsed = JSON.parse(raw)
31
+ if (parsed !== null && typeof parsed === 'object' && parsed.version === 1) return parsed
32
+ } catch {
33
+ // first run or unreadable file: start fresh
34
+ }
35
+ return createState()
36
+ }
37
+
38
+ /** Map a raw operation kind (tool name or client op) to 'read' | 'write' | 'edit'. */
39
+ export function mapOp(op) {
40
+ switch (op) {
41
+ case 'write': return 'write'
42
+ case 'edit':
43
+ case 'str_replace_editor': return 'edit'
44
+ case 'read':
45
+ case 'read_image':
46
+ default: return 'read'
47
+ }
48
+ }
49
+
50
+ /**
51
+ * Fold one observed operation into the state.
52
+ * 'write' is classified create vs modify through the per-session known-file
53
+ * registry (first contact = create, later writes = modify); edits are always
54
+ * modifies. Each file's counters also track firstSeen (first contact time,
55
+ * i.e. creation time) and lastSeen (most recent activity time).
56
+ * Returns true when a record was produced.
57
+ */
58
+ export function applyRecord(state, sessionId, path, op, time) {
59
+ if (!isValidRecordTarget(sessionId, path)) return false
60
+ const session = state.sessions[sessionId] ?? (state.sessions[sessionId] = { known: {}, counts: {}, recent: [] })
61
+ const timestamp = typeof time === 'number' ? time : Date.now()
62
+ const firstSeen = typeof session.known[path] === 'number' ? session.known[path] : timestamp
63
+ const finalOp = classifyOp(op, session.known[path])
64
+ session.known[path] = firstSeen
65
+ const counts = session.counts[path] ?? (session.counts[path] = { read: 0, create: 0, modify: 0 })
66
+ bumpCount(counts, finalOp, firstSeen, timestamp)
67
+ // Newest-first LRU history: revisiting a path moves it to the front
68
+ // instead of appending a duplicate; cap at RECENT_LIMIT entries.
69
+ const existing = session.recent.findIndex((entry) => entry.path === path)
70
+ if (existing !== -1) session.recent.splice(existing, 1)
71
+ session.recent.unshift({ path, op: finalOp, time: timestamp })
72
+ if (session.recent.length > RECENT_LIMIT) session.recent.length = RECENT_LIMIT
73
+ return true
74
+ }
75
+
76
+ /** A record target is valid when both ids are non-empty strings (no NUL). */
77
+ function isValidRecordTarget(sessionId, path) {
78
+ return typeof sessionId === 'string' && sessionId !== ''
79
+ && typeof path === 'string' && path !== '' && !path.includes('\0')
80
+ }
81
+
82
+ /** 'write' → create/modify by the known-file registry; 'edit' → modify; else read. */
83
+ function classifyOp(op, knownTime) {
84
+ if (op === 'write') return knownTime ? 'modify' : 'create'
85
+ if (op === 'edit') return 'modify'
86
+ return 'read'
87
+ }
88
+
89
+ /** Increment the matching counter and refresh firstSeen/lastSeen. */
90
+ function bumpCount(counts, finalOp, firstSeen, timestamp) {
91
+ if (finalOp === 'create') counts.create += 1
92
+ else if (finalOp === 'modify') counts.modify += 1
93
+ else counts.read += 1
94
+ counts.firstSeen = firstSeen
95
+ counts.lastSeen = timestamp
96
+ }
97
+
98
+ /**
99
+ * Normalize + trim a loaded state document in place: null/absent sessions are
100
+ * reset to {}, pre-existing history is deduped by path and capped at
101
+ * RECENT_LIMIT (LRU semantics: one entry per path, newest occurrence wins —
102
+ * the array is newest-first, so the first occurrence of each path is kept).
103
+ * Returns { state, trimmed } where trimmed reports whether anything changed.
104
+ */
105
+ export function trimLoadedState(loaded) {
106
+ if (loaded.sessions === undefined || loaded.sessions === null || typeof loaded.sessions !== 'object') loaded.sessions = {}
107
+ let trimmed = false
108
+ for (const session of Object.values(loaded.sessions)) {
109
+ if (Array.isArray(session.recent)) {
110
+ const seen = new Set()
111
+ const deduped = session.recent.filter((entry) => {
112
+ if (typeof entry?.path !== 'string' || entry.path === '') return false
113
+ if (seen.has(entry.path)) return false
114
+ seen.add(entry.path)
115
+ return true
116
+ })
117
+ if (deduped.length !== session.recent.length) trimmed = true
118
+ session.recent = deduped
119
+ if (session.recent.length > RECENT_LIMIT) {
120
+ session.recent.length = RECENT_LIMIT
121
+ trimmed = true
122
+ }
123
+ }
124
+ }
125
+ return { state: loaded, trimmed }
126
+ }
127
+
128
+ /**
129
+ * Whether `path` appears in this session's recorded file activity (counts or
130
+ * recent). The media route authorizes EXACTLY these paths — the record itself
131
+ * is the permission: the agent actually touched the file, so previewing it is
132
+ * expected, while arbitrary unrecorded paths stay refused.
133
+ */
134
+ export function isRecordedPath(state, sessionId, path) {
135
+ const session = state.sessions[sessionId]
136
+ if (session === undefined) return false
137
+ if (session.counts !== undefined && typeof session.counts[path] === 'object' && session.counts[path] !== null) return true
138
+ if (Array.isArray(session.recent)) return session.recent.some((entry) => entry.path === path)
139
+ return false
140
+ }
package/lib/store.js ADDED
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Store lifecycle for file activity: async state load with buffering of
3
+ * records that arrive before it completes, debounced atomic persistence
4
+ * (tmp+rename) and a teardown flush. The exposed store object carries a live
5
+ * `state` reference so route handlers always read the current document.
6
+ */
7
+ import { rename, writeFile } from 'node:fs/promises'
8
+ import { applyRecord, createState, loadState, mapOp, stateFile, trimLoadedState } from './state.js'
9
+
10
+ /** Build the per-apply store: { state, record, schedulePersist, dispose }. */
11
+ export function createStore(ctx) {
12
+ const store = { state: createState() }
13
+ const handle = {
14
+ ctx,
15
+ file: stateFile(),
16
+ store,
17
+ pending: [],
18
+ ready: false,
19
+ persistTimer: null,
20
+ dirtyChain: Promise.resolve(),
21
+ }
22
+ handle.persistNow = () => persistNow(handle)
23
+ handle.persistSoon = () => persistSoon(handle)
24
+ store.record = (sessionId, path, op, time) => record(handle, sessionId, path, op, time)
25
+ store.schedulePersist = () => persistSoon(handle)
26
+ store.dispose = () => dispose(handle)
27
+ void loadState(handle.file).then((loaded) => onLoaded(handle, loaded))
28
+ return store
29
+ }
30
+
31
+ /** Write the current state atomically (serialized through dirtyChain). */
32
+ function persistNow(handle) {
33
+ const snapshot = JSON.stringify(handle.store.state)
34
+ const tmp = `${handle.file}.tmp-${process.pid}`
35
+ handle.dirtyChain = handle.dirtyChain.then(async () => {
36
+ try {
37
+ await writeFile(tmp, snapshot, 'utf8')
38
+ await rename(tmp, handle.file)
39
+ } catch (error) {
40
+ handle.ctx.logger?.warn(`[dsh-file-activity] persist failed: ${error instanceof Error ? error.message : String(error)}`)
41
+ }
42
+ }).catch(() => {})
43
+ }
44
+
45
+ /** Debounced (500ms) schedule of a persist. */
46
+ function persistSoon(handle) {
47
+ if (handle.persistTimer !== null) return
48
+ handle.persistTimer = setTimeout(() => {
49
+ handle.persistTimer = null
50
+ persistNow(handle)
51
+ }, 500)
52
+ }
53
+
54
+ /** State loaded: normalize/trim, mark ready, drain buffered records. */
55
+ function onLoaded(handle, loaded) {
56
+ const result = trimLoadedState(loaded)
57
+ handle.store.state = result.state
58
+ handle.ready = true
59
+ if (drainPending(handle) || result.trimmed) persistSoon(handle)
60
+ }
61
+
62
+ /** Apply every record buffered before the state load finished. */
63
+ function drainPending(handle) {
64
+ const drained = handle.pending.splice(0)
65
+ for (const item of drained) {
66
+ applyRecord(handle.store.state, item.sessionId, item.path, mapOp(item.op), item.time)
67
+ }
68
+ return drained.length > 0
69
+ }
70
+
71
+ /** Record an operation once state is loaded (buffered before that). */
72
+ function record(handle, sessionId, path, op, time) {
73
+ if (!handle.ready) {
74
+ handle.pending.push({ sessionId, path, op, time })
75
+ return true
76
+ }
77
+ if (applyRecord(handle.store.state, sessionId, path, op, time)) {
78
+ persistSoon(handle)
79
+ return true
80
+ }
81
+ return false
82
+ }
83
+
84
+ /** Tear down on unload: flush pending persistence. */
85
+ function dispose(handle) {
86
+ if (handle.persistTimer !== null) {
87
+ clearTimeout(handle.persistTimer)
88
+ handle.persistTimer = null
89
+ }
90
+ persistNow(handle)
91
+ void handle.dirtyChain
92
+ }
package/package.json ADDED
@@ -0,0 +1,60 @@
1
+ {
2
+ "name": "dsh-file-activity",
3
+ "version": "0.4.6",
4
+ "description": "DSH web plugin: file activity tracker for dsh-better-sidebar — recent file access history plus create/modify/read counts grouped by folder (dotted multi-level paths), opening files in the sidebar's built-in native preview (syntax highlighting / Markdown rendering / images / PDF) and auto-open.",
5
+ "type": "module",
6
+ "main": "lib/index.js",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "https://github.com/baosfeng/my-dsh-plugins"
10
+ },
11
+ "engines": {
12
+ "node": ">=22"
13
+ },
14
+ "scripts": {
15
+ "build": "node scripts/build.mjs",
16
+ "test": "npx vitest run --coverage && cucumber-js test/features --import 'test/features/steps/*.mjs'"
17
+ },
18
+ "exports": {
19
+ ".": {
20
+ "default": "./lib/index.js"
21
+ },
22
+ "./client": {
23
+ "default": "./lib/client.js"
24
+ },
25
+ "./package.json": "./package.json"
26
+ },
27
+ "files": [
28
+ "lib",
29
+ "cordis.patch.yml",
30
+ "README.md",
31
+ "CHANGELOG.md",
32
+ "LICENSE",
33
+ "assets"
34
+ ],
35
+ "dsh": {
36
+ "bundle": {
37
+ "patch": "./cordis.patch.yml"
38
+ },
39
+ "client": {
40
+ "platform": "web",
41
+ "inject": [
42
+ "@deepseek-ai/dsh-client-runtime"
43
+ ]
44
+ }
45
+ },
46
+ "peerDependencies": {
47
+ "cordis": "^4.0.0-rc.8",
48
+ "dsh-better-sidebar": "^0.14.0",
49
+ "react": "^18.2.0"
50
+ },
51
+ "peerDependenciesMeta": {
52
+ "dsh-better-sidebar": {
53
+ "optional": true
54
+ },
55
+ "cordis": {
56
+ "optional": true
57
+ }
58
+ },
59
+ "license": "MIT"
60
+ }