feat: 添加云端管理命令(memory-stores、vault、schedule、skill-store、agents-platform)

- /memory-stores: 远程记忆存储管理
- /vault: 密钥保险库管理
- /schedule: 云端定时触发器管理(cron)
- /skill-store: 技能商店浏览和安装
- /agents-platform: 远程 agent 调度管理

Co-Authored-By: glm-5-turbo <zai-org@claude-code-best.win>
This commit is contained in:
claude-code-best
2026-05-09 23:04:17 +08:00
parent ee63c17697
commit 2437040b5b
47 changed files with 9309 additions and 5 deletions

View File

@@ -0,0 +1,263 @@
import React from 'react';
import { Box, Text } from '@anthropic/ink';
import type { Theme } from '@anthropic/ink';
import type { Memory, MemoryStore, MemoryVersion } from './memoryStoresApi.js';
type Props =
| { mode: 'list'; stores: MemoryStore[] }
| { mode: 'detail'; store: MemoryStore }
| { mode: 'created'; store: MemoryStore }
| { mode: 'archived'; store: MemoryStore }
| { mode: 'memory-list'; storeId: string; memories: Memory[] }
| { mode: 'memory-detail'; memory: Memory }
| { mode: 'memory-created'; memory: Memory }
| { mode: 'memory-updated'; memory: Memory }
| { mode: 'memory-deleted'; storeId: string; memoryId: string }
| { mode: 'versions'; storeId: string; versions: MemoryVersion[] }
| { mode: 'redacted'; version: MemoryVersion }
| { mode: 'error'; message: string };
function StoreRow({ store }: { store: MemoryStore }): React.ReactNode {
const isArchived = !!store.archived_at;
const createdAt = store.created_at ? new Date(store.created_at).toLocaleString() : '—';
return (
<Box flexDirection="column" marginBottom={1}>
<Box>
<Text bold>{store.memory_store_id}</Text>
<Text dimColor> · </Text>
<Text color={(isArchived ? 'warning' : 'success') as keyof Theme}>{isArchived ? 'archived' : 'active'}</Text>
{store.namespace ? (
<>
<Text dimColor> · ns: </Text>
<Text>{store.namespace}</Text>
</>
) : null}
</Box>
<Text>Name: {store.name}</Text>
<Text dimColor>Created: {createdAt}</Text>
</Box>
);
}
export function MemoryStoresView(props: Props): React.ReactNode {
if (props.mode === 'list') {
if (props.stores.length === 0) {
return (
<Box>
<Text dimColor>No memory stores found. Use /memory-stores create &lt;name&gt; to create one.</Text>
</Box>
);
}
return (
<Box flexDirection="column">
<Box marginBottom={1}>
<Text bold>Memory Stores ({props.stores.length})</Text>
</Box>
{props.stores.map(store => (
<StoreRow key={store.memory_store_id} store={store} />
))}
</Box>
);
}
if (props.mode === 'detail') {
const { store } = props;
const isArchived = !!store.archived_at;
const createdAt = store.created_at ? new Date(store.created_at).toLocaleString() : '—';
const archivedAt = store.archived_at ? new Date(store.archived_at).toLocaleString() : null;
return (
<Box flexDirection="column">
<Box marginBottom={1}>
<Text bold>Memory Store: {store.memory_store_id}</Text>
</Box>
<Text>Name: {store.name}</Text>
{store.namespace ? <Text>Namespace: {store.namespace}</Text> : null}
<Text>
Status:{' '}
<Text color={(isArchived ? 'warning' : 'success') as keyof Theme}>{isArchived ? 'archived' : 'active'}</Text>
</Text>
<Text dimColor>Created: {createdAt}</Text>
{archivedAt ? <Text dimColor>Archived: {archivedAt}</Text> : null}
</Box>
);
}
if (props.mode === 'created') {
const { store } = props;
return (
<Box flexDirection="column">
<Box>
<Text bold color={'success' as keyof Theme}>
Memory store created
</Text>
</Box>
<Text>ID: {store.memory_store_id}</Text>
<Text>Name: {store.name}</Text>
{store.namespace ? <Text>Namespace: {store.namespace}</Text> : null}
</Box>
);
}
if (props.mode === 'archived') {
const { store } = props;
const archivedAt = store.archived_at ? new Date(store.archived_at).toLocaleString() : '—';
return (
<Box flexDirection="column">
<Box>
<Text bold color={'warning' as keyof Theme}>
Memory store archived
</Text>
</Box>
<Text>ID: {store.memory_store_id}</Text>
<Text dimColor>Archived at: {archivedAt}</Text>
</Box>
);
}
if (props.mode === 'memory-list') {
const { storeId, memories } = props;
if (memories.length === 0) {
return (
<Box>
<Text dimColor>
No memories in store {storeId}. Use /memory-stores create-memory {storeId} &lt;content&gt; to add one.
</Text>
</Box>
);
}
return (
<Box flexDirection="column">
<Box marginBottom={1}>
<Text bold>
Memories in {storeId} ({memories.length})
</Text>
</Box>
{memories.map(mem => (
<Box key={mem.memory_id} flexDirection="column" marginBottom={1}>
<Text bold>{mem.memory_id}</Text>
<Text dimColor>{mem.content.length > 80 ? `${mem.content.slice(0, 80)}` : mem.content}</Text>
</Box>
))}
</Box>
);
}
if (props.mode === 'memory-detail') {
const { memory } = props;
const createdAt = memory.created_at ? new Date(memory.created_at).toLocaleString() : '—';
const updatedAt = memory.updated_at ? new Date(memory.updated_at).toLocaleString() : '—';
return (
<Box flexDirection="column">
<Box marginBottom={1}>
<Text bold>Memory: {memory.memory_id}</Text>
</Box>
<Text>Store: {memory.memory_store_id}</Text>
<Text>Content: {memory.content}</Text>
<Text dimColor>Created: {createdAt}</Text>
<Text dimColor>Updated: {updatedAt}</Text>
</Box>
);
}
if (props.mode === 'memory-created') {
const { memory } = props;
return (
<Box flexDirection="column">
<Box>
<Text bold color={'success' as keyof Theme}>
Memory created
</Text>
</Box>
<Text>ID: {memory.memory_id}</Text>
<Text>Store: {memory.memory_store_id}</Text>
<Text dimColor>Content: {memory.content}</Text>
</Box>
);
}
if (props.mode === 'memory-updated') {
const { memory } = props;
return (
<Box flexDirection="column">
<Box>
<Text bold color={'success' as keyof Theme}>
Memory updated
</Text>
</Box>
<Text>ID: {memory.memory_id}</Text>
<Text dimColor>Content: {memory.content}</Text>
</Box>
);
}
if (props.mode === 'memory-deleted') {
return (
<Box>
<Text color={'success' as keyof Theme}>
Memory {props.memoryId} deleted from store {props.storeId}.
</Text>
</Box>
);
}
if (props.mode === 'versions') {
const { storeId, versions } = props;
if (versions.length === 0) {
return (
<Box>
<Text dimColor>No memory versions found for store {storeId}.</Text>
</Box>
);
}
return (
<Box flexDirection="column">
<Box marginBottom={1}>
<Text bold>
Memory Versions in {storeId} ({versions.length})
</Text>
</Box>
{versions.map(ver => {
const createdAt = ver.created_at ? new Date(ver.created_at).toLocaleString() : '—';
const isRedacted = !!ver.redacted_at;
return (
<Box key={ver.version_id} flexDirection="column" marginBottom={1}>
<Box>
<Text bold>{ver.version_id}</Text>
{isRedacted ? (
<>
<Text dimColor> · </Text>
<Text color={'warning' as keyof Theme}>redacted</Text>
</>
) : null}
</Box>
<Text dimColor>Created: {createdAt}</Text>
</Box>
);
})}
</Box>
);
}
if (props.mode === 'redacted') {
const { version } = props;
const redactedAt = version.redacted_at ? new Date(version.redacted_at).toLocaleString() : '—';
return (
<Box flexDirection="column">
<Box>
<Text bold color={'warning' as keyof Theme}>
Version redacted
</Text>
</Box>
<Text>ID: {version.version_id}</Text>
<Text dimColor>Redacted at: {redactedAt}</Text>
</Box>
);
}
// error mode
return (
<Box>
<Text color={'error' as keyof Theme}>{props.message}</Text>
</Box>
);
}

View File

