memcue-dsh 0.4.0 → 0.5.1

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
@@ -42,9 +42,12 @@ once; `memcue_correct` of an approved fact is a deliberate correction, not a can
42
42
 
43
43
  ## Supported dsh
44
44
 
45
- The memcue binary must be 0.4.0 or newer: the plugin asks `memcue version -json` when it loads and, on an older
46
- binary, turns the session card and extraction off with one warning (recall keeps working, because the 0.3 hook is
47
- compatible). A build from source reports a suffixed version and counts as the current tree.
45
+ The plugin asks `memcue version -json` when it loads. Without a configured `binary` or `MEMCUE_BIN`, a missing binary
46
+ or one older than the plugin's release is replaced by that release, installed into
47
+ `$XDG_DATA_HOME/memcue/<version>/` (`MEMCUE_AUTO_INSTALL=0` turns that off): an older release may not open the base
48
+ the new one writes. A binary older than 0.4.0 that stays, a configured one or a failed install, turns the session card
49
+ and extraction off with one warning (recall keeps working). A build from source reports a suffixed version and counts
50
+ as the current tree.
48
51
 
49
52
  Tested with `dsh 0.2.0-rc.2`. Both the repository bundle and the standalone package declare
50
53
  `>=0.2.0-rc.2 <0.3.0` peer ranges for `@deepseek-ai/dsh-llm` and `@deepseek-ai/dsh-tools`, so dsh's compatibility
@@ -57,7 +60,10 @@ directory's `cordis.patch.yml`. The plugin directory is also its own package, fo
57
60
  development. The entry is plain ESM (`src/index.mjs`) on purpose: dsh installs a bundle under `node_modules`, where
58
61
  Node refuses to strip TypeScript types, so a `.ts` entry cannot be loaded from an installed package.
59
62
 
60
- 1. Put the `memcue` binary where the dsh process can run it and note its absolute path.
63
+ 1. Optional: put the `memcue` binary where the dsh process can run it and note its absolute path. Without a
64
+ configured `binary` and without a current `memcue` on `PATH`, the plugin installs the binary of its own release
65
+ into `${XDG_DATA_HOME:-~/.local/share}/memcue/<version>/`, the directory the Claude Code plugin uses
66
+ (`MEMCUE_AUTO_INSTALL=0` turns that off; see [Binary from the plugin](../../README.md#binary-from-the-plugin)).
61
67
 
62
68
  2. Install the bundle from the repository:
63
69
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "memcue-dsh",
3
- "version": "0.4.0",
3
+ "version": "0.5.1",
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,39 @@ 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 one, or one
533
+ // older than this plugin's release, is replaced by that release, installed or downloaded: an older release may not
534
+ // open the base the plugin's release writes (#272). Until that is done recall uses what PATH has. Anything else
535
+ // turns the card and extraction off with one clear line instead of failing every turn.
536
+ const checkBinary = async () => {
537
+ let { found, reported } = await versionOf()
538
+ let version = ''
539
+ try {
540
+ version = await pluginVersion()
541
+ } catch {}
542
+ if (!settings.pinned && !(found && versionAtLeast(reported, version || MIN_BINARY))) {
523
543
  try {
524
- reported = String(JSON.parse(out)?.version ?? '')
525
- } catch {
526
- reported = out.trim().split(/\s+/)[0] ?? ''
544
+ version = version || (await pluginVersion())
545
+ settings.binary = await install(version)
546
+ ;({ found, reported } = await versionOf())
547
+ } catch (error) {
548
+ if (!found) console.warn(`memcue: no memcue binary on PATH, and the plugin could not install ${version || 'its release'}: ${error.message}`)
527
549
  }
528
550
  }
529
- if (versionAtLeast(reported, MIN_BINARY)) return
551
+ if (!found || versionAtLeast(reported, MIN_BINARY)) return
530
552
  compatible = false
531
553
  extraction = false
532
554
  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
+ }