@try-works/dsh-memory 0.1.19 → 0.1.20

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.
Files changed (51) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +585 -585
  3. package/STATUS.md +287 -287
  4. package/cordis.patch.yml +69 -69
  5. package/package.json +1 -1
  6. package/scripts/check-package.mjs +76 -76
  7. package/src/SYSTEM.template.md +311 -311
  8. package/src/capture.js +242 -242
  9. package/src/client/index.js +886 -886
  10. package/src/config.js +57 -57
  11. package/src/consolidate-run.js +132 -132
  12. package/src/consolidate.js +330 -330
  13. package/src/context.js +88 -88
  14. package/src/decisions.js +106 -106
  15. package/src/defaults.js +93 -93
  16. package/src/digest.js +255 -255
  17. package/src/entry.js +179 -179
  18. package/src/eval.js +72 -72
  19. package/src/files.js +60 -60
  20. package/src/fixture.js +56 -56
  21. package/src/forget.js +71 -71
  22. package/src/freshness.js +65 -65
  23. package/src/fts.js +166 -166
  24. package/src/git.js +53 -53
  25. package/src/history.js +226 -226
  26. package/src/host.js +866 -866
  27. package/src/index.js +115 -94
  28. package/src/inject.js +108 -108
  29. package/src/layers.js +178 -178
  30. package/src/ledger.js +19 -19
  31. package/src/map.js +184 -184
  32. package/src/models.js +136 -136
  33. package/src/queue.js +602 -602
  34. package/src/rank.js +31 -31
  35. package/src/recover.js +91 -91
  36. package/src/refresh.js +80 -80
  37. package/src/remember.js +58 -58
  38. package/src/rerank.js +90 -90
  39. package/src/search.js +75 -75
  40. package/src/select.js +57 -57
  41. package/src/session.js +60 -60
  42. package/src/state.js +240 -240
  43. package/src/store.js +128 -128
  44. package/src/summarize.js +195 -195
  45. package/src/system-doc.js +171 -171
  46. package/src/tasks.js +617 -617
  47. package/src/telemetry.js +122 -122
  48. package/src/tools.js +828 -818
  49. package/src/working.js +288 -288
  50. package/src/workspace-key.js +68 -68
  51. package/src/workspace.js +87 -87