@@ -0,0 +1,586 @@
/**
* Regression tests for memoryStoresApi.ts
*
* Key invariants under test:
* - updateMemory MUST use PATCH, not POST (spec: PATCH /v1/memory_stores/{id}/memories)
* - archiveStore uses POST /v1/memory_stores/{id}/archive (not DELETE)
* - redactVersion uses POST /v1/memory_stores/{id}/memory_versions/{vid}/redact
* - All endpoints hit /v1/memory_stores (not /v1/code/triggers or /v1/agents)
* - 401/403/404/429/5xx classified correctly
* - withRetry retries only 5xx, not 4xx
*/
import {
afterAll,
afterEach,
beforeAll,
beforeEach,
describe,
expect,
mock,
test,
} from 'bun:test'
import { debugMock } from '../../../../tests/mocks/debug.js'
import { logMock } from '../../../../tests/mocks/log.js'
import { setupAxiosMock } from '../../../../tests/mocks/axios.js'
mock.module('src/utils/log.ts', logMock)
mock.module('src/utils/debug.ts', debugMock)
// ── Workspace API key mock ──────────────────────────────────────────────────
const mockApiKey = 'sk-ant-api03-test-memory-stores-key'
mock.module('src/constants/oauth.js', () => ({
getOauthConfig: () => ({ BASE_API_URL: 'https://api.anthropic.com' }),
}))
const prepareWorkspaceApiRequestMock = mock(async () => ({
apiKey: mockApiKey,
}))
mock.module('src/utils/teleport/api.js', () => ({
prepareWorkspaceApiRequest: prepareWorkspaceApiRequestMock,
}))
// Note: we do NOT mock src/services/auth/hostGuard.js here.
// The real assertWorkspaceHost() is called with the URL from getOauthConfig()
// (mocked to https://api.anthropic.com), which passes the host guard.
// Mocking hostGuard would pollute hostGuard's own test file via Bun process-level cache.
// ── Axios mock ──────────────────────────────────────────────────────────────
const axiosGetMock = mock(async () => ({}))
const axiosPostMock = mock(async () => ({}))
const axiosPatchMock = mock(async () => ({}))
const axiosDeleteMock = mock(async () => ({}))
const axiosIsAxiosError = mock((err: unknown) => {
return (
typeof err === 'object' &&
err !== null &&
'isAxiosError' in err &&
(err as { isAxiosError: boolean }).isAxiosError === true
)
})
const axiosHandle = setupAxiosMock()
axiosHandle.stubs.get = axiosGetMock
axiosHandle.stubs.post = axiosPostMock
axiosHandle.stubs.patch = axiosPatchMock
axiosHandle.stubs.delete = axiosDeleteMock
axiosHandle.stubs.isAxiosError = axiosIsAxiosError
// ── Lazy import after mocks ─────────────────────────────────────────────────
let listStores: typeof import('../memoryStoresApi.js').listStores
let getStore: typeof import('../memoryStoresApi.js').getStore
let createStore: typeof import('../memoryStoresApi.js').createStore
let archiveStore: typeof import('../memoryStoresApi.js').archiveStore
let listMemories: typeof import('../memoryStoresApi.js').listMemories
let createMemory: typeof import('../memoryStoresApi.js').createMemory
let getMemory: typeof import('../memoryStoresApi.js').getMemory
let updateMemory: typeof import('../memoryStoresApi.js').updateMemory
let deleteMemory: typeof import('../memoryStoresApi.js').deleteMemory
let listVersions: typeof import('../memoryStoresApi.js').listVersions
let redactVersion: typeof import('../memoryStoresApi.js').redactVersion
beforeAll(async () => {
axiosHandle.useStubs = true
const mod = await import('../memoryStoresApi.js')
listStores = mod.listStores
getStore = mod.getStore
createStore = mod.createStore
archiveStore = mod.archiveStore
listMemories = mod.listMemories
createMemory = mod.createMemory
getMemory = mod.getMemory
updateMemory = mod.updateMemory
deleteMemory = mod.deleteMemory
listVersions = mod.listVersions
redactVersion = mod.redactVersion
})
afterAll(() => {
axiosHandle.useStubs = false
})
beforeEach(() => {
axiosGetMock.mockClear()
axiosPostMock.mockClear()
axiosPatchMock.mockClear()
axiosDeleteMock.mockClear()
prepareWorkspaceApiRequestMock.mockClear()
process.env['ANTHROPIC_API_KEY'] = mockApiKey
})
afterEach(() => {
delete process.env['ANTHROPIC_API_KEY']
})
// ── REGRESSION: updateMemory MUST use PATCH not POST ─────────────────────
describe('updateMemory regression: must use PATCH not POST', () => {
test('updateMemory calls PATCH /v1/memory_stores/{id}/memories/{mid} (not POST)', async () => {
const updated = {
memory_id: 'mem_upd',
memory_store_id: 'ms_1',
content: 'Updated content',
}
axiosPatchMock.mockResolvedValueOnce({ data: updated, status: 200 })
await updateMemory('ms_1', 'mem_upd', 'Updated content')
// PATCH must have been called
expect(axiosPatchMock).toHaveBeenCalledTimes(1)
// POST must NOT have been called for update
expect(axiosPostMock).not.toHaveBeenCalled()
// The URL must contain the store id, memories path, and memory id
const calls = axiosPatchMock.mock.calls as unknown as [
string,
unknown,
unknown,
][]
const url = calls[0]?.[0] as string
expect(url).toContain('ms_1')
expect(url).toContain('/memories/')
expect(url).toContain('mem_upd')
expect(url).toContain('/v1/memory_stores/')
})
})
// ── listStores ────────────────────────────────────────────────────────────
describe('listStores', () => {
test('returns stores on 200', async () => {
const stores = [
{
memory_store_id: 'ms_1',
name: 'My Store',
namespace: 'work',
created_at: '2026-01-01T00:00:00Z',
},
]
axiosGetMock.mockResolvedValueOnce({ data: { data: stores }, status: 200 })
const result = await listStores()
expect(result).toHaveLength(1)
expect(result[0]!.memory_store_id).toBe('ms_1')
expect(axiosGetMock).toHaveBeenCalledTimes(1)
const calls = axiosGetMock.mock.calls as unknown as [string, unknown][]
expect(calls[0]?.[0]).toContain('/v1/memory_stores')
})
test('returns empty array on empty response', async () => {
axiosGetMock.mockResolvedValueOnce({ data: { data: [] }, status: 200 })
const result = await listStores()
expect(result).toHaveLength(0)
})
test('throws 401 with friendly message', async () => {
const err = Object.assign(new Error('Unauthorized'), {
isAxiosError: true,
response: { status: 401, data: {} },
})
axiosGetMock.mockRejectedValueOnce(err)
axiosIsAxiosError.mockImplementation(
(e: unknown) =>
typeof e === 'object' &&
e !== null &&
'isAxiosError' in e &&
(e as { isAxiosError: boolean }).isAxiosError === true,
)
await expect(listStores()).rejects.toThrow(/login|authenticate/i)
})
test('throws 403 with subscription message', async () => {
const err = Object.assign(new Error('Forbidden'), {
isAxiosError: true,
response: { status: 403, data: {} },
})
axiosGetMock.mockRejectedValueOnce(err)
axiosIsAxiosError.mockImplementation(
(e: unknown) =>
typeof e === 'object' &&
e !== null &&
'isAxiosError' in e &&
(e as { isAxiosError: boolean }).isAxiosError === true,
)
await expect(listStores()).rejects.toThrow(/subscription|pro|max|team/i)
})
test('retries on 5xx and eventually throws', async () => {
const make5xx = () =>
Object.assign(new Error('Server Error'), {
isAxiosError: true,
response: { status: 500, data: {} },
})
axiosGetMock
.mockRejectedValueOnce(make5xx())
.mockRejectedValueOnce(make5xx())
.mockRejectedValueOnce(make5xx())
axiosIsAxiosError.mockImplementation(
(e: unknown) =>
typeof e === 'object' &&
e !== null &&
'isAxiosError' in e &&
(e as { isAxiosError: boolean }).isAxiosError === true,
)
await expect(listStores()).rejects.toThrow()
expect(axiosGetMock).toHaveBeenCalledTimes(3)
}, 15000)
test('honors Retry-After header on 5xx', async () => {
const serverErr = Object.assign(new Error('Service Unavailable'), {
isAxiosError: true,
response: { status: 503, data: {}, headers: { 'retry-after': '0' } },
})
axiosGetMock
.mockRejectedValueOnce(serverErr)
.mockResolvedValueOnce({ data: { data: [] }, status: 200 })
axiosIsAxiosError.mockImplementation(
(e: unknown) =>
typeof e === 'object' &&
e !== null &&
'isAxiosError' in e &&
(e as { isAxiosError: boolean }).isAxiosError === true,
)
const result = await listStores()
expect(result).toHaveLength(0)
expect(axiosGetMock).toHaveBeenCalledTimes(2)
})
})
// ── getStore ──────────────────────────────────────────────────────────────
describe('getStore', () => {
test('calls GET /v1/memory_stores/{id}', async () => {
const store = {
memory_store_id: 'ms_get',
name: 'Work Store',
namespace: 'work',
}
axiosGetMock.mockResolvedValueOnce({ data: store, status: 200 })
const result = await getStore('ms_get')
expect(result.memory_store_id).toBe('ms_get')
const calls = axiosGetMock.mock.calls as unknown as [string, unknown][]
expect(calls[0]?.[0]).toContain('ms_get')
})
test('throws 404 with not found message', async () => {
const err = Object.assign(new Error('Not Found'), {
isAxiosError: true,
response: { status: 404, data: {} },
})
axiosGetMock.mockRejectedValueOnce(err)
axiosIsAxiosError.mockImplementation(
(e: unknown) =>
typeof e === 'object' &&
e !== null &&
'isAxiosError' in e &&
(e as { isAxiosError: boolean }).isAxiosError === true,
)
await expect(getStore('nonexistent')).rejects.toThrow(/not found/i)
})
})
// ── createStore ───────────────────────────────────────────────────────────
describe('createStore', () => {
test('sends POST /v1/memory_stores with name', async () => {
const store = {
memory_store_id: 'ms_new',
name: 'My New Store',
namespace: 'default',
}
axiosPostMock.mockResolvedValueOnce({ data: store, status: 201 })
const result = await createStore('My New Store')
expect(result.memory_store_id).toBe('ms_new')
const calls = axiosPostMock.mock.calls as unknown as [
string,
unknown,
unknown,
][]
const url = calls[0]?.[0] as string
const body = calls[0]?.[1] as Record<string, unknown>
expect(url).toContain('/v1/memory_stores')
expect(url).not.toContain('/v1/agents')
expect(body.name).toBe('My New Store')
})
})
// ── archiveStore ──────────────────────────────────────────────────────────
describe('archiveStore', () => {
test('calls POST /v1/memory_stores/{id}/archive (not DELETE)', async () => {
const store = {
memory_store_id: 'ms_arc',
name: 'Archived Store',
archived_at: '2026-01-01T00:00:00Z',
}
axiosPostMock.mockResolvedValueOnce({ data: store, status: 200 })
const result = await archiveStore('ms_arc')
expect(result.memory_store_id).toBe('ms_arc')
// POST must be called for archive
expect(axiosPostMock).toHaveBeenCalledTimes(1)
// DELETE must NOT be called
expect(axiosDeleteMock).not.toHaveBeenCalled()
const calls = axiosPostMock.mock.calls as unknown as [
string,
unknown,
unknown,
][]
const url = calls[0]?.[0] as string
expect(url).toContain('ms_arc')
expect(url).toContain('/archive')
})
})
// ── listMemories ──────────────────────────────────────────────────────────
describe('listMemories', () => {
test('calls GET /v1/memory_stores/{id}/memories', async () => {
const memories = [
{ memory_id: 'mem_1', memory_store_id: 'ms_1', content: 'Test memory' },
]
axiosGetMock.mockResolvedValueOnce({
data: { data: memories },
status: 200,
})
const result = await listMemories('ms_1')
expect(result).toHaveLength(1)
expect(result[0]!.memory_id).toBe('mem_1')
const calls = axiosGetMock.mock.calls as unknown as [string, unknown][]
expect(calls[0]?.[0]).toContain('ms_1')
expect(calls[0]?.[0]).toContain('/memories')
})
test('throws 404 when store not found', async () => {
const err = Object.assign(new Error('Not Found'), {
isAxiosError: true,
response: { status: 404, data: {} },
})
axiosGetMock.mockRejectedValueOnce(err)
axiosIsAxiosError.mockImplementation(
(e: unknown) =>
typeof e === 'object' &&
e !== null &&
'isAxiosError' in e &&
(e as { isAxiosError: boolean }).isAxiosError === true,
)
await expect(listMemories('nonexistent')).rejects.toThrow(/not found/i)
})
})
// ── createMemory ──────────────────────────────────────────────────────────
describe('createMemory', () => {
test('sends POST /v1/memory_stores/{id}/memories', async () => {
const memory = {
memory_id: 'mem_new',
memory_store_id: 'ms_1',
content: 'New memory content',
}
axiosPostMock.mockResolvedValueOnce({ data: memory, status: 201 })
const result = await createMemory('ms_1', 'New memory content')
expect(result.memory_id).toBe('mem_new')
const calls = axiosPostMock.mock.calls as unknown as [
string,
unknown,
unknown,
][]
const url = calls[0]?.[0] as string
const body = calls[0]?.[1] as Record<string, unknown>
expect(url).toContain('ms_1')
expect(url).toContain('/memories')
expect(body.content).toBe('New memory content')
})
})
// ── getMemory ─────────────────────────────────────────────────────────────
describe('getMemory', () => {
test('calls GET /v1/memory_stores/{id}/memories/{mid}', async () => {
const memory = {
memory_id: 'mem_get',
memory_store_id: 'ms_1',
content: 'Memory content',
}
axiosGetMock.mockResolvedValueOnce({ data: memory, status: 200 })
const result = await getMemory('ms_1', 'mem_get')
expect(result.memory_id).toBe('mem_get')
const calls = axiosGetMock.mock.calls as unknown as [string, unknown][]
expect(calls[0]?.[0]).toContain('ms_1')
expect(calls[0]?.[0]).toContain('/memories/')
expect(calls[0]?.[0]).toContain('mem_get')
})
})
// ── deleteMemory ──────────────────────────────────────────────────────────
describe('deleteMemory', () => {
test('calls DELETE /v1/memory_stores/{id}/memories/{mid}', async () => {
axiosDeleteMock.mockResolvedValueOnce({ status: 204 })
await deleteMemory('ms_1', 'mem_del')
const calls = axiosDeleteMock.mock.calls as unknown as [string, unknown][]
const url = calls[0]?.[0] as string
expect(url).toContain('ms_1')
expect(url).toContain('/memories/')
expect(url).toContain('mem_del')
})
test('throws 401 when not authenticated', async () => {
const err = Object.assign(new Error('Unauthorized'), {
isAxiosError: true,
response: { status: 401, data: {} },
})
axiosDeleteMock.mockRejectedValueOnce(err)
axiosIsAxiosError.mockImplementation(
(e: unknown) =>
typeof e === 'object' &&
e !== null &&
'isAxiosError' in e &&
(e as { isAxiosError: boolean }).isAxiosError === true,
)
await expect(deleteMemory('ms_1', 'mem_x')).rejects.toThrow(
/login|authenticate/i,
)
})
})
// ── listVersions ──────────────────────────────────────────────────────────
describe('listVersions', () => {
test('calls GET /v1/memory_stores/{id}/memory_versions', async () => {
const versions = [
{
version_id: 'ver_1',
memory_store_id: 'ms_1',
created_at: '2026-01-01T00:00:00Z',
},
]
axiosGetMock.mockResolvedValueOnce({
data: { data: versions },
status: 200,
})
const result = await listVersions('ms_1')
expect(result).toHaveLength(1)
expect(result[0]!.version_id).toBe('ver_1')
const calls = axiosGetMock.mock.calls as unknown as [string, unknown][]
expect(calls[0]?.[0]).toContain('ms_1')
expect(calls[0]?.[0]).toContain('/memory_versions')
})
})
// ── redactVersion ─────────────────────────────────────────────────────────
describe('redactVersion', () => {
test('calls POST /v1/memory_stores/{id}/memory_versions/{vid}/redact (not DELETE)', async () => {
const version = {
version_id: 'ver_red',
memory_store_id: 'ms_1',
redacted_at: '2026-01-01T00:00:00Z',
}
axiosPostMock.mockResolvedValueOnce({ data: version, status: 200 })
const result = await redactVersion('ms_1', 'ver_red')
expect(result.version_id).toBe('ver_red')
// POST must be called for redact
expect(axiosPostMock).toHaveBeenCalledTimes(1)
// DELETE must NOT be called
expect(axiosDeleteMock).not.toHaveBeenCalled()
const calls = axiosPostMock.mock.calls as unknown as [
string,
unknown,
unknown,
][]
const url = calls[0]?.[0] as string
expect(url).toContain('ms_1')
expect(url).toContain('/memory_versions/')
expect(url).toContain('ver_red')
expect(url).toContain('/redact')
})
test('throws 403 with subscription message', async () => {
const err = Object.assign(new Error('Forbidden'), {
isAxiosError: true,
response: { status: 403, data: {} },
})
axiosPostMock.mockRejectedValueOnce(err)
axiosIsAxiosError.mockImplementation(
(e: unknown) =>
typeof e === 'object' &&
e !== null &&
'isAxiosError' in e &&
(e as { isAxiosError: boolean }).isAxiosError === true,
)
await expect(redactVersion('ms_1', 'ver_x')).rejects.toThrow(
/subscription|pro|max|team/i,
)
})
})
// ── 429 rate-limit ────────────────────────────────────────────────────────
describe('429 rate-limit: not retried (non-5xx)', () => {
test('throws immediately on 429 without retry', async () => {
const err = Object.assign(new Error('Too Many Requests'), {
isAxiosError: true,
response: { status: 429, data: {}, headers: { 'retry-after': '60' } },
})
axiosGetMock.mockRejectedValueOnce(err)
axiosIsAxiosError.mockImplementation(
(e: unknown) =>
typeof e === 'object' &&
e !== null &&
'isAxiosError' in e &&
(e as { isAxiosError: boolean }).isAxiosError === true,
)
await expect(listStores()).rejects.toThrow()
// Must NOT have retried — 429 is not a 5xx
expect(axiosGetMock).toHaveBeenCalledTimes(1)
})
})
// ── Invariant: buildHeaders must return x-api-key, not Authorization ─────────
describe('invariant: x-api-key present, no Authorization, no x-organization-uuid', () => {
test('buildHeaders returns x-api-key header (workspace key)', async () => {
axiosGetMock.mockResolvedValueOnce({ data: { data: [] }, status: 200 })
await listStores()
const calls = axiosGetMock.mock.calls as unknown as [
string,
{ headers: Record<string, string> },
][]
const headers = calls[0]?.[1]?.headers ?? {}
expect(headers['x-api-key']).toBe(mockApiKey)
})
test('buildHeaders does NOT include Authorization header', async () => {
axiosGetMock.mockResolvedValueOnce({ data: { data: [] }, status: 200 })
await listStores()
const calls = axiosGetMock.mock.calls as unknown as [
string,
{ headers: Record<string, string> },
][]
const headers = calls[0]?.[1]?.headers ?? {}
expect(headers['Authorization']).toBeUndefined()
})
test('buildHeaders does NOT include x-organization-uuid header', async () => {
axiosGetMock.mockResolvedValueOnce({ data: { data: [] }, status: 200 })
await listStores()
const calls = axiosGetMock.mock.calls as unknown as [
string,
{ headers: Record<string, string> },
][]
const headers = calls[0]?.[1]?.headers ?? {}
expect(headers['x-organization-uuid']).toBeUndefined()
})
test('uses prepareWorkspaceApiRequest to obtain API key', async () => {
prepareWorkspaceApiRequestMock.mockClear()
axiosGetMock.mockResolvedValueOnce({ data: { data: [] }, status: 200 })
await listStores()
expect(prepareWorkspaceApiRequestMock).toHaveBeenCalledTimes(1)
})
test('request goes to api.anthropic.com (host guard passes for correct host)', async () => {
axiosGetMock.mockResolvedValueOnce({ data: { data: [] }, status: 200 })
await listStores()
const calls = axiosGetMock.mock.calls as unknown as [string, unknown][]
expect(calls[0]?.[0]).toContain('api.anthropic.com')
})
})

