@2en/clawly-plugins 1.4.1 → 1.6.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.
@@ -0,0 +1,187 @@
1
+ /**
2
+ * Memory directory browser — expose memory directory .md files as Gateway RPC methods.
3
+ *
4
+ * Gateway methods:
5
+ * - memory-browser.list — list all .md files in the OpenClaw memory directory
6
+ * - memory-browser.get — return the content of a single .md file by path
7
+ */
8
+
9
+ import fs from 'node:fs/promises'
10
+ import os from 'node:os'
11
+ import path from 'node:path'
12
+
13
+ import type {PluginApi} from '../index'
14
+
15
+ function isRecord(v: unknown): v is Record<string, unknown> {
16
+ return Boolean(v && typeof v === 'object' && !Array.isArray(v))
17
+ }
18
+
19
+ function configString(
20
+ cfg: Record<string, unknown>,
21
+ key: string,
22
+ fallback?: string,
23
+ ): string | undefined {
24
+ const v = cfg[key]
25
+ if (typeof v === 'string' && v.trim()) return v.trim()
26
+ return fallback
27
+ }
28
+
29
+ function readString(params: Record<string, unknown>, key: string): string | undefined {
30
+ const v = params[key]
31
+ if (typeof v !== 'string') return undefined
32
+ const t = v.trim()
33
+ return t ? t : undefined
34
+ }
35
+
36
+ function coercePluginConfig(api: PluginApi): Record<string, unknown> {
37
+ return isRecord(api.pluginConfig) ? api.pluginConfig : {}
38
+ }
39
+
40
+ /** Resolve the memory directory path. Memory lives under workspace (agents.defaults.workspace). */
41
+ function resolveMemoryDir(api: PluginApi, profile?: string): string {
42
+ const cfg = coercePluginConfig(api)
43
+ const configPath = configString(cfg, 'memoryDir')
44
+ if (configPath) return configPath
45
+
46
+ const baseDir =
47
+ process.env.OPENCLAW_WORKSPACE ?? path.join(os.homedir(), '.openclaw', 'workspace')
48
+ // Profile-aware: "main" or empty → default workspace, otherwise workspace-<profile>
49
+ if (profile && profile !== 'main') {
50
+ const parentDir = path.dirname(baseDir)
51
+ const baseName = path.basename(baseDir)
52
+ return path.join(parentDir, `${baseName}-${profile}`, 'memory')
53
+ }
54
+ return path.join(baseDir, 'memory')
55
+ }
56
+
57
+ /** Check that a relative path is safe (no directory traversal). */
58
+ function isSafeRelativePath(relativePath: string): boolean {
59
+ if (!relativePath || relativePath.startsWith('/') || relativePath.includes('\\')) {
60
+ return false
61
+ }
62
+ const normalized = path.normalize(relativePath)
63
+ if (normalized.startsWith('..') || normalized.includes('..')) {
64
+ return false
65
+ }
66
+ return true
67
+ }
68
+
69
+ /** Recursively collect all .md file paths under dir, relative to baseDir. */
70
+ async function collectMdFiles(dir: string, baseDir: string, acc: string[] = []): Promise<string[]> {
71
+ let entries: fs.Dirent[]
72
+ try {
73
+ entries = await fs.readdir(dir, {withFileTypes: true})
74
+ } catch (err) {
75
+ if ((err as NodeJS.ErrnoException).code === 'ENOENT') return acc
76
+ throw err
77
+ }
78
+
79
+ for (const e of entries) {
80
+ const full = path.join(dir, e.name)
81
+ const rel = path.relative(baseDir, full)
82
+
83
+ if (e.isDirectory()) {
84
+ await collectMdFiles(full, baseDir, acc)
85
+ } else if (e.isFile() && e.name.toLowerCase().endsWith('.md')) {
86
+ acc.push(rel)
87
+ }
88
+ }
89
+ return acc
90
+ }
91
+
92
+ export function registerMemoryBrowser(api: PluginApi) {
93
+ const memoryDir = resolveMemoryDir(api)
94
+ api.logger.info(`memory-browser: memory directory: ${memoryDir}`)
95
+
96
+ // -----------------------------
97
+ // memory-browser.list
98
+ // -----------------------------
99
+ api.registerGatewayMethod('memory-browser.list', async ({params, respond}) => {
100
+ try {
101
+ const profile = readString(params, 'profile')
102
+ const dir = profile ? resolveMemoryDir(api, profile) : memoryDir
103
+ const files = await collectMdFiles(dir, dir)
104
+ api.logger.info(`memory-browser.list: ${files.length} files found`)
105
+ files.sort()
106
+ respond(true, {files})
107
+ } catch (err) {
108
+ api.logger.error(
109
+ `memory-browser.list failed: ${err instanceof Error ? err.message : String(err)}`,
110
+ )
111
+ respond(false, undefined, {
112
+ code: 'error',
113
+ message: err instanceof Error ? err.message : String(err),
114
+ })
115
+ }
116
+ })
117
+
118
+ // -----------------------------
119
+ // memory-browser.get
120
+ // -----------------------------
121
+ api.registerGatewayMethod('memory-browser.get', async ({params, respond}) => {
122
+ const relativePath = readString(params, 'path')
123
+ const profile = readString(params, 'profile')
124
+ const dir = profile ? resolveMemoryDir(api, profile) : memoryDir
125
+ if (!relativePath) {
126
+ respond(false, undefined, {
127
+ code: 'invalid_params',
128
+ message: 'path (relative path to .md file) is required',
129
+ })
130
+ return
131
+ }
132
+ if (!isSafeRelativePath(relativePath)) {
133
+ respond(false, undefined, {
134
+ code: 'invalid_params',
135
+ message: 'path must be a safe relative path (no directory traversal)',
136
+ })
137
+ return
138
+ }
139
+ if (!relativePath.toLowerCase().endsWith('.md')) {
140
+ respond(false, undefined, {
141
+ code: 'invalid_params',
142
+ message: 'path must point to a .md file',
143
+ })
144
+ return
145
+ }
146
+
147
+ const fullPath = path.join(dir, path.normalize(relativePath))
148
+ const realMemory = await fs.realpath(dir).catch(() => dir)
149
+ const realResolved = await fs.realpath(fullPath).catch(() => null)
150
+
151
+ if (!realResolved) {
152
+ respond(false, undefined, {
153
+ code: 'not_found',
154
+ message: 'file not found',
155
+ })
156
+ return
157
+ }
158
+ const rel = path.relative(realMemory, realResolved)
159
+ if (rel.startsWith('..') || path.isAbsolute(rel)) {
160
+ respond(false, undefined, {
161
+ code: 'not_found',
162
+ message: 'file not found or outside memory directory',
163
+ })
164
+ return
165
+ }
166
+
167
+ try {
168
+ const content = await fs.readFile(fullPath, 'utf-8')
169
+ respond(true, {path: relativePath, content})
170
+ } catch (err) {
171
+ const code = (err as NodeJS.ErrnoException).code
172
+ if (code === 'ENOENT') {
173
+ respond(false, undefined, {code: 'not_found', message: 'file not found'})
174
+ return
175
+ }
176
+ api.logger.error(
177
+ `memory-browser.get failed: ${err instanceof Error ? err.message : String(err)}`,
178
+ )
179
+ respond(false, undefined, {
180
+ code: 'error',
181
+ message: err instanceof Error ? err.message : String(err),
182
+ })
183
+ }
184
+ })
185
+
186
+ api.logger.info(`memory-browser: registered (dir: ${memoryDir})`)
187
+ }
@@ -13,7 +13,7 @@ import fs from 'node:fs'
13
13
  import os from 'node:os'
