memcue-dsh 0.4.0 → 0.5.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/README.md CHANGED
@@ -57,7 +57,10 @@ directory's `cordis.patch.yml`. The plugin directory is also its own package, fo
57
57
  development. The entry is plain ESM (`src/index.mjs`) on purpose: dsh installs a bundle under `node_modules`, where
58
58
  Node refuses to strip TypeScript types, so a `.ts` entry cannot be loaded from an installed package.
59
59
 
60
- 1. Put the `memcue` binary where the dsh process can run it and note its absolute path.
60
+ 1. Optional: put the `memcue` binary where the dsh process can run it and note its absolute path. Without a
61
+ configured `binary` and without a current `memcue` on `PATH`, the plugin installs the binary of its own release
62
+ into `${XDG_DATA_HOME:-~/.local/share}/memcue/<version>/`, the directory the Claude Code plugin uses
63
+ (`MEMCUE_AUTO_INSTALL=0` turns that off; see [Binary from the plugin](../../README.md#binary-from-the-plugin)).
61
64
 
62
65
  2. Install the bundle from the repository:
63
66
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "memcue-dsh",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "memcue long-term memory for DeepSeek Harness (dsh): facts recalled before a step, extracted when a turn ends",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/index.mjs CHANGED
@@ -18,12 +18,13 @@
18
18
  * @module memcue-dsh
19
19
  */
20
20
  import { spawn } from 'node:child_process'
21
- import { createHash } from 'node:crypto'
21
+ import { createHash, randomUUID } from 'node:crypto'
22
22
  import { mkdir, readFile, readdir, rename, writeFile } from 'node:fs/promises'
23
23
  import { homedir } from 'node:os'
24
24
  import { dirname, isAbsolute, join, resolve } from 'node:path'
25
25
  import { createUserMessage } from '@deepseek-ai/dsh-llm'
26
26
  import { defineTool } from '@deepseek-ai/dsh-tools'
27
+ import { install, pluginVersion } from './install.mjs'
27
28
 
28
29
  /** Cordis plugin name used by loader diagnostics. */
29
30
  export const name = 'memcue'
@@ -135,6 +136,8 @@ function settingsOf(config) {
135
136
  }
136
137
  return {
137
138
  binary: expandHome(textOf(raw.binary, 'binary', process.env.MEMCUE_BIN || DEFAULTS.binary)) || DEFAULTS.binary,
139
+ // A configured binary is used as it is; the default one may be replaced by the release this plugin installs.
140
+ pinned: Boolean(textOf(raw.binary, 'binary', process.env.MEMCUE_BIN || '')),
138
141
  facts,
139
142
  factsKey: resolve(facts || defaultFacts()),
140
143
  // The global off switch wins over the profile: it is the one setting a user adds to a container or a launcher
@@ -406,10 +409,11 @@ async function readState(path) {
406
409
  }
407
410
 
408
411
  /** Write the checkpoint atomically: a crash leaves the old file or the new one, never a half-written one.
409
- * The batch holds raw messages, so the directory and file are private to the user. */
412
+ * The batch holds raw messages, so the directory and file are private to the user. Each write has its own temporary
413
+ * file: two plugin instances on one session would otherwise rename each other's file and fail with ENOENT (#261). */
410
414
  async function writeState(path, state) {
411
415
  await mkdir(dirname(path), { recursive: true, mode: 0o700 })
412
- const tmp = `${path}.tmp`
416
+ const tmp = `${path}.${randomUUID()}.tmp`
413
417
  await writeFile(tmp, JSON.stringify(state), { mode: 0o600 })
414
418
  await rename(tmp, path)
415
419
  }
@@ -512,21 +516,35 @@ export function apply(ctx, config) {
512
516
  interpolate: false,
513
517
  })
514
518
 
515
- // The binary is installed separately from this package: check once that it has the commands the plugin uses, and
516
- // turn the card and extraction off with one clear line instead of failing them every turn.
517
- const checkBinary = async () => {
519
+ // The version the binary reports, and whether there is a binary at all.
520
+ const versionOf = async () => {
518
521
  // `-json` is 0.4.0 too: an older binary answers the flag with an error, so ask for the plain form as well.
519
522
  let { code, out } = await runMemcue(settings, ['version', '-json'], { timeoutMs: 5_000 })
520
523
  if (code !== 0) ({ code, out } = await runMemcue(settings, ['version'], { timeoutMs: 5_000 }))
521
- let reported = ''
522
- if (code === 0) {
524
+ if (code !== 0) return { found: false, reported: '' }
525
+ try {
526
+ return { found: true, reported: String(JSON.parse(out)?.version ?? '') }
527
+ } catch {
528
+ return { found: true, reported: out.trim().split(/\s+/)[0] ?? '' }
529
+ }
530
+ }
531
+
532
+ // Check once that the binary has the commands the plugin uses. Without a configured binary, a missing or older
533
+ // one is replaced by the release of this plugin, installed or downloaded; until that is done recall uses what
534
+ // PATH has. Anything else turns the card and extraction off with one clear line instead of failing every turn.
535
+ const checkBinary = async () => {
536
+ let { found, reported } = await versionOf()
537
+ if (!settings.pinned && !(found && versionAtLeast(reported, MIN_BINARY))) {
538
+ let version = ''
523
539
  try {
524
- reported = String(JSON.parse(out)?.version ?? '')
525
- } catch {
526
- reported = out.trim().split(/\s+/)[0] ?? ''
540
+ version = await pluginVersion()
541
+ settings.binary = await install(version)
542
+ ;({ found, reported } = await versionOf())
543
+ } catch (error) {
544
+ if (!found) console.warn(`memcue: no memcue binary on PATH, and the plugin could not install ${version || 'its release'}: ${error.message}`)
527
545
  }
528
546
  }
529
- if (versionAtLeast(reported, MIN_BINARY)) return
547
+ if (!found || versionAtLeast(reported, MIN_BINARY)) return
530
548
  compatible = false
531
549
  extraction = false
532
550
  console.warn(`memcue: binary ${reported || 'unknown'} is older than ${MIN_BINARY}; the session card and extraction are off, recall still works. Update memcue.`)
@@ -0,0 +1,120 @@
1
+ /**
2
+ * Installs the memcue binary of this plugin's release when none is configured, the way the Claude Code plugin does:
3
+ * into `${XDG_DATA_HOME:-~/.local/share}/memcue/<version>/memcue`, a directory the two plugins share, checked
4
+ * against the release's `checksums.txt`. `MEMCUE_AUTO_INSTALL=0` turns it off.
5
+ *
6
+ * @module memcue-dsh/install
7
+ */
8
+ import { spawn } from 'node:child_process'
9
+ import { createHash } from 'node:crypto'
10
+ import { access, constants, chmod, mkdir, mkdtemp, readFile, rename, rm, rmdir, stat, writeFile } from 'node:fs/promises'
11
+ import { homedir } from 'node:os'
12
+ import { join } from 'node:path'
13
+
14
+ /** Where the releases are downloaded from; MEMCUE_RELEASE_URL replaces it, for a mirror or a test. */
15
+ const RELEASES = 'https://github.com/juev/memcue/releases/download'
16
+
17
+ /** A lock left by a crashed install stops blocking after this long. */
18
+ const STALE_LOCK_MS = 10 * 60_000
19
+
20
+ /**
21
+ * The version of this plugin, which is the release of the binary it installs.
22
+ * @returns the version from package.json.
23
+ */
24
+ export async function pluginVersion() {
25
+ const manifest = JSON.parse(await readFile(new URL('../package.json', import.meta.url), 'utf8'))
26
+ return String(manifest.version)
27
+ }
28
+
29
+ /**
30
+ * Path of the installed binary of a release.
31
+ * @param version - release version without the leading v.
32
+ * @returns the absolute path, whether or not it exists.
33
+ */
34
+ export function installedPath(version) {
35
+ const data = process.env.XDG_DATA_HOME || join(homedir(), '.local', 'share')
36
+ return join(data, 'memcue', version, 'memcue')
37
+ }
38
+
39
+ /**
40
+ * Whether path is an executable file.
41
+ * @param path - file to check.
42
+ * @returns true when it can be run.
43
+ */
44
+ export async function executable(path) {
45
+ try {
46
+ await access(path, constants.X_OK)
47
+ return true
48
+ } catch {
49
+ return false
50
+ }
51
+ }
52
+
53
+ function target() {
54
+ const os = { darwin: 'darwin', linux: 'linux' }[process.platform]
55
+ const arch = { x64: 'amd64', arm64: 'arm64' }[process.arch]
56
+ if (!os || !arch) throw new Error(`no memcue release for ${process.platform}/${process.arch}`)
57
+ return `${os}_${arch}`
58
+ }
59
+
60
+ async function download(url, timeoutMs) {
61
+ const response = await fetch(url, { signal: AbortSignal.timeout(timeoutMs) })
62
+ if (!response.ok) throw new Error(`${url}: ${response.status} ${response.statusText}`)
63
+ return Buffer.from(await response.arrayBuffer())
64
+ }
65
+
66
+ function untar(archive, dir) {
67
+ return new Promise((resolve, reject) => {
68
+ const child = spawn('tar', ['-xzf', archive, '-C', dir, 'memcue'], { stdio: ['ignore', 'ignore', 'pipe'] })
69
+ let err = ''
70
+ child.stderr.on('data', (chunk) => { err += chunk })
71
+ child.on('error', reject)
72
+ child.on('close', (code) => (code === 0 ? resolve() : reject(new Error(`tar: ${err.trim()}`))))
73
+ })
74
+ }
75
+
76
+ /**
77
+ * Install the binary of a release unless it is there already. The archive must match its line in the release's
78
+ * checksums.txt; it is unpacked next to the target and moved into place, so a reader never sees half a binary. A lock
79
+ * directory, the same one the Claude Code plugin takes, keeps two installs from racing.
80
+ * @param version - release version without the leading v.
81
+ * @returns the path of the installed binary.
82
+ * @throws when auto-install is off, the platform has no release, or the download or the checksum fails.
83
+ */
84
+ export async function install(version) {
85
+ const path = installedPath(version)
86
+ if (await executable(path)) return path
87
+ if (process.env.MEMCUE_AUTO_INSTALL === '0') throw new Error('MEMCUE_AUTO_INSTALL=0')
88
+ const archive = `memcue_${version}_${target()}.tar.gz`
89
+ const dir = join(path, '..')
90
+ await mkdir(dir, { recursive: true })
91
+ const lock = join(dir, '.lock')
92
+ try {
93
+ if (Date.now() - (await stat(lock)).mtimeMs > STALE_LOCK_MS) await rmdir(lock)
94
+ } catch {
95
+ // no lock
96
+ }
97
+ try {
98
+ await mkdir(lock)
99
+ } catch {
100
+ throw new Error('another install of memcue is running')
101
+ }
102
+ const tmp = await mkdtemp(join(dir, '.tmp.'))
103
+ try {
104
+ const base = `${process.env.MEMCUE_RELEASE_URL || RELEASES}/v${version}`
105
+ const [data, sums] = await Promise.all([download(`${base}/${archive}`, 45_000),
106
+ download(`${base}/checksums.txt`, 15_000)])
107
+ const want = sums.toString('utf8').split('\n').map((line) => line.trim().split(/\s+/))
108
+ .find(([, file]) => file === archive)?.[0]
109
+ const got = createHash('sha256').update(data).digest('hex')
110
+ if (!want || want !== got) throw new Error(`checksum of ${archive} does not match checksums.txt`)
111
+ await writeFile(join(tmp, archive), data)
112
+ await untar(join(tmp, archive), tmp)
113
+ await chmod(join(tmp, 'memcue'), 0o755)
114
+ await rename(join(tmp, 'memcue'), path)
115
+ return path
116
+ } finally {
117
+ await rm(tmp, { recursive: true, force: true })
118
+ await rmdir(lock).catch(() => {})
119
+ }
120
+ }