View File

@@ -0,0 +1,69 @@
/**
* Tests for memory-stores/index.ts — command metadata only.
*/
import { beforeAll, describe, expect, mock, test } from 'bun:test'
mock.module('bun:bundle', () => ({
feature: (_name: string) => true,
}))
let cmd: {
load?: () => Promise<{ call: unknown }>
isEnabled?: () => boolean
name?: string
type?: string
aliases?: string[]
description?: string
bridgeSafe?: boolean
availability?: string[]
}
beforeAll(async () => {
const mod = await import('../index.js')
cmd = mod.default as typeof cmd
})
describe('memoryStoresCommand metadata', () => {
test('name is "memory-stores"', () => {
expect(cmd.name).toBe('memory-stores')
})
test('type is local-jsx', () => {
expect(cmd.type).toBe('local-jsx')
})
test('isEnabled returns true', () => {
expect(cmd.isEnabled?.()).toBe(true)
})
test('aliases include mem and mstore', () => {
expect(cmd.aliases).toContain('mem')
expect(cmd.aliases).toContain('mstore')
})
test('bridgeSafe is false', () => {
expect(cmd.bridgeSafe).toBe(false)
})
test('availability includes claude-ai', () => {
expect(cmd.availability).toContain('claude-ai')
})
test('description mentions memory', () => {
expect(cmd.description?.toLowerCase()).toMatch(/memory/)
})
test('load() exists and is a function', () => {
expect(typeof cmd.load).toBe('function')
})
test('load() resolves to object with call function', async () => {
const loaded = await cmd.load!()
expect(typeof (loaded as { call?: unknown }).call).toBe('function')
})
test('isHidden is boolean (dynamic: false when ANTHROPIC_API_KEY set, true when absent)', () => {
// isHidden = !process.env['ANTHROPIC_API_KEY']
expect(typeof (cmd as { isHidden?: unknown }).isHidden).toBe('boolean')
})
})

