mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-18 14:25:51 +00:00
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:
263
src/commands/memory-stores/MemoryStoresView.tsx
Normal file
263
src/commands/memory-stores/MemoryStoresView.tsx
Normal 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 <name> 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} <content> 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>
|
||||
);
|
||||
}
|
||||
586
src/commands/memory-stores/__tests__/api.test.ts
Normal file
586
src/commands/memory-stores/__tests__/api.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
69
src/commands/memory-stores/__tests__/index.test.ts
Normal file
69
src/commands/memory-stores/__tests__/index.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
380
src/commands/memory-stores/__tests__/launchMemoryStores.test.ts
Normal file
380
src/commands/memory-stores/__tests__/launchMemoryStores.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
190
src/commands/memory-stores/__tests__/parseArgs.test.ts
Normal file
190
src/commands/memory-stores/__tests__/parseArgs.test.ts
Normal 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')
|
||||
}
|
||||
})
|
||||
})
|
||||
30
src/commands/memory-stores/index.ts
Normal file
30
src/commands/memory-stores/index.ts
Normal 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
|
||||
279
src/commands/memory-stores/launchMemoryStores.tsx
Normal file
279
src/commands/memory-stores/launchMemoryStores.tsx
Normal 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,
|
||||
});
|
||||
377
src/commands/memory-stores/memoryStoresApi.ts
Normal file
377
src/commands/memory-stores/memoryStoresApi.ts
Normal 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
|
||||
})
|
||||
}
|
||||
207
src/commands/memory-stores/parseArgs.ts
Normal file
207
src/commands/memory-stores/parseArgs.ts
Normal 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}`,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user