@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/fts.js CHANGED
@@ -1,166 +1,166 @@
1
- // FTS5 index + query builder (node:sqlite). Derived artifact — regenerable, gitignored.
2
- import { DatabaseSync } from 'node:sqlite'
3
- import { existsSync, mkdirSync, readFileSync } from 'node:fs'
4
- import { join, relative, sep } from 'node:path'
5
- import { indexedFiles } from './files.js'
6
-
7
- export const STOPWORDS = new Set([
8
- 'a', 'an', 'the', 'is', 'are', 'was', 'were', 'be', 'been', 'being', 'am',
9
- 'of', 'to', 'in', 'on', 'at', 'for', 'by', 'with', 'about', 'into', 'from',
10
- 'and', 'or', 'not', 'but', 'if', 'then', 'so', 'than', 'too', 'very',
11
- 'this', 'that', 'these', 'those', 'it', 'its', 'i', 'you', 'he', 'she', 'we', 'they',
12
- 'do', 'does', 'did', 'have', 'has', 'had', 'will', 'would', 'can', 'could', 'should',
13
- 'what', 'who', 'whom', 'when', 'where', 'why', 'how', 'which', 'there', 'here',
14
- ])
15
-
16
- export function indexPath(root) { return join(root, 'index', 'memory.fts.sqlite') }
17
-
18
- /**
19
- * OR of phrase-prefix queries over letter/number tokens (drop stopwords + <2 char terms).
20
- * Tokens align with FTS5 unicode61, which treats _ . / - as separators.
21
- */
22
- export function buildFtsQuery(input) {
23
- const terms = String(input ?? '').match(/[\p{L}\p{N}]+/gu) ?? []
24
- const words = terms.filter((w) => w.length >= 2 && !STOPWORDS.has(w.toLowerCase()))
25
- if (words.length === 0) return null
26
- return words.map((w) => '"' + w + '"*').join(' OR ')
27
- }
28
-
29
- /**
30
- * Scripts written without spaces (CJK and similar) cannot be searched as words: the unicode61
31
- * tokenizer treats a whole run as ONE token, so a substring never matches. Those queries take
32
- * the substring (trigram) index instead.
33
- */
34
- const NO_SPACE_SCRIPT = new RegExp('[' + String.fromCharCode(0x3040) + '-' + String.fromCharCode(0x30ff)
35
- + String.fromCharCode(0x3400) + '-' + String.fromCharCode(0x4dbf)
36
- + String.fromCharCode(0x4e00) + '-' + String.fromCharCode(0x9fff)
37
- + String.fromCharCode(0xf900) + '-' + String.fromCharCode(0xfaff)
38
- + String.fromCharCode(0xac00) + '-' + String.fromCharCode(0xd7af) + ']')
39
-
40
- /** True when a query needs substring matching rather than word matching. */
41
- export function needsSubstring(input) { return NO_SPACE_SCRIPT.test(String(input ?? '')) }
42
-
43
- /**
44
- * A trigram MATCH expression. The trigram tokenizer matches inside a phrase, so the query is
45
- * quoted; it needs at least three characters, and shorter input cannot be matched at all.
46
- * @returns the expression, or null when it cannot match.
47
- */
48
- export function buildTrigramQuery(input) {
49
- const text = String(input ?? '').trim().split('"').join(' ').replace(/\s+/g, ' ')
50
- if (text.length < 3) return null
51
- return '"' + text + '"'
52
- }
53
-
54
- /**
55
- * Create both indexes when missing. The word table serves normal queries; the trigram table
56
- * serves substring queries (no-space scripts), which the word tokenizer cannot express.
57
- */
58
- function ensureTables(db) {
59
- db.exec('CREATE VIRTUAL TABLE IF NOT EXISTS mem_fts USING fts5(file, line, section, content)')
60
- try { db.exec("CREATE VIRTUAL TABLE IF NOT EXISTS " + SUBSTRING_TABLE + " USING fts5(file, line, section, content, tokenize='trigram')") }
61
- catch { /* a build without trigram still works, just without substring search */ }
62
- }
63
-
64
- /**
65
- * A LIKE pattern for queries too short for the trigram index. CJK words are frequently only
66
- * two characters - 内存, 系统 - and the trigram tokenizer cannot express fewer than three, so
67
- * those would otherwise be unsearchable. The pattern is escaped so user input cannot inject
68
- * wildcards, and a bounded scan is acceptable for a personal store.
69
- * @returns the pattern, or null when the query is unusable.
70
- */
71
- export function buildLikeQuery(input) {
72
- const text = String(input ?? '').trim()
73
- if (text === '') return null
74
- const escaped = text.replace(/[\\%_]/g, (ch) => '\\' + ch)
75
- return '%' + escaped + '%'
76
- }
77
-
78
- /** The trigram (substring) table name. */
79
- export const SUBSTRING_TABLE = 'mem_fts_tri'
80
-
81
- /** Rebuild the FTS index from the committed layer files. file is root-relative (git-portable). */
82
- /** Insert every non-empty line of one file, tracking its section headings. */
83
- function indexFile(db, root, file) {
84
- const insert = db.prepare('INSERT INTO mem_fts (file, line, section, content) VALUES (?, ?, ?, ?)')
85
- const insertTri = db.prepare('INSERT INTO ' + SUBSTRING_TABLE + ' (file, line, section, content) VALUES (?, ?, ?, ?)')
86
- const rel = relative(root, file).replace(/\\/g, '/')
87
- const lines = readFileSync(file, 'utf8').split('\n')
88
- let section = ''
89
- let rows = 0
90
- for (let i = 0; i < lines.length; i++) {
91
- const line = lines[i]
92
- const h = line.match(/^##\s+(.+)$/)
93
- if (h) section = h[1].trim()
94
- if (line.trim() === '') continue
95
- insert.run(rel, i + 1, section, line)
96
- insertTri.run(rel, i + 1, section, line)
97
- rows++
98
- }
99
- return rows
100
- }
101
-
102
- /** A root-relative path, always with forward slashes. */
103
- function relOf(root, file) { return relative(root, file).replace(/\\/g, '/') }
104
-
105
- /**
106
- * Index just the files a write touched. A full rebuild re-reads and re-tokenizes the whole
107
- * store, which costs seconds once the store has a few hundred entries - and it used to run on
108
- * every single write. This keeps one write O(that file).
109
- * @param files - absolute paths to (re)index.
110
- * @returns the number of lines inserted.
111
- */
112
- export function updateIndex(root, files) {
113
- const list = [...new Set(files)].filter(Boolean)
114
- if (list.length === 0) return { rows: 0, files: 0 }
115
- mkdirSync(join(root, 'index'), { recursive: true })
116
- const db = new DatabaseSync(indexPath(root))
117
- ensureTables(db)
118
- // Rows survive a rename or a delete unless something removes them explicitly, and a ghost
119
- // hit points at a file that no longer exists. Purge first, then re-insert what was asked for.
120
- purgeMissing(db, root)
121
- const drop = db.prepare('DELETE FROM mem_fts WHERE file = ?')
122
- let dropTri
123
- try { dropTri = db.prepare('DELETE FROM ' + SUBSTRING_TABLE + ' WHERE file = ?') } catch { dropTri = undefined }
124
- let rows = 0
125
- for (const file of list) {
126
- drop.run(relOf(root, file))
127
- if (dropTri) try { dropTri.run(relOf(root, file)) } catch {}
128
- try { rows += indexFile(db, root, file) } catch {}
129
- }
130
- db.close()
131
- return { rows, files: list.length }
132
- }
133
-
134
- /**
135
- * Delete index rows whose file no longer exists on disk. Cheap enough to run on every
136
- * incremental update, and it is the only thing that notices a rename or a delete.
137
- * @returns how many stale file rows were removed.
138
- */
139
- export function purgeMissing(db, root) {
140
- let removed = 0
141
- for (const table of ['mem_fts', SUBSTRING_TABLE]) {
142
- let files
143
- try { files = db.prepare('SELECT DISTINCT file FROM ' + table).all() } catch { continue }
144
- for (const row of files) {
145
- // Stored paths are root-relative and forward-slashed.
146
- const absolute = join(root, String(row.file).split('/').join(sep))
147
- if (existsSync(absolute)) continue
148
- try { db.prepare('DELETE FROM ' + table + ' WHERE file = ?').run(row.file); removed += 1 } catch {}
149
- }
150
- }
151
- return removed
152
- }
153
-
154
- /** Rebuild the whole index. The repair path when it is missing or suspected stale. */
155
- export function buildIndex(root) {
156
- const dbPath = indexPath(root)
157
- mkdirSync(join(root, 'index'), { recursive: true })
158
- const db = new DatabaseSync(dbPath)
159
- db.exec('DROP TABLE IF EXISTS mem_fts')
160
- db.exec('DROP TABLE IF EXISTS ' + SUBSTRING_TABLE)
161
- ensureTables(db)
162
- let rows = 0
163
- for (const file of indexedFiles(root)) rows += indexFile(db, root, file)
164
- db.close()
165
- return { dbPath, rows }
166
- }
1
+ // FTS5 index + query builder (node:sqlite). Derived artifact — regenerable, gitignored.
2
+ import { DatabaseSync } from 'node:sqlite'
3
+ import { existsSync, mkdirSync, readFileSync } from 'node:fs'
4
+ import { join, relative, sep } from 'node:path'
5
+ import { indexedFiles } from './files.js'
6
+
7
+ export const STOPWORDS = new Set([
8
+ 'a', 'an', 'the', 'is', 'are', 'was', 'were', 'be', 'been', 'being', 'am',
9
+ 'of', 'to', 'in', 'on', 'at', 'for', 'by', 'with', 'about', 'into', 'from',
10
+ 'and', 'or', 'not', 'but', 'if', 'then', 'so', 'than', 'too', 'very',
11
+ 'this', 'that', 'these', 'those', 'it', 'its', 'i', 'you', 'he', 'she', 'we', 'they',
12
+ 'do', 'does', 'did', 'have', 'has', 'had', 'will', 'would', 'can', 'could', 'should',
13
+ 'what', 'who', 'whom', 'when', 'where', 'why', 'how', 'which', 'there', 'here',
14
+ ])
15
+
16
+ export function indexPath(root) { return join(root, 'index', 'memory.fts.sqlite') }
17
+
18
+ /**
19
+ * OR of phrase-prefix queries over letter/number tokens (drop stopwords + <2 char terms).
20
+ * Tokens align with FTS5 unicode61, which treats _ . / - as separators.
21
+ */
22
+ export function buildFtsQuery(input) {
23
+ const terms = String(input ?? '').match(/[\p{L}\p{N}]+/gu) ?? []
24
+ const words = terms.filter((w) => w.length >= 2 && !STOPWORDS.has(w.toLowerCase()))
25
+ if (words.length === 0) return null
26
+ return words.map((w) => '"' + w + '"*').join(' OR ')
27
+ }
28
+
29
+ /**
30
+ * Scripts written without spaces (CJK and similar) cannot be searched as words: the unicode61
31
+ * tokenizer treats a whole run as ONE token, so a substring never matches. Those queries take
32
+ * the substring (trigram) index instead.
33
+ */
34
+ const NO_SPACE_SCRIPT = new RegExp('[' + String.fromCharCode(0x3040) + '-' + String.fromCharCode(0x30ff)
35
+ + String.fromCharCode(0x3400) + '-' + String.fromCharCode(0x4dbf)
36
+ + String.fromCharCode(0x4e00) + '-' + String.fromCharCode(0x9fff)
37
+ + String.fromCharCode(0xf900) + '-' + String.fromCharCode(0xfaff)
38
+ + String.fromCharCode(0xac00) + '-' + String.fromCharCode(0xd7af) + ']')
39
+
40
+ /** True when a query needs substring matching rather than word matching. */
41
+ export function needsSubstring(input) { return NO_SPACE_SCRIPT.test(String(input ?? '')) }
42
+
43
+ /**
44
+ * A trigram MATCH expression. The trigram tokenizer matches inside a phrase, so the query is
45
+ * quoted; it needs at least three characters, and shorter input cannot be matched at all.
46
+ * @returns the expression, or null when it cannot match.
47
+ */
48
+ export function buildTrigramQuery(input) {
49
+ const text = String(input ?? '').trim().split('"').join(' ').replace(/\s+/g, ' ')
50
+ if (text.length < 3) return null
51
+ return '"' + text + '"'
52
+ }
53
+
54
+ /**
55
+ * Create both indexes when missing. The word table serves normal queries; the trigram table
56
+ * serves substring queries (no-space scripts), which the word tokenizer cannot express.
57
+ */
58
+ function ensureTables(db) {
59
+ db.exec('CREATE VIRTUAL TABLE IF NOT EXISTS mem_fts USING fts5(file, line, section, content)')
60
+ try { db.exec("CREATE VIRTUAL TABLE IF NOT EXISTS " + SUBSTRING_TABLE + " USING fts5(file, line, section, content, tokenize='trigram')") }
61
+ catch { /* a build without trigram still works, just without substring search */ }
62
+ }
63
+
64
+ /**
65
+ * A LIKE pattern for queries too short for the trigram index. CJK words are frequently only
66
+ * two characters - 内存, 系统 - and the trigram tokenizer cannot express fewer than three, so
67
+ * those would otherwise be unsearchable. The pattern is escaped so user input cannot inject
68
+ * wildcards, and a bounded scan is acceptable for a personal store.
69
+ * @returns the pattern, or null when the query is unusable.
70
+ */
71
+ export function buildLikeQuery(input) {
72
+ const text = String(input ?? '').trim()
73
+ if (text === '') return null
74
+ const escaped = text.replace(/[\\%_]/g, (ch) => '\\' + ch)
75
+ return '%' + escaped + '%'
76
+ }
77
+
78
+ /** The trigram (substring) table name. */
79
+ export const SUBSTRING_TABLE = 'mem_fts_tri'
80
+
81
+ /** Rebuild the FTS index from the committed layer files. file is root-relative (git-portable). */
82
+ /** Insert every non-empty line of one file, tracking its section headings. */
83
+ function indexFile(db, root, file) {
84
+ const insert = db.prepare('INSERT INTO mem_fts (file, line, section, content) VALUES (?, ?, ?, ?)')
85
+ const insertTri = db.prepare('INSERT INTO ' + SUBSTRING_TABLE + ' (file, line, section, content) VALUES (?, ?, ?, ?)')
86
+ const rel = relative(root, file).replace(/\\/g, '/')
87
+ const lines = readFileSync(file, 'utf8').split('\n')
88
+ let section = ''
89
+ let rows = 0
90
+ for (let i = 0; i < lines.length; i++) {
91
+ const line = lines[i]
92
+ const h = line.match(/^##\s+(.+)$/)
93
+ if (h) section = h[1].trim()
94
+ if (line.trim() === '') continue
95
+ insert.run(rel, i + 1, section, line)
96
+ insertTri.run(rel, i + 1, section, line)
97
+ rows++
98
+ }
99
+ return rows
100
+ }
101
+
102
+ /** A root-relative path, always with forward slashes. */
103
+ function relOf(root, file) { return relative(root, file).replace(/\\/g, '/') }
104
+
105
+ /**
106
+ * Index just the files a write touched. A full rebuild re-reads and re-tokenizes the whole
107
+ * store, which costs seconds once the store has a few hundred entries - and it used to run on
108
+ * every single write. This keeps one write O(that file).
109
+ * @param files - absolute paths to (re)index.
110
+ * @returns the number of lines inserted.
111
+ */
112
+ export function updateIndex(root, files) {
113
+ const list = [...new Set(files)].filter(Boolean)
114
+ if (list.length === 0) return { rows: 0, files: 0 }
115
+ mkdirSync(join(root, 'index'), { recursive: true })
116
+ const db = new DatabaseSync(indexPath(root))
117
+ ensureTables(db)
118
+ // Rows survive a rename or a delete unless something removes them explicitly, and a ghost
119
+ // hit points at a file that no longer exists. Purge first, then re-insert what was asked for.
120
+ purgeMissing(db, root)
121
+ const drop = db.prepare('DELETE FROM mem_fts WHERE file = ?')
122
+ let dropTri
123
+ try { dropTri = db.prepare('DELETE FROM ' + SUBSTRING_TABLE + ' WHERE file = ?') } catch { dropTri = undefined }
124
+ let rows = 0
125
+ for (const file of list) {
126
+ drop.run(relOf(root, file))
127
+ if (dropTri) try { dropTri.run(relOf(root, file)) } catch {}
128
+ try { rows += indexFile(db, root, file) } catch {}
129
+ }
130
+ db.close()
131
+ return { rows, files: list.length }
132
+ }
133
+
134
+ /**
135
+ * Delete index rows whose file no longer exists on disk. Cheap enough to run on every
136
+ * incremental update, and it is the only thing that notices a rename or a delete.
137
+ * @returns how many stale file rows were removed.
138
+ */
139
+ export function purgeMissing(db, root) {
140
+ let removed = 0
141
+ for (const table of ['mem_fts', SUBSTRING_TABLE]) {
142
+ let files
143
+ try { files = db.prepare('SELECT DISTINCT file FROM ' + table).all() } catch { continue }
144
+ for (const row of files) {
145
+ // Stored paths are root-relative and forward-slashed.
146
+ const absolute = join(root, String(row.file).split('/').join(sep))
147
+ if (existsSync(absolute)) continue
148
+ try { db.prepare('DELETE FROM ' + table + ' WHERE file = ?').run(row.file); removed += 1 } catch {}
149
+ }
150
+ }
151
+ return removed
152
+ }
153
+
154
+ /** Rebuild the whole index. The repair path when it is missing or suspected stale. */
155
+ export function buildIndex(root) {
156
+ const dbPath = indexPath(root)
157
+ mkdirSync(join(root, 'index'), { recursive: true })
158
+ const db = new DatabaseSync(dbPath)
159
+ db.exec('DROP TABLE IF EXISTS mem_fts')
160
+ db.exec('DROP TABLE IF EXISTS ' + SUBSTRING_TABLE)
161
+ ensureTables(db)
162
+ let rows = 0
163
+ for (const file of indexedFiles(root)) rows += indexFile(db, root, file)
164
+ db.close()
165
+ return { dbPath, rows }
166
+ }
package/src/git.js CHANGED
@@ -1,53 +1,53 @@
1
- // Git operations + sync scheduling. The runner is injectable so the logic is
2
- // unit-testable without a git binary; the plugin injects ctx.subprocess.
3
- export function commitMessage(scope, summary) {
4
- return 'memory(' + (scope || 'store') + '): ' + (summary || 'update') + ' [dsh-memory]'
5
- }
6
-
7
- export class GitRunner {
8
- /** @param {(argv: string[], opts?: {cwd?: string}) => Promise<{code?: number|null, stdout?: string, stderr?: string}>} run */
9
- constructor(run) { this.run = run }
10
- init(cwd) { return this.run(['init'], { cwd }) }
11
- remoteGet(cwd) { return this.run(['remote', 'get-url', 'origin'], { cwd }) }
12
- remoteAdd(cwd, url) { return this.run(['remote', 'add', 'origin', url], { cwd }) }
13
- remoteSet(cwd, url) { return this.run(['remote', 'set-url', 'origin', url], { cwd }) }
14
- add(cwd, paths) { return this.run(['add', '--', ...paths], { cwd }) }
15
- addAll(cwd) { return this.run(['add', '-A'], { cwd }) }
16
- commit(cwd, msg) { return this.run(['commit', '-m', msg], { cwd }) }
17
- pull(cwd, branch) { return this.run(['pull', '--rebase', '--autostash', 'origin', branch], { cwd }) }
18
- push(cwd, branch) { return this.run(['push', 'origin', branch], { cwd }) }
19
- statusPorcelain(cwd) { return this.run(['status', '--porcelain'], { cwd }) }
20
- }
21
-
22
- /**
23
- * Classify a git run result. Sync used to swallow every failure with a bare
24
- * `.catch()`, which let a rebase conflict diverge the store with no signal at all.
25
- * @returns {{ok: boolean, conflicted?: boolean, reason?: string}}
26
- */
27
- export function classifyGitResult(result) {
28
- if (!result) return { ok: false, reason: 'git produced no result' }
29
- if (result.code === 0) return { ok: true }
30
- const stdout = String(result.stdout ?? '')
31
- const stderr = String(result.stderr ?? '')
32
- const combined = stdout + ' ' + stderr
33
- const conflicted = /conflict|automatic merge failed|needs merge|could not apply/i.test(combined)
34
- const reason = (stderr.trim() || stdout.trim() || 'exit code ' + String(result.code)).slice(0, 400)
35
- return { ok: false, conflicted, reason }
36
- }
37
-
38
- /** Debounced local commit + hourly remote sync, with an injectable clock. */
39
- export class SyncScheduler {
40
- constructor({ commitDebounceMs = 10000, syncIntervalMs = 3600000, now = () => Date.now() } = {}) {
41
- this.commitDebounceMs = commitDebounceMs
42
- this.syncIntervalMs = syncIntervalMs
43
- this.now = now
44
- this.dirty = false
45
- this.lastCommitAt = 0
46
- this.lastSyncAt = 0
47
- }
48
- markDirty() { this.dirty = true }
49
- shouldCommit() { return this.dirty && (this.now() - this.lastCommitAt) >= this.commitDebounceMs }
50
- committed() { this.dirty = false; this.lastCommitAt = this.now() }
51
- shouldSync() { return (this.now() - this.lastSyncAt) >= this.syncIntervalMs }
52
- synced() { this.lastSyncAt = this.now() }
53
- }
1
+ // Git operations + sync scheduling. The runner is injectable so the logic is
2
+ // unit-testable without a git binary; the plugin injects ctx.subprocess.
3
+ export function commitMessage(scope, summary) {
4
+ return 'memory(' + (scope || 'store') + '): ' + (summary || 'update') + ' [dsh-memory]'
5
+ }
6
+
7
+ export class GitRunner {
8
+ /** @param {(argv: string[], opts?: {cwd?: string}) => Promise<{code?: number|null, stdout?: string, stderr?: string}>} run */
9
+ constructor(run) { this.run = run }
10
+ init(cwd) { return this.run(['init'], { cwd }) }
11
+ remoteGet(cwd) { return this.run(['remote', 'get-url', 'origin'], { cwd }) }
12
+ remoteAdd(cwd, url) { return this.run(['remote', 'add', 'origin', url], { cwd }) }
13
+ remoteSet(cwd, url) { return this.run(['remote', 'set-url', 'origin', url], { cwd }) }
14
+ add(cwd, paths) { return this.run(['add', '--', ...paths], { cwd }) }
15
+ addAll(cwd) { return this.run(['add', '-A'], { cwd }) }
16
+ commit(cwd, msg) { return this.run(['commit', '-m', msg], { cwd }) }
17
+ pull(cwd, branch) { return this.run(['pull', '--rebase', '--autostash', 'origin', branch], { cwd }) }
18
+ push(cwd, branch) { return this.run(['push', 'origin', branch], { cwd }) }
19
+ statusPorcelain(cwd) { return this.run(['status', '--porcelain'], { cwd }) }
20
+ }
21
+
22
+ /**
23
+ * Classify a git run result. Sync used to swallow every failure with a bare
24
+ * `.catch()`, which let a rebase conflict diverge the store with no signal at all.
25
+ * @returns {{ok: boolean, conflicted?: boolean, reason?: string}}
26
+ */
27
+ export function classifyGitResult(result) {
28
+ if (!result) return { ok: false, reason: 'git produced no result' }
29
+ if (result.code === 0) return { ok: true }
30
+ const stdout = String(result.stdout ?? '')
31
+ const stderr = String(result.stderr ?? '')
32
+ const combined = stdout + ' ' + stderr
33
+ const conflicted = /conflict|automatic merge failed|needs merge|could not apply/i.test(combined)
34
+ const reason = (stderr.trim() || stdout.trim() || 'exit code ' + String(result.code)).slice(0, 400)
35
+ return { ok: false, conflicted, reason }
36
+ }
37
+
38
+ /** Debounced local commit + hourly remote sync, with an injectable clock. */
39
+ export class SyncScheduler {
40
+ constructor({ commitDebounceMs = 10000, syncIntervalMs = 3600000, now = () => Date.now() } = {}) {
41
+ this.commitDebounceMs = commitDebounceMs
42
+ this.syncIntervalMs = syncIntervalMs
43
+ this.now = now
44
+ this.dirty = false
45
+ this.lastCommitAt = 0
46
+ this.lastSyncAt = 0
47
+ }
48
+ markDirty() { this.dirty = true }
49
+ shouldCommit() { return this.dirty && (this.now() - this.lastCommitAt) >= this.commitDebounceMs }
50
+ committed() { this.dirty = false; this.lastCommitAt = this.now() }
51
+ shouldSync() { return (this.now() - this.lastSyncAt) >= this.syncIntervalMs }
52
+ synced() { this.lastSyncAt = this.now() }
53
+ }