View File

@@ -0,0 +1,380 @@
import { beforeAll, beforeEach, describe, expect, mock, test } from 'bun:test'
import { debugMock } from '../../../../tests/mocks/debug.js'
import { logMock } from '../../../../tests/mocks/log.js'
mock.module('src/utils/log.ts', logMock)
mock.module('src/utils/debug.ts', debugMock)
// ── Analytics mock ──────────────────────────────────────────────────────────
const logEventMock = mock(() => {})
mock.module('src/services/analytics/index.js', () => ({
logEvent: logEventMock,
}))
// ── MemoryStoresView mock ───────────────────────────────────────────────────
const memoryStoresViewMock = mock((_props: unknown) => null)
mock.module('src/commands/memory-stores/MemoryStoresView.js', () => ({
MemoryStoresView: memoryStoresViewMock,
}))
// ── memoryStoresApi mock ──────────────────────────────────────────────────
const listStoresMock = mock(async () => [] as unknown)
const getStoreMock = mock(async () => ({}) as unknown)
const createStoreMock = mock(async () => ({}) as unknown)
const archiveStoreMock = mock(async () => ({}) as unknown)
const listMemoriesMock = mock(async () => [] as unknown)
const createMemoryMock = mock(async () => ({}) as unknown)
const getMemoryMock = mock(async () => ({}) as unknown)
const updateMemoryMock = mock(async () => ({}) as unknown)
const deleteMemoryMock = mock(async () => undefined)
const listVersionsMock = mock(async () => [] as unknown)
const redactVersionMock = mock(async () => ({}) as unknown)
mock.module('src/commands/memory-stores/memoryStoresApi.js', () => ({
listStores: listStoresMock,
getStore: getStoreMock,
createStore: createStoreMock,
archiveStore: archiveStoreMock,
listMemories: listMemoriesMock,
createMemory: createMemoryMock,
getMemory: getMemoryMock,
updateMemory: updateMemoryMock,
deleteMemory: deleteMemoryMock,
listVersions: listVersionsMock,
redactVersion: redactVersionMock,
}))
let callMemoryStores: typeof import('../launchMemoryStores.js').callMemoryStores
beforeAll(async () => {
const mod = await import('../launchMemoryStores.js')
callMemoryStores = mod.callMemoryStores
})
function makeOnDone() {
return mock(() => {})
}
beforeEach(() => {
logEventMock.mockClear()
listStoresMock.mockClear()
getStoreMock.mockClear()
createStoreMock.mockClear()
archiveStoreMock.mockClear()
listMemoriesMock.mockClear()
createMemoryMock.mockClear()
getMemoryMock.mockClear()
updateMemoryMock.mockClear()
deleteMemoryMock.mockClear()
listVersionsMock.mockClear()
redactVersionMock.mockClear()
memoryStoresViewMock.mockClear()
})
describe('callMemoryStores: invalid args', () => {
test('invalid subcommand → onDone with usage + null', async () => {
const onDone = makeOnDone()
const result = await callMemoryStores(onDone, {} as never, 'badcmd')
expect(result).toBeNull()
expect(onDone).toHaveBeenCalledTimes(1)
const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? []
expect(msg).toMatch(/Usage/i)
})
})
describe('callMemoryStores: list', () => {
test('list returns empty stores', async () => {
listStoresMock.mockResolvedValueOnce([])
const onDone = makeOnDone()
await callMemoryStores(onDone, {} as never, 'list')
expect(listStoresMock).toHaveBeenCalledTimes(1)
const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? []
expect(msg).toMatch(/no memory stores/i)
})
test('list with stores reports count', async () => {
const stores = [
{ memory_store_id: 'ms_1', name: 'Work', namespace: 'work' },
]
listStoresMock.mockResolvedValueOnce(stores)
const onDone = makeOnDone()
await callMemoryStores(onDone, {} as never, '')
const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? []
expect(msg).toMatch(/1 memory store/)
})
test('list API error → error view', async () => {
listStoresMock.mockRejectedValueOnce(new Error('Network error'))
const onDone = makeOnDone()
await callMemoryStores(onDone, {} as never, 'list')
const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? []
expect(msg).toMatch(/failed to list memory stores/i)
})
})
describe('callMemoryStores: get', () => {
test('get calls getStore with id', async () => {
const store = { memory_store_id: 'ms_get', name: 'Work Store' }
getStoreMock.mockResolvedValueOnce(store)
const onDone = makeOnDone()
await callMemoryStores(onDone, {} as never, 'get ms_get')
expect(getStoreMock).toHaveBeenCalledTimes(1)
const calls = getStoreMock.mock.calls as unknown as [string][]
expect(calls[0]?.[0]).toBe('ms_get')
})
test('get API error → error message', async () => {
getStoreMock.mockRejectedValueOnce(new Error('Not found'))
const onDone = makeOnDone()
await callMemoryStores(onDone, {} as never, 'get ms_missing')
const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? []
expect(msg).toMatch(/failed to get memory store/i)
})
})
describe('callMemoryStores: create', () => {
test('create calls createStore with name', async () => {
const store = { memory_store_id: 'ms_new', name: 'New Store' }
createStoreMock.mockResolvedValueOnce(store)
const onDone = makeOnDone()
await callMemoryStores(onDone, {} as never, 'create New Store')
expect(createStoreMock).toHaveBeenCalledTimes(1)
const calls = createStoreMock.mock.calls as unknown as [string][]
expect(calls[0]?.[0]).toBe('New Store')
const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? []
expect(msg).toMatch(/memory store created/i)
})
test('create API error → error message', async () => {
createStoreMock.mockRejectedValueOnce(new Error('Subscription required'))
const onDone = makeOnDone()
await callMemoryStores(onDone, {} as never, 'create My Store')
const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? []
expect(msg).toMatch(/failed to create memory store/i)
})
})
describe('callMemoryStores: archive', () => {
test('archive calls archiveStore with id', async () => {
const store = {
memory_store_id: 'ms_arc',
name: 'Old Store',
archived_at: '2026-01-01',
}
archiveStoreMock.mockResolvedValueOnce(store)
const onDone = makeOnDone()
await callMemoryStores(onDone, {} as never, 'archive ms_arc')
expect(archiveStoreMock).toHaveBeenCalledTimes(1)
const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? []
expect(msg).toMatch(/archived/i)
})
test('archive API error → error message', async () => {
archiveStoreMock.mockRejectedValueOnce(new Error('Not found'))
const onDone = makeOnDone()
await callMemoryStores(onDone, {} as never, 'archive ms_missing')
const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? []
expect(msg).toMatch(/failed to archive memory store/i)
})
})
describe('callMemoryStores: memories', () => {
test('memories lists memories in store', async () => {
const memories = [
{ memory_id: 'mem_1', memory_store_id: 'ms_1', content: 'Test' },
]
listMemoriesMock.mockResolvedValueOnce(memories)
const onDone = makeOnDone()
await callMemoryStores(onDone, {} as never, 'memories ms_1')
expect(listMemoriesMock).toHaveBeenCalledTimes(1)
const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? []
expect(msg).toMatch(/1 memory/)
})
test('memories API error → error message', async () => {
listMemoriesMock.mockRejectedValueOnce(new Error('Not found'))
const onDone = makeOnDone()
await callMemoryStores(onDone, {} as never, 'memories ms_missing')
const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? []
expect(msg).toMatch(/failed to list memories/i)
})
})
describe('callMemoryStores: create-memory', () => {
test('create-memory calls createMemory with storeId and content', async () => {
const memory = {
memory_id: 'mem_new',
memory_store_id: 'ms_1',
content: 'hello world',
}
createMemoryMock.mockResolvedValueOnce(memory)
const onDone = makeOnDone()
await callMemoryStores(
onDone,
{} as never,
'create-memory ms_1 hello world',
)
expect(createMemoryMock).toHaveBeenCalledTimes(1)
const calls = createMemoryMock.mock.calls as unknown as [string, string][]
expect(calls[0]?.[0]).toBe('ms_1')
expect(calls[0]?.[1]).toBe('hello world')
const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? []
expect(msg).toMatch(/memory created/i)
})
test('create-memory API error → error message', async () => {
createMemoryMock.mockRejectedValueOnce(new Error('Forbidden'))
const onDone = makeOnDone()
await callMemoryStores(
onDone,
{} as never,
'create-memory ms_1 test content',
)
const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? []
expect(msg).toMatch(/failed to create memory/i)
})
})
describe('callMemoryStores: get-memory', () => {
test('get-memory calls getMemory', async () => {
const memory = {
memory_id: 'mem_get',
memory_store_id: 'ms_1',
content: 'Test',
}
getMemoryMock.mockResolvedValueOnce(memory)
const onDone = makeOnDone()
await callMemoryStores(onDone, {} as never, 'get-memory ms_1 mem_get')
expect(getMemoryMock).toHaveBeenCalledTimes(1)
const calls = getMemoryMock.mock.calls as unknown as [string, string][]
expect(calls[0]?.[0]).toBe('ms_1')
expect(calls[0]?.[1]).toBe('mem_get')
})
test('get-memory API error → error message', async () => {
getMemoryMock.mockRejectedValueOnce(new Error('Not found'))
const onDone = makeOnDone()
await callMemoryStores(onDone, {} as never, 'get-memory ms_1 mem_missing')
const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? []
expect(msg).toMatch(/failed to get memory/i)
})
})
describe('callMemoryStores: update-memory', () => {
test('update-memory calls updateMemory with storeId, memoryId, and content', async () => {
const memory = {
memory_id: 'mem_upd',
memory_store_id: 'ms_1',
content: 'new content',
}
updateMemoryMock.mockResolvedValueOnce(memory)
const onDone = makeOnDone()
await callMemoryStores(
onDone,
{} as never,
'update-memory ms_1 mem_upd new content',
)
expect(updateMemoryMock).toHaveBeenCalledTimes(1)
const calls = updateMemoryMock.mock.calls as unknown as [
string,
string,
string,
][]
expect(calls[0]?.[0]).toBe('ms_1')
expect(calls[0]?.[1]).toBe('mem_upd')
expect(calls[0]?.[2]).toBe('new content')
const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? []
expect(msg).toMatch(/updated/i)
})
test('update-memory API error → error message', async () => {
updateMemoryMock.mockRejectedValueOnce(new Error('Not found'))
const onDone = makeOnDone()
await callMemoryStores(
onDone,
{} as never,
'update-memory ms_1 mem_missing new content',
)
const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? []
expect(msg).toMatch(/failed to update memory/i)
})
})
describe('callMemoryStores: delete-memory', () => {
test('delete-memory calls deleteMemory', async () => {
deleteMemoryMock.mockResolvedValueOnce(undefined)
const onDone = makeOnDone()
await callMemoryStores(onDone, {} as never, 'delete-memory ms_1 mem_del')
expect(deleteMemoryMock).toHaveBeenCalledTimes(1)
const calls = deleteMemoryMock.mock.calls as unknown as [string, string][]
expect(calls[0]?.[0]).toBe('ms_1')
expect(calls[0]?.[1]).toBe('mem_del')
const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? []
expect(msg).toMatch(/deleted/i)
})
test('delete-memory API error → error message', async () => {
deleteMemoryMock.mockRejectedValueOnce(new Error('Not found'))
const onDone = makeOnDone()
await callMemoryStores(
onDone,
{} as never,
'delete-memory ms_1 mem_missing',
)
const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? []
expect(msg).toMatch(/failed to delete memory/i)
})
})
describe('callMemoryStores: versions', () => {
test('versions lists memory versions', async () => {
const versions = [
{
version_id: 'ver_1',
memory_store_id: 'ms_1',
created_at: '2026-01-01',
},
]
listVersionsMock.mockResolvedValueOnce(versions)
const onDone = makeOnDone()
await callMemoryStores(onDone, {} as never, 'versions ms_1')
expect(listVersionsMock).toHaveBeenCalledTimes(1)
const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? []
expect(msg).toMatch(/1 version/)
})
test('versions API error → error message', async () => {
listVersionsMock.mockRejectedValueOnce(new Error('Not found'))
const onDone = makeOnDone()
await callMemoryStores(onDone, {} as never, 'versions ms_missing')
const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? []
expect(msg).toMatch(/failed to list versions/i)
})
})
describe('callMemoryStores: redact', () => {
test('redact calls redactVersion with storeId and versionId', async () => {
const version = {
version_id: 'ver_red',
memory_store_id: 'ms_1',
redacted_at: '2026-01-01',
}
redactVersionMock.mockResolvedValueOnce(version)
const onDone = makeOnDone()
await callMemoryStores(onDone, {} as never, 'redact ms_1 ver_red')
expect(redactVersionMock).toHaveBeenCalledTimes(1)
const calls = redactVersionMock.mock.calls as unknown as [string, string][]
expect(calls[0]?.[0]).toBe('ms_1')
expect(calls[0]?.[1]).toBe('ver_red')
const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? []
expect(msg).toMatch(/redacted/i)
})
test('redact API error → error message', async () => {
redactVersionMock.mockRejectedValueOnce(new Error('Forbidden'))
const onDone = makeOnDone()
await callMemoryStores(onDone, {} as never, 'redact ms_1 ver_missing')
const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? []
expect(msg).toMatch(/failed to redact version/i)
})
})