14
14
  import path from 'node:path'
15
15
 
16
- import type {PluginApi} from './index'
16
+ import type {PluginApi} from '../index'
17
17
 
18
18
  const TOKEN_DIR = path.join(os.homedir(), '.openclaw', 'clawly')
19
19
  const TOKEN_FILE = path.join(TOKEN_DIR, 'expo-push-token.json')
@@ -0,0 +1,243 @@
1
+ /**
2
+ * Plugin version check and update management.
3
+ *
4
+ * Methods:
5
+ * - clawly.plugins.version({ pluginId, npmPkgName }) → VersionResult
6
+ * - clawly.plugins.update({ pluginId, npmPkgName, strategy, targetVersion?, restart? }) → UpdateResult
7
+ */
8
+
9
+ import fs from 'node:fs'
10
+ import path from 'node:path'
11
+ import {$} from 'zx'
12
+
13
+ import type {PluginApi} from '../index'
14
+ import {LruCache} from '../lib/lruCache'
15
+ import {isUpdateAvailable} from '../lib/semver'
16
+ import {stripCliLogs} from '../lib/stripCliLogs'
17
+
18
+ $.verbose = false
19
+
20
+ interface NpmViewCache {
21
+ version: string
22
+ versions: string[]
23
+ }
24
+
25
+ const npmCache = new LruCache<NpmViewCache>({maxSize: 5, ttlMs: 5 * 60 * 1000})
26
+
27
+ function resolveStateDir(api: PluginApi): string {
28
+ return api.runtime.state?.resolveStateDir?.(process.env) ?? process.env.OPENCLAW_STATE_DIR ?? ''
29
+ }
30
+
31
+ function readExtensionVersion(stateDir: string, pluginId: string): string | null {
32
+ try {
33
+ const pkgPath = path.join(stateDir, 'extensions', pluginId, 'package.json')
34
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'))
35
+ return typeof pkg.version === 'string' ? pkg.version : null
36
+ } catch {
37
+ return null
38
+ }
39
+ }
40
+
41
+ async function fetchNpmView(npmPkgName: string): Promise<NpmViewCache | null> {
42
+ const cached = npmCache.get(npmPkgName)
43
+ if (cached) return cached
44
+
45
+ try {
46
+ const result = await $`npm view ${npmPkgName} --json`
47
+ const parsed = JSON.parse(stripCliLogs(result.stdout))
48
+ const entry: NpmViewCache = {
49
+ version: typeof parsed.version === 'string' ? parsed.version : '',
50
+ versions: Array.isArray(parsed.versions) ? parsed.versions : [],
51
+ }
52
+ npmCache.set(npmPkgName, entry)
53
+ return entry
54
+ } catch {
55
+ return null
56
+ }
57
+ }
58
+
59
+ export function registerPlugins(api: PluginApi) {
60
+ // ── clawly.plugins.version ──────────────────────────────────────
61
+
62
+ api.registerGatewayMethod('clawly.plugins.version', async ({params, respond}) => {
63
+ const pluginId = typeof params.pluginId === 'string' ? params.pluginId.trim() : ''
64
+ const npmPkgName = typeof params.npmPkgName === 'string' ? params.npmPkgName.trim() : ''
65
+
66
+ if (!pluginId || !npmPkgName) {
67
+ respond(false, undefined, {
68
+ code: 'invalid_params',
69
+ message: 'pluginId and npmPkgName are required',
70
+ })
71
+ return
72
+ }
73
+
74
+ const stateDir = resolveStateDir(api)
75
+ api.logger.info(`plugins.version: checking ${pluginId} (${npmPkgName})`)
76
+ let latestNpmVersion: string | null = null
77
+ let allNpmVersions: string[] = []
78
+ let updateAvailable = false
79
+ const errors: string[] = []
80
+
81
+ // Step 1: Read installed version from extensions/<id>/package.json
82
+ const npmPackageVersion = stateDir ? readExtensionVersion(stateDir, pluginId) : null
83
+ api.logger.info(`plugins.version: installed version = ${npmPackageVersion ?? 'not found'}`)
84
+
85
+ // Step 2: Fetch npm registry info
86
+ try {
87
+ const npm = await fetchNpmView(npmPkgName)
88
+ if (npm) {
89
+ latestNpmVersion = npm.version
90
+ allNpmVersions = npm.versions
91
+ api.logger.info(
92
+ `plugins.version: npm registry latest=${latestNpmVersion}, ${allNpmVersions.length} versions available`,
93
+ )
94
+ } else {
95
+ api.logger.warn(`plugins.version: npm view returned null for ${npmPkgName}`)
96
+ }
97
+ } catch (err) {
98
+ errors.push(`npm view: ${err instanceof Error ? err.message : String(err)}`)
99
+ api.logger.error(`plugins.version: npm view failed — ${errors[errors.length - 1]}`)
100
+ }
101
+
102
+ // Step 3: Compute update availability
103
+ if (npmPackageVersion && latestNpmVersion) {
104
+ updateAvailable = isUpdateAvailable(npmPackageVersion, latestNpmVersion)
105
+ }
106
+
107
+ api.logger.info(
108
+ `plugins.version: result current=${npmPackageVersion} latest=${latestNpmVersion} updateAvailable=${updateAvailable}`,
109
+ )
110
+
111
+ respond(true, {
112
+ pluginVersion: npmPackageVersion,
113
+ npmPackageVersion,
114
+ latestNpmVersion,
115
+ allNpmVersions,
116
+ updateAvailable,
117
+ ...(errors.length ? {error: errors.join('; ')} : {}),
118
+ })
119
+ })
120
+
121
+ // ── clawly.plugins.update ──────────────────────────────────────
122
+
123
+ api.registerGatewayMethod('clawly.plugins.update', async ({params, respond}) => {
124
+ const pluginId = typeof params.pluginId === 'string' ? params.pluginId.trim() : ''
125
+ const npmPkgName = typeof params.npmPkgName === 'string' ? params.npmPkgName.trim() : ''
126
+ const strategy = typeof params.strategy === 'string' ? params.strategy.trim() : ''
127
+ const targetVersion =
128
+ typeof params.targetVersion === 'string' ? params.targetVersion.trim() : undefined
129
+ const restart = params.restart === true
130
+
131
+ if (!pluginId || !npmPkgName || !strategy) {
132
+ respond(false, undefined, {
133
+ code: 'invalid_params',
134
+ message: 'pluginId, npmPkgName, and strategy are required',
135
+ })
136
+ return
137
+ }
138
+
139
+ if (!['force', 'update', 'install'].includes(strategy)) {
140
+ respond(false, undefined, {
141
+ code: 'invalid_params',
142
+ message: `invalid strategy: ${strategy}`,
143
+ })
144
+ return
145
+ }
146
+
147
+ const installTarget = targetVersion ? `${npmPkgName}@${targetVersion}` : npmPkgName
148
+
149
+ try {
150
+ let output = ''
151
+
152
+ if (strategy === 'force') {
153
+ const stateDir = resolveStateDir(api)
154
+ if (!stateDir) throw new Error('cannot resolve openclaw state dir')
155
+
156
+ const configPath = path.join(stateDir, 'openclaw.json')
157
+
158
+ // 1. Save current plugins.entries.<id> config (preserve user settings)
159
+ let savedEntry: Record<string, unknown> = {enabled: true}
160
+ try {
161
+ const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'))
162
+ const entry = config?.plugins?.entries?.[pluginId]
163
+ if (entry && typeof entry === 'object') {
164
+ savedEntry = entry as Record<string, unknown>
165
+ }
166
+ } catch {
167
+ // config unreadable — use default
168
+ }
169
+ api.logger.info(`plugins: force — saved entry config for ${pluginId}`)
170
+
171
+ // 2. Delete plugins.entries.<id> from config so install starts clean
172
+ try {
173
+ const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'))
174
+ if (config?.plugins?.entries?.[pluginId]) {
175
+ delete config.plugins.entries[pluginId]
176
+ fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n')
177
+ }
178
+ } catch {
179
+ // ignore
180
+ }
181
+
182
+ // 3. Remove extension dir to force clean install
183
+ const extensionDir = path.join(stateDir, 'extensions', pluginId)
184
+ if (fs.existsSync(extensionDir)) {
185
+ api.logger.info(`plugins: force — removing ${extensionDir}`)
186
+ await $`rm -rf ${extensionDir}`
187
+ }
188
+
189
+ // 4. Install fresh
190
+ api.logger.info(`plugins: force — installing ${installTarget}`)
191
+ const result = await $`openclaw plugins install ${installTarget}`
192
+ output = result.stdout.trim()
193
+
194
+ // 5. Merge saved config back into plugins.entries.<id>
195
+ try {
196
+ const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'))
197
+ if (!config.plugins) config.plugins = {}
198
+ if (!config.plugins.entries) config.plugins.entries = {}
199
+ config.plugins.entries[pluginId] = {
200
+ ...config.plugins.entries[pluginId],
201
+ ...savedEntry,
202
+ }
203
+ fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n')
204
+ api.logger.info(`plugins: force — restored entry config for ${pluginId}`)
205
+ } catch (err) {
206
+ api.logger.warn(
207
+ `plugins: force — failed to restore entry config: ${err instanceof Error ? err.message : String(err)}`,
208
+ )
209
+ }
210
+ } else if (strategy === 'update') {
211
+ const result = await $`openclaw plugins update ${pluginId}`
212
+ output = result.stdout.trim()
213
+ } else {
214
+ // install
215
+ const result = await $`openclaw plugins install ${installTarget}`
216
+ output = result.stdout.trim()
217
+ }
218
+
219
+ // Invalidate npm cache for this package
220
+ npmCache.delete(npmPkgName)
221
+
222
+ let restarted = false
223
+ if (restart) {
224
+ try {
225
+ await $`openclaw gateway restart`
226
+ restarted = true
227
+ } catch (err) {
228
+ api.logger.warn(
229
+ `plugins: gateway restart failed — ${err instanceof Error ? err.message : String(err)}`,
230
+ )
231
+ }
232
+ }
233
+
234
+ respond(true, {ok: true, strategy, output, restarted})
235
+ } catch (err) {
236
+ const msg = err instanceof Error ? err.message : String(err)
237
+ api.logger.error(`plugins: update failed — ${msg}`)
238
+ respond(false, {ok: false, strategy, error: msg})
239
+ }
240
+ })
241
+
242
+ api.logger.info('plugins: registered clawly.plugins.version + clawly.plugins.update')
243
+ }
@@ -7,7 +7,8 @@
7
7
 