package/src/history.js CHANGED
@@ -1,226 +1,226 @@
1
- // Commit-history shard: what actually changed in a repository, and when.
2
- //
3
- // The codebase map says what a module IS; this says how it CHANGED. It is a workspace-only
4
- // layered kind (history.md index over history/<day>.md), one file per calendar day, so a
5
- // long-lived repo produces many small readable files instead of one growing log.
6
- //
7
- // Ingestion is incremental per workspace behind a hash watermark in state/history.json: only
8
- // commits the store has not recorded are turned into entries.
9
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
10
- import { join } from 'node:path'
11
- import { allocateId } from './entry.js'
12
- import { appendToTopic, layerIndexPath, layerText, rebuildLayerIndex, readLayerTopics } from './layers.js'
13
- import { updateIndex } from './fts.js'
14
-
15
- /** Field/record separators git writes for us, chosen so a commit body cannot forge them. */
16
- const FIELD_SEP = '\u001f'
17
- const RECORD_SEP = '\u001e'
18
- /** How many commits one ingest pass will record. */
19
- export const DEFAULT_HISTORY_LIMIT = 40
20
-
21
- export function historyStatePath(root) { return join(root, 'state', 'history.json') }
22
-
23
- export function readHistoryState(root) {
24
- try { return JSON.parse(readFileSync(historyStatePath(root), 'utf8')) } catch { return {} }
25
- }
26
-
27
- export function writeHistoryState(root, value) {
28
- mkdirSync(join(root, 'state'), { recursive: true })
29
- writeFileSync(historyStatePath(root), JSON.stringify(value, null, 2) + '\n')
30
- }
31
-
32
- /**
33
- * Parse log output produced with a FIELD_SEP-delimited pretty format.
34
- * @returns {{hash: string, date: string, subject: string, body: string, files: string[]}[]}
35
- */
36
- export function parseGitLog(text) {
37
- const out = []
38
- for (const record of String(text ?? '').split(RECORD_SEP)) {
39
- if (record.trim() === '') continue
40
- const parts = record.split(FIELD_SEP)
41
- if (parts.length < 4) continue
42
- // Format: hash FS iso FS subject FS body FS, then (with --name-only) the changed
43
- // files, so everything from parts[4] on is the file list.
44
- const files = []
45
- for (const chunk of parts.slice(4)) {
46
- for (const line of chunk.split('\n')) {
47
- const file = line.trim().replace(/^--\s+/, '')
48
- if (file !== '') files.push(file)
49
- }
50
- }
51
- out.push({
52
- hash: String(parts[0] ?? '').trim(),
53
- date: String(parts[1] ?? '').trim(),
54
- subject: String(parts[2] ?? '').trim(),
55
- body: String(parts[3] ?? '').trim(),
56
- files,
57
- })
58
- }
59
- return out
60
- }
61
- /**
62
- * The pretty format used for ingest. FS ends the body and RECORD_SEP ends the commit, so
63
- * --name-only appends the changed files inside the same record and neither a subject nor a
64
- * commit body can forge a boundary.
65
- */
66
- export const HISTORY_FORMAT = '%x1e%H%x1f%cI%x1f%s%x1f%b%x1f'
67
-
68
- /** Calendar day (YYYY-MM-DD) of a git ISO date, in UTC. */
69
- export function dayOf(iso) {
70
- const text = String(iso ?? '').trim()
71
- if (text === '') return 'unknown'
72
- const date = new Date(text)
73
- if (Number.isNaN(date.getTime())) return text.slice(0, 10) || 'unknown'
74
- return date.toISOString().slice(0, 10)
75
- }
76
-
77
- /** Heading for a commit entry: the subject, clipped, with the short hash. */
78
- export function commitHeading(commit) {
79
- const subject = String(commit.subject ?? '').trim() || '(no subject)'
80
- const short = String(commit.hash ?? '').slice(0, 8)
81
- const clipped = subject.length > 80 ? subject.slice(0, 77) + '...' : subject
82
- return clipped + ' (' + short + ')'
83
- }
84
-
85
- /**
86
- * Render one commit as a Format-2 block, the same shape every other layer uses so the
87
- * parser, ledger, ranking and search work on history entries unchanged.
88
- * @param commit - parsed commit.
89
- * @param id - allocated entry id.
90
- * @param options.summary - optional note describing why the change mattered.
91
- */
92
- export function renderCommitEntry(commit, id, options = {}) {
93
- const body = []
94
- body.push('Commit: ' + commit.hash)
95
- if (commit.body) body.push('', commit.body)
96
- if (commit.files.length) {
97
- body.push('', 'Files (' + commit.files.length + '):')
98
- for (const file of commit.files.slice(0, 40)) body.push('- ' + file)
99
- if (commit.files.length > 40) body.push('- (+' + (commit.files.length - 40) + ' more)')
100
- }
101
- if (options.summary) body.push('', 'Why it mattered: ' + options.summary)
102
- return [
103
- '## ' + commitHeading(commit),
104
- '',
105
- '**Id:** ' + id,
106
- '**Source:** git:' + String(commit.hash ?? '').slice(0, 12),
107
- '**Date:** ' + commit.date,
108
- '**Updated:** ' + commit.date,
109
- '**Importance:** ' + (options.importance ?? 2),
110
- '**Status:** active',
111
- '**Format:** 2',
112
- '',
113
- body.join('\n'),
114
- ].join('\n')
115
- }
116
-
117
- /**
118
- * Group unseen commits by the day file they belong in and keep only what the limit allows.
119
- * @param commits - parsed commits, any order.
120
- * @param seen - Set of hashes already recorded.
121
- * @param limit - maximum new commits to take.
122
- * @returns {{day: string, commits: object[]}[]} day groups in chronological order.
123
- */
124
- export function planHistoryIngest(commits, seen, limit = DEFAULT_HISTORY_LIMIT) {
125
- const fresh = commits.filter((commit) => commit.hash && !seen.has(commit.hash)).slice(0, limit)
126
- const byDay = new Map()
127
- for (const commit of fresh) {
128
- const day = dayOf(commit.date)
129
- if (!byDay.has(day)) byDay.set(day, [])
130
- byDay.get(day).push(commit)
131
- }
132
- return [...byDay.entries()]
133
- .sort((a, b) => a[0].localeCompare(b[0]))
134
- .map(([day, group]) => ({ day, commits: group.sort((a, b) => String(a.date).localeCompare(String(b.date))) }))
135
- }
136
-
137
- /**
138
- * Ingest new commits of one repository into that workspace history layer.
139
- *
140
- * Incremental in two layers: the git range starts at the last recorded commit when the
141
- * watermark is still reachable, and the seen-set drops anything already recorded even if the
142
- * range had to fall back (a rebase or force-push rewrites history and breaks the ancestor
143
- * walk). Only new commits are appended, so a re-ingest of the same range is a no-op.
144
- *
145
- * @param root - memory store root.
146
- * @param workspace - workspace key.
147
- * @param repoPath - local repository to read.
148
- * @param run - git runner: (argv, opts) => { code, stdout, stderr }.
149
- * @param options.limit - maximum new commits to record in this pass.
150
- * @returns a summary of what was recorded.
151
- */
152
- export async function ingestHistory(root, workspace, repoPath, run, options = {}) {
153
- const limit = options.limit ?? DEFAULT_HISTORY_LIMIT
154
- if (!looksLikeRepo(repoPath)) return { ok: false, reason: 'not a git repository', repoPath, added: 0 }
155
- const state = readHistoryState(root)
156
- const record = state[workspace] ?? { hashes: [], head: undefined }
157
- // Two guards, because bookkeeping alone is not enough. The hash watermark is fast but
158
- // bounded (and a rewound watermark, a fresh clone or an expired window would re-add
159
- // commits), so the layer itself is also read: a commit already written is never written
160
- // twice, whatever the bookkeeping says.
161
- const seen = new Set(options.allHashes ? [] : record.hashes)
162
- for (const hash of recordedCommits(root, workspace)) seen.add(hash)
163
- const range = record.head ? [record.head + '..HEAD'] : []
164
- const argv = ['log', '--date=iso-strict', '--pretty=format:' + HISTORY_FORMAT, '--name-only', '-n', String(limit)]
165
- const result = await run([...argv, ...range], { cwd: repoPath })
166
- // The runner may report either `code` or `exitCode` (GitRunner and ctx.subprocess differ).
167
- // Reading only one silently treated every successful range query as a failure, so each
168
- // ingest re-read the whole log and reported fellBack.
169
- const failed = (outcome) => (outcome?.exitCode ?? outcome?.code ?? 1) !== 0
170
- const fallback = range.length > 0 && failed(result)
171
- const output = fallback ? await run(argv, { cwd: repoPath }) : result
172
- const commits = parseGitLog(output.stdout ?? '')
173
- const plan = planHistoryIngest(commits, seen, limit)
174
- let added = 0
175
- const touched = new Set()
176
- for (const group of plan) {
177
- for (const commit of group.commits) {
178
- const heading = commitHeading(commit)
179
- const id = allocateId(layerText(root, 'workspace', workspace, 'history'), workspace, 'history')
180
- const block = renderCommitEntry(commit, id, options.renderOptions ?? {})
181
- touched.add(appendToTopic(root, 'workspace', workspace, 'history', group.day, block))
182
- seen.add(commit.hash)
183
- added += 1
184
- }
185
- }
186
- if (added > 0) {
187
- rebuildLayerIndex(root, 'workspace', workspace, 'history')
188
- // Index what this pass wrote. Without this the entries exist on disk but are
189
- // invisible to search until some unrelated write triggers a reindex.
190
- updateIndex(root, [...touched, layerIndexPath(root, 'workspace', workspace, 'history')])
191
- }
192
- const head = await run(['rev-parse', 'HEAD'], { cwd: repoPath })
193
- const headHash = (head.stdout ?? '').trim()
194
- const bounded = [...seen].slice(-500)
195
- state[workspace] = {
196
- repoPath,
197
- head: headHash !== '' ? headHash : record.head,
198
- hashes: bounded,
199
- updatedAt: new Date().toISOString(),
200
- ...(output === result ? {} : { fellBack: true }),
201
- }
202
- writeHistoryState(root, state)
203
- return { ok: true, repoPath, added, skipped: commits.length - added, fellBack: fallback, head: state[workspace].head }
204
- }
205
-
206
- /**
207
- * Commit hashes already present in a workspace history layer, read from the entries
208
- * themselves. This is the authoritative record: state/history.json is an optimisation.
209
- * @returns a Set of full hashes.
210
- */
211
- export function recordedCommits(root, workspace) {
212
- const found = new Set()
213
- for (const topic of readLayerTopics(root, 'workspace', workspace, 'history')) {
214
- for (const match of topic.text.matchAll(/^Commit: ([0-9a-f]{7,40})$/gm)) found.add(match[1])
215
- }
216
- return found
217
- }
218
-
219
- /** Commits already recorded for a workspace, from bookkeeping only. */
220
- export function recordedHashes(root, workspace) {
221
- return new Set(readHistoryState(root)[workspace]?.hashes ?? [])
222
- }
223
- /** True when a path looks like a repository the store can read history from. */
224
- export function looksLikeRepo(path) {
225
- return typeof path === 'string' && path !== '' && existsSync(join(path, '.git'))
226
- }
1
+ // Commit-history shard: what actually changed in a repository, and when.
2
+ //
3
+ // The codebase map says what a module IS; this says how it CHANGED. It is a workspace-only
4
+ // layered kind (history.md index over history/<day>.md), one file per calendar day, so a
5
+ // long-lived repo produces many small readable files instead of one growing log.
6
+ //
7
+ // Ingestion is incremental per workspace behind a hash watermark in state/history.json: only
8
+ // commits the store has not recorded are turned into entries.
9
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
10
+ import { join } from 'node:path'
11
+ import { allocateId } from './entry.js'
12
+ import { appendToTopic, layerIndexPath, layerText, rebuildLayerIndex, readLayerTopics } from './layers.js'
13
+ import { updateIndex } from './fts.js'
14
+
15
+ /** Field/record separators git writes for us, chosen so a commit body cannot forge them. */
16
+ const FIELD_SEP = '\u001f'
17
+ const RECORD_SEP = '\u001e'
18
+ /** How many commits one ingest pass will record. */
19
+ export const DEFAULT_HISTORY_LIMIT = 40
20
+
21
+ export function historyStatePath(root) { return join(root, 'state', 'history.json') }
22
+
23
+ export function readHistoryState(root) {
24
+ try { return JSON.parse(readFileSync(historyStatePath(root), 'utf8')) } catch { return {} }
25
+ }
26
+
27
+ export function writeHistoryState(root, value) {
28
+ mkdirSync(join(root, 'state'), { recursive: true })
29
+ writeFileSync(historyStatePath(root), JSON.stringify(value, null, 2) + '\n')
30
+ }
31
+
32
+ /**
33
+ * Parse log output produced with a FIELD_SEP-delimited pretty format.
34
+ * @returns {{hash: string, date: string, subject: string, body: string, files: string[]}[]}
35
+ */
36
+ export function parseGitLog(text) {
37
+ const out = []
38
+ for (const record of String(text ?? '').split(RECORD_SEP)) {
39
+ if (record.trim() === '') continue
40
+ const parts = record.split(FIELD_SEP)
41
+ if (parts.length < 4) continue
42
+ // Format: hash FS iso FS subject FS body FS, then (with --name-only) the changed
43
+ // files, so everything from parts[4] on is the file list.
44
+ const files = []
45
+ for (const chunk of parts.slice(4)) {
46
+ for (const line of chunk.split('\n')) {
47
+ const file = line.trim().replace(/^--\s+/, '')
48
+ if (file !== '') files.push(file)
49
+ }
50
+ }
51
+ out.push({
52
+ hash: String(parts[0] ?? '').trim(),
53
+ date: String(parts[1] ?? '').trim(),
54
+ subject: String(parts[2] ?? '').trim(),
55
+ body: String(parts[3] ?? '').trim(),
56
+ files,
57
+ })
58
+ }
59
+ return out
60
+ }
61
+ /**
62
+ * The pretty format used for ingest. FS ends the body and RECORD_SEP ends the commit, so
63
+ * --name-only appends the changed files inside the same record and neither a subject nor a
64
+ * commit body can forge a boundary.
65
+ */
66
+ export const HISTORY_FORMAT = '%x1e%H%x1f%cI%x1f%s%x1f%b%x1f'
67
+
68
+ /** Calendar day (YYYY-MM-DD) of a git ISO date, in UTC. */
69
+ export function dayOf(iso) {
70
+ const text = String(iso ?? '').trim()
71
+ if (text === '') return 'unknown'
72
+ const date = new Date(text)
73
+ if (Number.isNaN(date.getTime())) return text.slice(0, 10) || 'unknown'
74
+ return date.toISOString().slice(0, 10)
75
+ }
76
+
77
+ /** Heading for a commit entry: the subject, clipped, with the short hash. */
78
+ export function commitHeading(commit) {
79
+ const subject = String(commit.subject ?? '').trim() || '(no subject)'
80
+ const short = String(commit.hash ?? '').slice(0, 8)
81
+ const clipped = subject.length > 80 ? subject.slice(0, 77) + '...' : subject
82
+ return clipped + ' (' + short + ')'
83
+ }
84
+
85
+ /**
86
+ * Render one commit as a Format-2 block, the same shape every other layer uses so the
87
+ * parser, ledger, ranking and search work on history entries unchanged.
88
+ * @param commit - parsed commit.
89
+ * @param id - allocated entry id.
90
+ * @param options.summary - optional note describing why the change mattered.
91
+ */
92
+ export function renderCommitEntry(commit, id, options = {}) {
93
+ const body = []
94
+ body.push('Commit: ' + commit.hash)
95
+ if (commit.body) body.push('', commit.body)
96
+ if (commit.files.length) {
97
+ body.push('', 'Files (' + commit.files.length + '):')
98
+ for (const file of commit.files.slice(0, 40)) body.push('- ' + file)
99
+ if (commit.files.length > 40) body.push('- (+' + (commit.files.length - 40) + ' more)')
100
+ }
101
+ if (options.summary) body.push('', 'Why it mattered: ' + options.summary)
102
+ return [
103
+ '## ' + commitHeading(commit),
104
+ '',
105
+ '**Id:** ' + id,
106
+ '**Source:** git:' + String(commit.hash ?? '').slice(0, 12),
107
+ '**Date:** ' + commit.date,
108
+ '**Updated:** ' + commit.date,
109
+ '**Importance:** ' + (options.importance ?? 2),
110
+ '**Status:** active',
111
+ '**Format:** 2',
112
+ '',
113
+ body.join('\n'),
114
+ ].join('\n')
115
+ }
116
+
117
+ /**
118
+ * Group unseen commits by the day file they belong in and keep only what the limit allows.
119
+ * @param commits - parsed commits, any order.
120
+ * @param seen - Set of hashes already recorded.
121
+ * @param limit - maximum new commits to take.
122
+ * @returns {{day: string, commits: object[]}[]} day groups in chronological order.
123
+ */
124
+ export function planHistoryIngest(commits, seen, limit = DEFAULT_HISTORY_LIMIT) {
125
+ const fresh = commits.filter((commit) => commit.hash && !seen.has(commit.hash)).slice(0, limit)
126
+ const byDay = new Map()
127
+ for (const commit of fresh) {
128
+ const day = dayOf(commit.date)
129
+ if (!byDay.has(day)) byDay.set(day, [])
130
+ byDay.get(day).push(commit)
131
+ }
132
+ return [...byDay.entries()]
133
+ .sort((a, b) => a[0].localeCompare(b[0]))
134
+ .map(([day, group]) => ({ day, commits: group.sort((a, b) => String(a.date).localeCompare(String(b.date))) }))
135
+ }
136
+
137
+ /**
138
+ * Ingest new commits of one repository into that workspace history layer.
139
+ *
140
+ * Incremental in two layers: the git range starts at the last recorded commit when the
141
+ * watermark is still reachable, and the seen-set drops anything already recorded even if the
142
+ * range had to fall back (a rebase or force-push rewrites history and breaks the ancestor
143
+ * walk). Only new commits are appended, so a re-ingest of the same range is a no-op.
144
+ *
145
+ * @param root - memory store root.
146
+ * @param workspace - workspace key.
147
+ * @param repoPath - local repository to read.
148
+ * @param run - git runner: (argv, opts) => { code, stdout, stderr }.
149
+ * @param options.limit - maximum new commits to record in this pass.
150
+ * @returns a summary of what was recorded.
151
+ */
152
+ export async function ingestHistory(root, workspace, repoPath, run, options = {}) {
153
+ const limit = options.limit ?? DEFAULT_HISTORY_LIMIT
154
+ if (!looksLikeRepo(repoPath)) return { ok: false, reason: 'not a git repository', repoPath, added: 0 }
155
+ const state = readHistoryState(root)
156
+ const record = state[workspace] ?? { hashes: [], head: undefined }
157
+ // Two guards, because bookkeeping alone is not enough. The hash watermark is fast but
158
+ // bounded (and a rewound watermark, a fresh clone or an expired window would re-add
159
+ // commits), so the layer itself is also read: a commit already written is never written
160
+ // twice, whatever the bookkeeping says.
161
+ const seen = new Set(options.allHashes ? [] : record.hashes)
162
+ for (const hash of recordedCommits(root, workspace)) seen.add(hash)
163
+ const range = record.head ? [record.head + '..HEAD'] : []
164
+ const argv = ['log', '--date=iso-strict', '--pretty=format:' + HISTORY_FORMAT, '--name-only', '-n', String(limit)]
165
+ const result = await run([...argv, ...range], { cwd: repoPath })
166
+ // The runner may report either `code` or `exitCode` (GitRunner and ctx.subprocess differ).
167
+ // Reading only one silently treated every successful range query as a failure, so each
168
+ // ingest re-read the whole log and reported fellBack.
169
+ const failed = (outcome) => (outcome?.exitCode ?? outcome?.code ?? 1) !== 0
170
+ const fallback = range.length > 0 && failed(result)
171
+ const output = fallback ? await run(argv, { cwd: repoPath }) : result
172
+ const commits = parseGitLog(output.stdout ?? '')
173
+ const plan = planHistoryIngest(commits, seen, limit)
174
+ let added = 0
175
+ const touched = new Set()
176
+ for (const group of plan) {
177
+ for (const commit of group.commits) {
178
+ const heading = commitHeading(commit)
179
+ const id = allocateId(layerText(root, 'workspace', workspace, 'history'), workspace, 'history')
180
+ const block = renderCommitEntry(commit, id, options.renderOptions ?? {})
181
+ touched.add(appendToTopic(root, 'workspace', workspace, 'history', group.day, block))
182
+ seen.add(commit.hash)
183
+ added += 1
184
+ }
185
+ }
186
+ if (added > 0) {
187
+ rebuildLayerIndex(root, 'workspace', workspace, 'history')
188
+ // Index what this pass wrote. Without this the entries exist on disk but are
189
+ // invisible to search until some unrelated write triggers a reindex.
190
+ updateIndex(root, [...touched, layerIndexPath(root, 'workspace', workspace, 'history')])
191
+ }
192
+ const head = await run(['rev-parse', 'HEAD'], { cwd: repoPath })
193
+ const headHash = (head.stdout ?? '').trim()
194
+ const bounded = [...seen].slice(-500)
195
+ state[workspace] = {
196
+ repoPath,
197
+ head: headHash !== '' ? headHash : record.head,
198
+ hashes: bounded,
199
+ updatedAt: new Date().toISOString(),
200
+ ...(output === result ? {} : { fellBack: true }),
201
+ }
202
+ writeHistoryState(root, state)
203
+ return { ok: true, repoPath, added, skipped: commits.length - added, fellBack: fallback, head: state[workspace].head }
204
+ }
205
+
206
+ /**
207
+ * Commit hashes already present in a workspace history layer, read from the entries
208
+ * themselves. This is the authoritative record: state/history.json is an optimisation.
209
+ * @returns a Set of full hashes.
210
+ */
211
+ export function recordedCommits(root, workspace) {
212
+ const found = new Set()
213
+ for (const topic of readLayerTopics(root, 'workspace', workspace, 'history')) {
214
+ for (const match of topic.text.matchAll(/^Commit: ([0-9a-f]{7,40})$/gm)) found.add(match[1])
215
+ }
216
+ return found
217
+ }
218
+
219
+ /** Commits already recorded for a workspace, from bookkeeping only. */
220
+ export function recordedHashes(root, workspace) {
221
+ return new Set(readHistoryState(root)[workspace]?.hashes ?? [])
222
+ }
223
+ /** True when a path looks like a repository the store can read history from. */
224
+ export function looksLikeRepo(path) {
225
+ return typeof path === 'string' && path !== '' && existsSync(join(path, '.git'))
226
+ }