View File

@@ -0,0 +1,190 @@
/**
* Unit tests for parseMemoryStoresArgs
*/
import { describe, expect, test } from 'bun:test'
import { parseMemoryStoresArgs } from '../parseArgs.js'
describe('parseMemoryStoresArgs: list', () => {
test('empty string → list', () => {
expect(parseMemoryStoresArgs('')).toEqual({ action: 'list' })
})
test('"list" → list', () => {
expect(parseMemoryStoresArgs('list')).toEqual({ action: 'list' })
})
test('whitespace-only → list', () => {
expect(parseMemoryStoresArgs(' ')).toEqual({ action: 'list' })
})
})
describe('parseMemoryStoresArgs: get', () => {
test('get ms_123 → { action: get, id: ms_123 }', () => {
expect(parseMemoryStoresArgs('get ms_123')).toEqual({
action: 'get',
id: 'ms_123',
})
})
test('get without id → invalid', () => {
const result = parseMemoryStoresArgs('get')
expect(result.action).toBe('invalid')
if (result.action === 'invalid') {
expect(result.reason).toMatch(/store id/i)
}
})
})
describe('parseMemoryStoresArgs: create', () => {
test('create "My Store" → { action: create, name }', () => {
const result = parseMemoryStoresArgs('create My Work Store')
expect(result).toEqual({ action: 'create', name: 'My Work Store' })
})
test('create without name → invalid', () => {
const result = parseMemoryStoresArgs('create')
expect(result.action).toBe('invalid')
})
})
describe('parseMemoryStoresArgs: archive', () => {
test('archive ms_123 → { action: archive, id: ms_123 }', () => {
expect(parseMemoryStoresArgs('archive ms_123')).toEqual({
action: 'archive',
id: 'ms_123',
})
})
test('archive without id → invalid', () => {
const result = parseMemoryStoresArgs('archive')
expect(result.action).toBe('invalid')
})
})
describe('parseMemoryStoresArgs: memories', () => {
test('memories ms_123 → { action: memories, storeId: ms_123 }', () => {
expect(parseMemoryStoresArgs('memories ms_123')).toEqual({
action: 'memories',
storeId: 'ms_123',
})
})
test('memories without storeId → invalid', () => {
const result = parseMemoryStoresArgs('memories')
expect(result.action).toBe('invalid')
})
})
describe('parseMemoryStoresArgs: create-memory', () => {
test('create-memory ms_123 hello world → { action: create-memory, storeId, content }', () => {
const result = parseMemoryStoresArgs('create-memory ms_123 hello world')
expect(result).toEqual({
action: 'create-memory',
storeId: 'ms_123',
content: 'hello world',
})
})
test('create-memory without content → invalid', () => {
const result = parseMemoryStoresArgs('create-memory ms_123')
expect(result.action).toBe('invalid')
})
test('create-memory without args → invalid', () => {
const result = parseMemoryStoresArgs('create-memory')
expect(result.action).toBe('invalid')
})
})
describe('parseMemoryStoresArgs: get-memory', () => {
test('get-memory ms_123 mem_456 → { action: get-memory, storeId, memoryId }', () => {
const result = parseMemoryStoresArgs('get-memory ms_123 mem_456')
expect(result).toEqual({
action: 'get-memory',
storeId: 'ms_123',
memoryId: 'mem_456',
})
})
test('get-memory with only store id → invalid', () => {
const result = parseMemoryStoresArgs('get-memory ms_123')
expect(result.action).toBe('invalid')
})
})
describe('parseMemoryStoresArgs: update-memory', () => {
test('update-memory ms_123 mem_456 new content → { action: update-memory, storeId, memoryId, content }', () => {
const result = parseMemoryStoresArgs(
'update-memory ms_123 mem_456 new content',
)
expect(result).toEqual({
action: 'update-memory',
storeId: 'ms_123',
memoryId: 'mem_456',
content: 'new content',
})
})
test('update-memory without content → invalid', () => {
const result = parseMemoryStoresArgs('update-memory ms_123 mem_456')
expect(result.action).toBe('invalid')
})
})
describe('parseMemoryStoresArgs: delete-memory', () => {
test('delete-memory ms_123 mem_456 → { action: delete-memory, storeId, memoryId }', () => {
const result = parseMemoryStoresArgs('delete-memory ms_123 mem_456')
expect(result).toEqual({
action: 'delete-memory',
storeId: 'ms_123',
memoryId: 'mem_456',
})
})
test('delete-memory with only store id → invalid', () => {
const result = parseMemoryStoresArgs('delete-memory ms_123')
expect(result.action).toBe('invalid')
})
})
describe('parseMemoryStoresArgs: versions', () => {
test('versions ms_123 → { action: versions, storeId: ms_123 }', () => {
expect(parseMemoryStoresArgs('versions ms_123')).toEqual({
action: 'versions',
storeId: 'ms_123',
})
})
test('versions without storeId → invalid', () => {
const result = parseMemoryStoresArgs('versions')
expect(result.action).toBe('invalid')
})
})
describe('parseMemoryStoresArgs: redact', () => {
test('redact ms_123 ver_456 → { action: redact, storeId, versionId }', () => {
const result = parseMemoryStoresArgs('redact ms_123 ver_456')
expect(result).toEqual({
action: 'redact',
storeId: 'ms_123',
versionId: 'ver_456',
})
})
test('redact with only store id → invalid', () => {
const result = parseMemoryStoresArgs('redact ms_123')
expect(result.action).toBe('invalid')
})
})
describe('parseMemoryStoresArgs: unknown sub-command', () => {
test('unknown subcommand → invalid with reason', () => {
const result = parseMemoryStoresArgs('foobar')
expect(result.action).toBe('invalid')
if (result.action === 'invalid') {
expect(result.reason).toMatch(/unknown sub-command/i)
expect(result.reason).toContain('foobar')
}
})
})