8
8
  import {$} from 'zx'
9
9
 
10
- import type {PluginApi} from './index'
10
+ import type {PluginApi} from '../index'
11
+ import {stripCliLogs} from '../lib/stripCliLogs'
11
12
 
12
13
  $.verbose = false
13
14
 
@@ -25,12 +26,7 @@ interface PresenceEntry {
25
26
  export async function isClientOnline(host = DEFAULT_HOST): Promise<boolean> {
26
27
  try {
27
28
  const result = await $`openclaw gateway call system-presence --json`
28
- const output = result.stdout.trim()
29
- let jsonStr = output
30
- if (!output.startsWith('[')) {
31
- jsonStr = output.slice(output.indexOf('\n['))
32
- }
33
- console.log({jsonStr})
29
+ const jsonStr = stripCliLogs(result.stdout)
34
30
  const entries: PresenceEntry[] = JSON.parse(jsonStr)
35
31
  const entry = entries.find((e) => e.host === host)
36
32
  if (!entry) return false
package/index.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * OpenClaw plugin: Clawly utility RPC methods (clawly.*).
2
+ * OpenClaw plugin: Clawly utility RPC methods (clawly.*), memory browser, and ClawHub CLI bridge.
3
3
  *
4
4
  * Gateway methods:
5
5
  * - clawly.file.getOutbound — read a persisted outbound file by original-path hash
@@ -8,6 +8,9 @@
8
8
  * - clawly.notification.send — send a push notification directly
9
9
  * - clawly.agent.send — send a message to the agent (+ optional push)
10
10
  * - clawly.agent.echo — echo-wrapped agent message (bypasses LLM)
11
+ * - memory-browser.list — list all .md files in the memory directory
12
+ * - memory-browser.get — return content of a single .md file
13
+ * - clawhub2gateway.* — ClawHub CLI RPC bridge (search/install/update/list/explore/inspect/star/unstar)
11
14
  *
12
15
  * Agent tools:
13
16
  * - clawly_is_user_online — check if user's device is connected
@@ -21,19 +24,29 @@
21
24
  * - before_tool_call — enforces delivery fields on cron.create
22
25
  */
23
26
 
24
- import {registerAgentSend} from './agent-send'
25
27
  import {registerCalendar} from './calendar'
26
- import {registerIsUserOnlineTool, registerSendAppPushTool} from './tools'
27
28
  import {registerClawlyCronChannel} from './channel'
29
+ import {registerCommands} from './command'
28
30
  import {registerCronHook} from './cron-hook'
29
- import {registerEchoCommand} from './echo'
30
31
  import {registerEmail} from './email'
32
+ import {registerGateway} from './gateway'
31
33
  import {getGatewayConfig} from './gateway-fetch'
32
- import {registerNotification} from './notification'
33
34
  import {registerOutboundHook, registerOutboundMethods} from './outbound'
34
- import {registerPresence} from './presence'
35
+ import {registerTools} from './tools'
35
36
 
36
37
  type PluginRuntime = {
38
+ system?: {
39
+ runCommandWithTimeout?: (
40
+ argv: string[],
41
+ opts: {timeoutMs: number; cwd?: string; env?: Record<string, string | undefined>},
42
+ ) => Promise<{
43
+ stdout: string
44
+ stderr: string
45
+ code: number | null
46
+ signal: string | null
47
+ killed: boolean
48
+ }>
49
+ }
37
50
  state?: {
38
51
  resolveStateDir?: (env?: NodeJS.ProcessEnv) => string
39
52
  }
@@ -86,14 +99,11 @@ export default {
86
99
  register(api: PluginApi) {
87
100
  registerOutboundHook(api)
88
101
  registerOutboundMethods(api)
89
- registerEchoCommand(api)
90
- registerPresence(api)
91
- registerNotification(api)
92
- registerAgentSend(api)
93
- registerIsUserOnlineTool(api)
94
- registerSendAppPushTool(api)
102
+ registerCommands(api)
103
+ registerTools(api)
95
104
  registerClawlyCronChannel(api)
96
105
  registerCronHook(api)
106
+ registerGateway(api)
97
107
 
98
108
  // Email & calendar (optional — requires gatewayBaseUrl + gatewayToken in config)
99
109
  const gw = getGatewayConfig(api)
@@ -0,0 +1,85 @@
1
+ import {afterEach, beforeEach, describe, expect, jest, test} from 'bun:test'
2
+ import {LruCache} from './lruCache'
3
+
4
+ describe('LruCache', () => {
5
+ test('get returns undefined for missing key', () => {
6
+ const cache = new LruCache<string>({maxSize: 3, ttlMs: 60_000})
7
+ expect(cache.get('missing')).toBeUndefined()
8
+ })
9
+
10
+ test('set and get', () => {
11
+ const cache = new LruCache<number>({maxSize: 3, ttlMs: 60_000})
12
+ cache.set('a', 1)
13
+ expect(cache.get('a')).toBe(1)
14
+ expect(cache.size).toBe(1)
15
+ })
16
+
17
+ test('evicts oldest when over maxSize', () => {
18
+ const cache = new LruCache<number>({maxSize: 2, ttlMs: 60_000})
19
+ cache.set('a', 1)
20
+ cache.set('b', 2)
21
+ cache.set('c', 3)
22
+ expect(cache.get('a')).toBeUndefined()
23
+ expect(cache.get('b')).toBe(2)
24
+ expect(cache.get('c')).toBe(3)
25
+ expect(cache.size).toBe(2)
26
+ })
27
+
28
+ test('get refreshes position (prevents eviction)', () => {
29
+ const cache = new LruCache<number>({maxSize: 2, ttlMs: 60_000})
30
+ cache.set('a', 1)
31
+ cache.set('b', 2)
32
+ // Access 'a' to refresh its position
33
+ cache.get('a')
34
+ // Insert 'c' — should evict 'b' (now oldest) instead of 'a'
35
+ cache.set('c', 3)
36
+ expect(cache.get('a')).toBe(1)
37
+ expect(cache.get('b')).toBeUndefined()
38
+ expect(cache.get('c')).toBe(3)
39
+ })
40
+
41
+ test('expired entries return undefined', () => {
42
+ const now = Date.now()
43
+ jest.setSystemTime(new Date(now))
44
+
45
+ const cache = new LruCache<string>({maxSize: 5, ttlMs: 1000})
46
+ cache.set('x', 'val')
47
+ expect(cache.get('x')).toBe('val')
48
+
49
+ // Advance past TTL
50
+ jest.setSystemTime(new Date(now + 1001))
51
+ expect(cache.get('x')).toBeUndefined()
52
+ expect(cache.size).toBe(0)
53
+
54
+ jest.restoreAllMocks()
55
+ })
56
+
57
+ test('delete removes entry', () => {
58
+ const cache = new LruCache<number>({maxSize: 5, ttlMs: 60_000})
59
+ cache.set('a', 1)
60
+ expect(cache.delete('a')).toBe(true)
61
+ expect(cache.get('a')).toBeUndefined()
62
+ expect(cache.delete('a')).toBe(false)
63
+ })
64
+
65
+ test('clear removes all entries', () => {
66
+ const cache = new LruCache<number>({maxSize: 5, ttlMs: 60_000})
67
+ cache.set('a', 1)
68
+ cache.set('b', 2)
69
+ cache.clear()
70
+ expect(cache.size).toBe(0)
71
+ expect(cache.get('a')).toBeUndefined()
72
+ })
73
+
74
+ test('overwriting a key updates value and refreshes position', () => {
75
+ const cache = new LruCache<number>({maxSize: 2, ttlMs: 60_000})
76
+ cache.set('a', 1)
77
+ cache.set('b', 2)
78
+ cache.set('a', 10)
79
+ // 'a' is refreshed, 'b' is oldest
80
+ cache.set('c', 3)
81
+ expect(cache.get('a')).toBe(10)
82
+ expect(cache.get('b')).toBeUndefined()
83
+ expect(cache.get('c')).toBe(3)
84
+ })
85
+ })
@@ -0,0 +1,50 @@
1
+ interface CacheEntry<V> {
2
+ value: V
3
+ expiresAt: number
4
+ }
5
+
6
+ export class LruCache<V> {
7
+ private map = new Map<string, CacheEntry<V>>()
8
+ private maxSize: number
9
+ private ttlMs: number
10
+
11
+ constructor(opts: {maxSize: number; ttlMs: number}) {
12
+ this.maxSize = opts.maxSize
13
+ this.ttlMs = opts.ttlMs
14
+ }
15
+
16
+ get(key: string): V | undefined {
17
+ const entry = this.map.get(key)
18
+ if (!entry) return undefined
19
+ if (Date.now() > entry.expiresAt) {
20
+ this.map.delete(key)
21
+ return undefined
22
+ }
23
+ // Refresh position: delete and re-insert (Map preserves insertion order)
24
+ this.map.delete(key)
25
+ this.map.set(key, entry)
26
+ return entry.value
27
+ }
28
+
29
+ set(key: string, value: V): void {
30
+ this.map.delete(key)
31
+ if (this.map.size >= this.maxSize) {
32
+ // Evict oldest (first entry)
33
+ const oldest = this.map.keys().next().value
34
+ if (oldest !== undefined) this.map.delete(oldest)
35
+ }
36
+ this.map.set(key, {value, expiresAt: Date.now() + this.ttlMs})
37
+ }
38
+
39
+ delete(key: string): boolean {
40
+ return this.map.delete(key)
41
+ }
42
+
43
+ clear(): void {
44
+ this.map.clear()
45
+ }
46
+
47
+ get size(): number {
48
+ return this.map.size
49
+ }
50
+ }