View File

@@ -0,0 +1,30 @@
import { getGlobalConfig } from '../../utils/config.js'
import type { Command } from '../../types/command.js'
const memoryStoresCommand: Command = {
type: 'local-jsx',
name: 'memory-stores',
aliases: ['mem', 'mstore'],
description:
'Manage remote memory stores (cross-device memory persistence). Requires Claude Pro/Max/Team subscription.',
// REPL markdown renderer strips `<...>` as HTML tags — use uppercase.
argumentHint:
'list | get ID | create NAME | archive ID | memories STORE_ID | create-memory STORE_ID CONTENT | get-memory STORE_ID MEMORY_ID | update-memory STORE_ID MEMORY_ID CONTENT | delete-memory STORE_ID MEMORY_ID | versions STORE_ID | redact STORE_ID VERSION_ID',
// Visible when a workspace API key is available from env or saved settings.
// Use a getter so getGlobalConfig() runs lazily (after enableConfigs())
// instead of at module-load time, which races bootstrap and throws.
get isHidden(): boolean {
return (
!process.env['ANTHROPIC_API_KEY'] && !getGlobalConfig().workspaceApiKey
)
},
isEnabled: () => true,
bridgeSafe: false,
availability: ['claude-ai'],
load: async () => {
const m = await import('./launchMemoryStores.js')
return { call: m.callMemoryStores }
},
}
export default memoryStoresCommand

View File

@@ -0,0 +1,279 @@
import React from 'react';
import {
type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
logEvent,
} from '../../services/analytics/index.js';
import type { LocalJSXCommandCall, LocalJSXCommandOnDone } from '../../types/command.js';
import {
archiveStore,
createMemory,
createStore,
deleteMemory,
getMemory,
getStore,
listMemories,
listStores,
listVersions,
redactVersion,
updateMemory,
} from './memoryStoresApi.js';
import { MemoryStoresView } from './MemoryStoresView.js';
import { parseMemoryStoresArgs } from './parseArgs.js';
import { launchCommand } from '../_shared/launchCommand.js';
type MemoryStoresViewProps = React.ComponentProps<typeof MemoryStoresView>;
async function dispatchMemoryStores(
parsed: ReturnType<typeof parseMemoryStoresArgs>,
onDone: LocalJSXCommandOnDone,
): Promise<MemoryStoresViewProps | null> {
if (parsed.action === 'list') {
logEvent('tengu_memory_stores_list', {});
try {
const stores = await listStores();
onDone(stores.length === 0 ? 'No memory stores found.' : `${stores.length} memory store(s).`, {
display: 'system',
});
return { mode: 'list', stores };
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
logEvent('tengu_memory_stores_failed', {
reason: msg as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
});
onDone(`Failed to list memory stores: ${msg}`, { display: 'system' });
return { mode: 'error', message: msg };
}
}
if (parsed.action === 'get') {
const { id } = parsed;
logEvent('tengu_memory_stores_get', {
id: id as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
});
try {
const store = await getStore(id);
onDone(`Memory store ${id} fetched.`, { display: 'system' });
return { mode: 'detail', store };
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
logEvent('tengu_memory_stores_failed', {
reason: msg as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
});
onDone(`Failed to get memory store ${id}: ${msg}`, { display: 'system' });
return { mode: 'error', message: msg };
}
}
if (parsed.action === 'create') {
const { name } = parsed;
logEvent('tengu_memory_stores_create', {
name: name as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
});
try {
const store = await createStore(name);
onDone(`Memory store created: ${store.memory_store_id}`, { display: 'system' });
return { mode: 'created', store };
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
logEvent('tengu_memory_stores_failed', {
reason: msg as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
});
onDone(`Failed to create memory store: ${msg}`, { display: 'system' });
return { mode: 'error', message: msg };
}
}
if (parsed.action === 'archive') {
const { id } = parsed;
logEvent('tengu_memory_stores_archive', {
id: id as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
});
try {
const store = await archiveStore(id);
onDone(`Memory store ${id} archived.`, { display: 'system' });
return { mode: 'archived', store };
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
logEvent('tengu_memory_stores_failed', {
reason: msg as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
});
onDone(`Failed to archive memory store ${id}: ${msg}`, { display: 'system' });
return { mode: 'error', message: msg };
}
}
if (parsed.action === 'memories') {
const { storeId } = parsed;
logEvent('tengu_memory_stores_list_memories', {
storeId: storeId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
});
try {
const memories = await listMemories(storeId);
onDone(
memories.length === 0
? `No memories in store ${storeId}.`
: `${memories.length} memory(ies) in store ${storeId}.`,
{ display: 'system' },
);
return { mode: 'memory-list', storeId, memories };
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
logEvent('tengu_memory_stores_failed', {
reason: msg as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
});
onDone(`Failed to list memories in store ${storeId}: ${msg}`, { display: 'system' });
return { mode: 'error', message: msg };
}
}
if (parsed.action === 'create-memory') {
const { storeId, content } = parsed;
logEvent('tengu_memory_stores_create_memory', {
storeId: storeId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
});
try {
const memory = await createMemory(storeId, content);
onDone(`Memory created: ${memory.memory_id}`, { display: 'system' });
return { mode: 'memory-created', memory };
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
logEvent('tengu_memory_stores_failed', {
reason: msg as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
});
onDone(`Failed to create memory in store ${storeId}: ${msg}`, { display: 'system' });
return { mode: 'error', message: msg };
}
}
if (parsed.action === 'get-memory') {
const { storeId, memoryId } = parsed;
logEvent('tengu_memory_stores_get_memory', {
storeId: storeId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
});
try {
const memory = await getMemory(storeId, memoryId);
onDone(`Memory ${memoryId} fetched.`, { display: 'system' });
return { mode: 'memory-detail', memory };
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
logEvent('tengu_memory_stores_failed', {
reason: msg as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
});
onDone(`Failed to get memory ${memoryId}: ${msg}`, { display: 'system' });
return { mode: 'error', message: msg };
}
}
if (parsed.action === 'update-memory') {
const { storeId, memoryId, content } = parsed;
logEvent('tengu_memory_stores_update_memory', {
storeId: storeId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
});
try {
const memory = await updateMemory(storeId, memoryId, content);
onDone(`Memory ${memoryId} updated.`, { display: 'system' });
return { mode: 'memory-updated', memory };
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
logEvent('tengu_memory_stores_failed', {
reason: msg as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
});
onDone(`Failed to update memory ${memoryId}: ${msg}`, { display: 'system' });
return { mode: 'error', message: msg };
}
}
if (parsed.action === 'delete-memory') {
const { storeId, memoryId } = parsed;
logEvent('tengu_memory_stores_delete_memory', {
storeId: storeId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
});
try {
await deleteMemory(storeId, memoryId);
onDone(`Memory ${memoryId} deleted.`, { display: 'system' });
return { mode: 'memory-deleted', storeId, memoryId };
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
logEvent('tengu_memory_stores_failed', {
reason: msg as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
});
onDone(`Failed to delete memory ${memoryId}: ${msg}`, { display: 'system' });
return { mode: 'error', message: msg };
}
}
if (parsed.action === 'versions') {
const { storeId } = parsed;
logEvent('tengu_memory_stores_versions', {
storeId: storeId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
});
try {
const versions = await listVersions(storeId);
onDone(
versions.length === 0
? `No memory versions found for store ${storeId}.`
: `${versions.length} version(s) in store ${storeId}.`,
{ display: 'system' },
);
return { mode: 'versions', storeId, versions };
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
logEvent('tengu_memory_stores_failed', {
reason: msg as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
});
onDone(`Failed to list versions for store ${storeId}: ${msg}`, { display: 'system' });
return { mode: 'error', message: msg };
}
}
// parsed.action === 'redact' (all other actions handled above)
const redactParsed = parsed as { action: 'redact'; storeId: string; versionId: string };
const { storeId, versionId } = redactParsed;
logEvent('tengu_memory_stores_redact', {
storeId: storeId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
});
try {
const version = await redactVersion(storeId, versionId);
onDone(`Version ${versionId} redacted.`, { display: 'system' });
return { mode: 'redacted', version };
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
logEvent('tengu_memory_stores_failed', {
reason: msg as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
});
onDone(`Failed to redact version ${versionId}: ${msg}`, { display: 'system' });
return { mode: 'error', message: msg };
}
}
const USAGE_MS =
'Usage: /memory-stores list | get ID | create NAME | archive ID | memories STORE_ID | create-memory STORE_ID CONTENT | get-memory STORE_ID MEMORY_ID | update-memory STORE_ID MEMORY_ID CONTENT | delete-memory STORE_ID MEMORY_ID | versions STORE_ID | redact STORE_ID VERSION_ID';
export const callMemoryStores: LocalJSXCommandCall = launchCommand<
ReturnType<typeof parseMemoryStoresArgs>,
MemoryStoresViewProps
>({
commandName: 'memory-stores',
parseArgs: (raw: string) => {
logEvent('tengu_memory_stores_started', {
args: raw as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
});
const result = parseMemoryStoresArgs(raw);
if (result.action === 'invalid') {
logEvent('tengu_memory_stores_failed', {
reason: result.reason as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
});
return {
action: 'invalid' as const,
reason: `${USAGE_MS}\n${result.reason}`,
};
}
return result;
},
dispatch: dispatchMemoryStores,
View: MemoryStoresView,
// The invalid-args path returns null (matching original behaviour) since the
// error reason is already surfaced via onDone. The dispatch-error path
// renders an error view with the thrown message.
errorView: (_msg: string) => null,
});

View File

@@ -0,0 +1,377 @@
/**
* Thin HTTP client for the /v1/memory_stores endpoint.
*
* Key spec facts (from binary reverse-engineering of v2.1.123):
* - list stores: GET /v1/memory_stores
* - create store: POST /v1/memory_stores
* - get store: GET /v1/memory_stores/{id}
* - archive store: POST /v1/memory_stores/{id}/archive ← POST not DELETE
* - list memories: GET /v1/memory_stores/{id}/memories
* - create memory: POST /v1/memory_stores/{id}/memories
* - get memory: GET /v1/memory_stores/{id}/memories/{mid}
* - update memory: PATCH /v1/memory_stores/{id}/memories/{mid} ← PATCH not POST
* - delete memory: DELETE /v1/memory_stores/{id}/memories/{mid}
* - list versions: GET /v1/memory_stores/{id}/memory_versions
* - redact version: POST /v1/memory_stores/{id}/memory_versions/{vid}/redact
*
* CRITICAL INVARIANT: updateMemory uses PATCH (not POST).
* Binary evidence: "PATCH /v1/memory_stores/{memory_store_id}/memories"
*
* Reuses the same base-URL + auth-header pattern as triggersApi.ts / agentsApi.ts.
*/
import axios from 'axios'
import { getOauthConfig } from '../../constants/oauth.js'
import { assertWorkspaceHost } from '../../services/auth/hostGuard.js'
import { prepareWorkspaceApiRequest } from '../../utils/teleport/api.js'
export type MemoryStore = {
memory_store_id: string
name: string
namespace?: string
archived_at?: string | null
created_at?: string
}
export type Memory = {
memory_id: string
memory_store_id: string
content: string
created_at?: string
updated_at?: string
}
export type MemoryVersion = {
version_id: string
memory_store_id: string
created_at?: string
redacted_at?: string | null
}
export type CreateStoreBody = {
name: string
namespace?: string
}
export type CreateMemoryBody = {
content: string
}
export type UpdateMemoryBody = {
content: string
}
type ListStoresResponse = {
data: MemoryStore[]
}
type ListMemoriesResponse = {
data: Memory[]
}
type ListVersionsResponse = {
data: MemoryVersion[]
}
// Server requires this exact beta header — confirmed from runtime error
// "this API is in beta: add `managed-agents-2026-04-01`". Memory stores share
// the managed-agents beta umbrella with /v1/agents and /v1/code/triggers.
const MEMORY_STORES_BETA_HEADER = 'managed-agents-2026-04-01'
const MAX_RETRIES = 3
function sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms))
}
class MemoryStoresApiError extends Error {
constructor(
message: string,
public readonly statusCode: number,
) {
super(message)
this.name = 'MemoryStoresApiError'
}
}
async function buildHeaders(): Promise<Record<string, string>> {
// /v1/memory_stores requires a workspace-scoped API key (sk-ant-api03-*).
// Server explicitly returns: "memory stores require a workspace-scoped API key or session"
// (probed 2026-05-03). Subscription OAuth bearer tokens always 401 here.
// Guard the host before sending the key to prevent credential leakage.
let apiKey: string
try {
const prepared = await prepareWorkspaceApiRequest()
apiKey = prepared.apiKey
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err)
throw new MemoryStoresApiError(msg, 501)
}
assertWorkspaceHost(memoryStoresBaseUrl())
return {
'x-api-key': apiKey,
'anthropic-version': '2023-06-01',
'anthropic-beta': MEMORY_STORES_BETA_HEADER,
'content-type': 'application/json',
}
}
function memoryStoresBaseUrl(): string {
return `${getOauthConfig().BASE_API_URL}/v1/memory_stores`
}
function classifyError(err: unknown): MemoryStoresApiError {
if (axios.isAxiosError(err)) {
const status = err.response?.status ?? 0
if (status === 401) {
return new MemoryStoresApiError(
'Authentication failed. Please run /login to re-authenticate.',
401,
)
}
if (status === 403) {
return new MemoryStoresApiError(
'Subscription required. Memory stores require a Claude Pro/Max/Team subscription.',
403,
)
}
if (status === 404) {
return new MemoryStoresApiError('Memory store or memory not found.', 404)
}
if (status === 429) {
const retryAfter =
(err.response?.headers as Record<string, string> | undefined)?.[
'retry-after'
] ?? ''
const detail = retryAfter ? ` Retry after ${retryAfter}s.` : ''
return new MemoryStoresApiError(`Rate limit exceeded.${detail}`, 429)
}
const msg =
(err.response?.data as { error?: { message?: string } } | undefined)
?.error?.message ?? err.message
return new MemoryStoresApiError(msg, status)
}
if (err instanceof MemoryStoresApiError) return err
return new MemoryStoresApiError(
err instanceof Error ? err.message : String(err),
0,
)
}
/**
* Parses the Retry-After header value into milliseconds.
* Accepts both integer-seconds (e.g. "30") and HTTP-date strings.
* Returns null when the header is absent or unparseable.
*/
function parseRetryAfterMs(header: string | undefined): number | null {
if (!header) return null
const seconds = Number(header)
if (!Number.isNaN(seconds) && seconds >= 0) return seconds * 1000
const date = Date.parse(header)
if (!Number.isNaN(date)) return Math.max(0, date - Date.now())
return null
}
async function withRetry<T>(fn: () => Promise<T>): Promise<T> {
let lastErr: MemoryStoresApiError | undefined
for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
try {
return await fn()
} catch (err: unknown) {
const classified = classifyError(err)
// Only retry 5xx errors
if (classified.statusCode >= 500) {
lastErr = classified
if (attempt < MAX_RETRIES - 1) {
const retryAfterHeader = axios.isAxiosError(err)
? (err.response?.headers as Record<string, string> | undefined)?.[
'retry-after'
]
: undefined
const waitMs =
parseRetryAfterMs(retryAfterHeader) ?? 500 * 2 ** attempt
await sleep(waitMs)
}
continue
}
throw classified
}
}
throw lastErr ?? new MemoryStoresApiError('Request failed after retries', 0)
}
// ── Store CRUD ─────────────────────────────────────────────────────────────
export async function listStores(): Promise<MemoryStore[]> {
return withRetry(async () => {
const headers = await buildHeaders()
const response = await axios.get<ListStoresResponse>(
memoryStoresBaseUrl(),
{
headers,
},
)
return response.data.data ?? []
})
}
export async function createStore(
name: string,
namespace?: string,
): Promise<MemoryStore> {
return withRetry(async () => {
const headers = await buildHeaders()
const body: CreateStoreBody = { name }
if (namespace) body.namespace = namespace
const response = await axios.post<MemoryStore>(
memoryStoresBaseUrl(),
body,
{
headers,
},
)
return response.data
})
}
export async function getStore(id: string): Promise<MemoryStore> {
return withRetry(async () => {
const headers = await buildHeaders()
const response = await axios.get<MemoryStore>(
`${memoryStoresBaseUrl()}/${id}`,
{ headers },
)
return response.data
})
}
/**
* Archive a memory store (soft delete).
*
* IMPORTANT: The upstream API uses POST (not DELETE) for archiving.
* Binary literal evidence: "POST /v1/memory_stores/{memory_store_id}/archive"
*/
export async function archiveStore(id: string): Promise<MemoryStore> {
return withRetry(async () => {
const headers = await buildHeaders()
const response = await axios.post<MemoryStore>(
`${memoryStoresBaseUrl()}/${id}/archive`,
{},
{ headers },
)
return response.data
})
}
// ── Memory CRUD ────────────────────────────────────────────────────────────
export async function listMemories(storeId: string): Promise<Memory[]> {
return withRetry(async () => {
const headers = await buildHeaders()
const response = await axios.get<ListMemoriesResponse>(
`${memoryStoresBaseUrl()}/${storeId}/memories`,
{ headers },
)
return response.data.data ?? []
})
}
export async function createMemory(
storeId: string,
content: string,
): Promise<Memory> {
return withRetry(async () => {
const headers = await buildHeaders()
const body: CreateMemoryBody = { content }
const response = await axios.post<Memory>(
`${memoryStoresBaseUrl()}/${storeId}/memories`,
body,
{ headers },
)
return response.data
})
}
export async function getMemory(
storeId: string,
memoryId: string,
): Promise<Memory> {
return withRetry(async () => {
const headers = await buildHeaders()
const response = await axios.get<Memory>(
`${memoryStoresBaseUrl()}/${storeId}/memories/${memoryId}`,
{ headers },
)
return response.data
})
}
/**
* Update a memory's content.
*
* CRITICAL INVARIANT: This endpoint uses PATCH (not POST/PUT).
* Binary literal evidence: "PATCH /v1/memory_stores/{memory_store_id}/memories"
* Test name: "updateMemory calls PATCH /v1/memory_stores/{id}/memories/{mid} (not POST)"
*/
export async function updateMemory(
storeId: string,
memoryId: string,
content: string,
): Promise<Memory> {
return withRetry(async () => {
const headers = await buildHeaders()
const body: UpdateMemoryBody = { content }
const response = await axios.patch<Memory>(
`${memoryStoresBaseUrl()}/${storeId}/memories/${memoryId}`,
body,
{ headers },
)
return response.data
})
}
export async function deleteMemory(
storeId: string,
memoryId: string,
): Promise<void> {
return withRetry(async () => {
const headers = await buildHeaders()
await axios.delete(
`${memoryStoresBaseUrl()}/${storeId}/memories/${memoryId}`,
{ headers },
)
})
}
// ── Versions ───────────────────────────────────────────────────────────────
export async function listVersions(storeId: string): Promise<MemoryVersion[]> {
return withRetry(async () => {
const headers = await buildHeaders()
const response = await axios.get<ListVersionsResponse>(
`${memoryStoresBaseUrl()}/${storeId}/memory_versions`,
{ headers },
)
return response.data.data ?? []
})
}
/**
* Redact a memory version (PII removal).
*
* IMPORTANT: Uses POST (not DELETE) for redaction.
* Binary literal evidence: "POST /v1/memory_stores/{id}/memory_versions/{vid}/redact"
*/
export async function redactVersion(
storeId: string,
versionId: string,
): Promise<MemoryVersion> {
return withRetry(async () => {
const headers = await buildHeaders()
const response = await axios.post<MemoryVersion>(
`${memoryStoresBaseUrl()}/${storeId}/memory_versions/${versionId}/redact`,
{},
{ headers },
)
return response.data
})
}

View File

@@ -0,0 +1,207 @@
/**
* Parse the args string for the /memory-stores command.
*
* Supported sub-commands:
* list → { action: 'list' }
* get <id> → { action: 'get', id }
* create <name> → { action: 'create', name }
* archive <id> → { action: 'archive', id }
* memories <store_id> → { action: 'memories', storeId }
* create-memory <store_id> <content> → { action: 'create-memory', storeId, content }
* get-memory <store_id> <memory_id> → { action: 'get-memory', storeId, memoryId }
* update-memory <store_id> <memory_id> <content> → { action: 'update-memory', storeId, memoryId, content }
* delete-memory <store_id> <memory_id> → { action: 'delete-memory', storeId, memoryId }
* versions <store_id> → { action: 'versions', storeId }
* redact <store_id> <version_id> → { action: 'redact', storeId, versionId }
* (empty) → { action: 'list' }
* anything else → { action: 'invalid', reason }
*/
export type MemoryStoresArgs =
| { action: 'list' }
| { action: 'get'; id: string }
| { action: 'create'; name: string }
| { action: 'archive'; id: string }
| { action: 'memories'; storeId: string }
| { action: 'create-memory'; storeId: string; content: string }
| { action: 'get-memory'; storeId: string; memoryId: string }
| {
action: 'update-memory'
storeId: string
memoryId: string
content: string
}
| { action: 'delete-memory'; storeId: string; memoryId: string }
| { action: 'versions'; storeId: string }
| { action: 'redact'; storeId: string; versionId: string }
| { action: 'invalid'; reason: string }
const USAGE =
'Usage: /memory-stores list | get ID | create NAME | archive ID | memories STORE_ID | create-memory STORE_ID CONTENT | get-memory STORE_ID MEMORY_ID | update-memory STORE_ID MEMORY_ID CONTENT | delete-memory STORE_ID MEMORY_ID | versions STORE_ID | redact STORE_ID VERSION_ID'
export function parseMemoryStoresArgs(args: string): MemoryStoresArgs {
const trimmed = args.trim()
if (trimmed === '' || trimmed === 'list') {
return { action: 'list' }
}
const spaceIdx = trimmed.indexOf(' ')
const subCmd = spaceIdx === -1 ? trimmed : trimmed.slice(0, spaceIdx)
const rest = spaceIdx === -1 ? '' : trimmed.slice(spaceIdx + 1).trim()
// ── get ───────────────────────────────────────────────────────────────────
if (subCmd === 'get') {
if (!rest) {
return { action: 'invalid', reason: 'get requires a store id' }
}
const id = rest.split(/\s+/)[0]
/* istanbul ignore next */
if (!id) {
return { action: 'invalid', reason: 'get requires a store id' }
}
return { action: 'get', id }
}
// ── create ────────────────────────────────────────────────────────────────
if (subCmd === 'create') {
if (!rest) {
return {
action: 'invalid',
reason: 'create requires a store name, e.g. create "My Work Store"',
}
}
return { action: 'create', name: rest }
}
// ── archive ───────────────────────────────────────────────────────────────
if (subCmd === 'archive') {
if (!rest) {
return { action: 'invalid', reason: 'archive requires a store id' }
}
const id = rest.split(/\s+/)[0]
/* istanbul ignore next */
if (!id) {
return { action: 'invalid', reason: 'archive requires a store id' }
}
return { action: 'archive', id }
}
// ── memories ──────────────────────────────────────────────────────────────
if (subCmd === 'memories') {
if (!rest) {
return { action: 'invalid', reason: 'memories requires a store id' }
}
const storeId = rest.split(/\s+/)[0]
/* istanbul ignore next */
if (!storeId) {
return { action: 'invalid', reason: 'memories requires a store id' }
}
return { action: 'memories', storeId }
}
// ── create-memory ─────────────────────────────────────────────────────────
if (subCmd === 'create-memory') {
const parts = rest.split(/\s+/)
if (parts.length < 2 || !parts[0]) {
return {
action: 'invalid',
reason:
'create-memory requires a store id and content, e.g. create-memory ms_123 "The content"',
}
}
const storeId = parts[0]
const content = parts.slice(1).join(' ')
if (!content.trim()) {
return {
action: 'invalid',
reason: 'create-memory requires non-empty content',
}
}
return { action: 'create-memory', storeId, content: content.trim() }
}
// ── get-memory ────────────────────────────────────────────────────────────
if (subCmd === 'get-memory') {
const parts = rest.split(/\s+/)
if (parts.length < 2 || !parts[0] || !parts[1]) {
return {
action: 'invalid',
reason:
'get-memory requires a store id and memory id, e.g. get-memory ms_123 mem_456',
}
}
return { action: 'get-memory', storeId: parts[0], memoryId: parts[1] }
}
// ── update-memory ─────────────────────────────────────────────────────────
if (subCmd === 'update-memory') {
const parts = rest.split(/\s+/)
if (parts.length < 3 || !parts[0] || !parts[1]) {
return {
action: 'invalid',
reason:
'update-memory requires store id, memory id, and content, e.g. update-memory ms_123 mem_456 "New content"',
}
}
const storeId = parts[0]
const memoryId = parts[1]
const content = parts.slice(2).join(' ')
if (!content.trim()) {
return {
action: 'invalid',
reason: 'update-memory requires non-empty content',
}
}
return {
action: 'update-memory',
storeId,
memoryId,
content: content.trim(),
}
}
// ── delete-memory ─────────────────────────────────────────────────────────
if (subCmd === 'delete-memory') {
const parts = rest.split(/\s+/)
if (parts.length < 2 || !parts[0] || !parts[1]) {
return {
action: 'invalid',
reason:
'delete-memory requires a store id and memory id, e.g. delete-memory ms_123 mem_456',
}
}
return { action: 'delete-memory', storeId: parts[0], memoryId: parts[1] }
}
// ── versions ──────────────────────────────────────────────────────────────
if (subCmd === 'versions') {
if (!rest) {
return { action: 'invalid', reason: 'versions requires a store id' }
}
const storeId = rest.split(/\s+/)[0]
/* istanbul ignore next */
if (!storeId) {
return { action: 'invalid', reason: 'versions requires a store id' }
}
return { action: 'versions', storeId }
}
// ── redact ────────────────────────────────────────────────────────────────
if (subCmd === 'redact') {
const parts = rest.split(/\s+/)
if (parts.length < 2 || !parts[0] || !parts[1]) {
return {
action: 'invalid',
reason:
'redact requires a store id and version id, e.g. redact ms_123 ver_456',
}
}
return { action: 'redact', storeId: parts[0], versionId: parts[1] }
}
return {
action: 'invalid',
reason: `Unknown sub-command "${subCmd}". ${USAGE}`,
}
}