feat(platform-service): add memory-items API backed by Cosmos

This commit is contained in:
Saravana Dhandapani 2026-02-15 03:20:09 -08:00
parent cb728d3dfe
commit 17c41e8441
7 changed files with 436 additions and 2 deletions

View File

@ -85,7 +85,8 @@ Voice capture pipeline
Text capture
- [ ] **P0** — Persist raw text to `memory_items` Cosmos container (currently in-memory)
- [x] **P0** — Backend: persist raw text to `memory_items` Cosmos container (`platform-service` `POST /api/memory-items`)
- [ ] **P0** — Mobile: call `platform-service` `POST /api/memory-items` during capture/triage flow
Image/screenshot capture
@ -94,7 +95,8 @@ Image/screenshot capture
Triage persistence
- [ ] **P0** — Store structured `MemoryItem` in Cosmos DB with all triage fields (currently in-memory)
- [x] **P0** — Backend: store structured `MemoryItem` in Cosmos DB with triage fields (`platform-service` `memory_items`)
- [ ] **P0** — Mobile: wire triage confirmation to send `triageResult` + `brainIds` to backend (`POST /api/memory-items`)
Widgets

View File

@ -8,6 +8,11 @@ const CONTAINER_DEFS: Record<string, ContainerConfig> = {
notification_prefs: { partitionKeyPath: '/userId' },
audit_log: { partitionKeyPath: '/category', defaultTtl: 90 * 86400 },
feature_flags: { partitionKeyPath: '/id' },
// Mobile capture primitives (MindLyst-style).
memory_items: { partitionKeyPath: '/userId' },
daily_briefs: { partitionKeyPath: '/userId' },
reflections: { partitionKeyPath: '/userId' },
brain_insights: { partitionKeyPath: '/userId' },
};
export async function initCosmosIfNeeded(): Promise<void> {

View File

@ -0,0 +1,63 @@
/**
* Tests for memory item schemas.
*/
import { describe, it, expect } from 'vitest';
import {
CreateMemoryItemSchema,
ListMemoryItemsQuerySchema,
PatchMemoryItemSchema,
ReassignMemoryItemSchema,
} from './types.js';
describe('ListMemoryItemsQuerySchema', () => {
it('accepts defaults', () => {
const result = ListMemoryItemsQuerySchema.safeParse({});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.limit).toBe(50);
expect(result.data.offset).toBe(0);
}
});
it('rejects huge limit', () => {
const result = ListMemoryItemsQuerySchema.safeParse({ limit: 9999 });
expect(result.success).toBe(false);
});
});
describe('CreateMemoryItemSchema', () => {
it('requires rawContent', () => {
const result = CreateMemoryItemSchema.safeParse({ sourceType: 'text' });
expect(result.success).toBe(false);
});
it('accepts minimal valid payload', () => {
const result = CreateMemoryItemSchema.safeParse({ rawContent: 'hello world' });
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.sourceType).toBe('text');
expect(result.data.captureSurface).toBe('app');
}
});
});
describe('ReassignMemoryItemSchema', () => {
it('accepts newBrainId', () => {
const result = ReassignMemoryItemSchema.safeParse({ newBrainId: 'work' });
expect(result.success).toBe(true);
});
});
describe('PatchMemoryItemSchema', () => {
it('requires reminderAt for set_reminder at route level', () => {
const result = PatchMemoryItemSchema.safeParse({ action: 'set_reminder' });
expect(result.success).toBe(true);
});
it('rejects invalid action', () => {
const result = PatchMemoryItemSchema.safeParse({ action: 'nope' });
expect(result.success).toBe(false);
});
});

View File

@ -0,0 +1,101 @@
/**
* Memory items repository Cosmos DB CRUD.
*
* Container: memory_items (partition: /userId)
*/
import { getContainer } from '../../lib/cosmos.js';
import type { MemoryItemDoc } from './types.js';
function container() {
return getContainer('memory_items');
}
export type ListMemoryItemsQuery = {
productId: string;
userId: string;
brainId?: string;
filter?: 'forgotten' | 'completed_today';
limit: number;
offset: number;
};
export async function list(query: ListMemoryItemsQuery): Promise<{ items: MemoryItemDoc[] }> {
const conditions: string[] = ['c.userId = @userId', 'c.productId = @productId'];
const params: { name: string; value: string | number | boolean }[] = [
{ name: '@userId', value: query.userId },
{ name: '@productId', value: query.productId },
];
if (query.brainId) {
conditions.push('ARRAY_CONTAINS(c.brainIds, @brainId)');
params.push({ name: '@brainId', value: query.brainId });
}
if (query.filter === 'forgotten') {
const before = new Date(Date.now() - 48 * 3600 * 1000).toISOString();
conditions.push('c.actedOn = false');
conditions.push('c.createdAt < @before');
params.push({ name: '@before', value: before });
}
if (query.filter === 'completed_today') {
const todayPrefix = new Date().toISOString().slice(0, 10);
conditions.push('c.actedOn = true');
conditions.push('STARTSWITH(c.actedOnAt, @todayPrefix)');
params.push({ name: '@todayPrefix', value: todayPrefix });
}
const where = `WHERE ${conditions.join(' AND ')}`;
const { resources } = await container()
.items.query<MemoryItemDoc>(
{
query: `SELECT * FROM c ${where} ORDER BY c.createdAt DESC OFFSET @offset LIMIT @limit`,
parameters: [
...params,
{ name: '@offset', value: query.offset },
{ name: '@limit', value: query.limit },
],
},
{ partitionKey: query.userId }
)
.fetchAll();
return { items: resources ?? [] };
}
export async function getById(
id: string,
userId: string,
productId: string
): Promise<MemoryItemDoc | null> {
try {
const { resource } = await container().item(id, userId).read<MemoryItemDoc>();
if (!resource) return null;
if (resource.productId !== productId) return null;
return resource;
} catch {
return null;
}
}
export async function create(doc: MemoryItemDoc): Promise<MemoryItemDoc> {
const { resource } = await container().items.create(doc);
return resource as MemoryItemDoc;
}
export async function replace(doc: MemoryItemDoc): Promise<MemoryItemDoc> {
const { resource } = await container().item(doc.id, doc.userId).replace(doc);
return resource as MemoryItemDoc;
}
export async function remove(id: string, userId: string): Promise<boolean> {
try {
await container().item(id, userId).delete();
return true;
} catch {
return false;
}
}

View File

@ -0,0 +1,170 @@
/**
* Memory items REST endpoints.
*
* GET /memory-items list (optional brainId/filter)
* POST /memory-items create
* PUT /memory-items/:id/reassign reassign to another brain
* PATCH /memory-items/:id mark done/undone, increment nudge, set reminder
* DELETE /memory-items/:id delete
*
* Container: memory_items (partition key: /userId)
*/
import type { FastifyInstance } from 'fastify';
import { randomUUID } from 'node:crypto';
import { DEFAULT_PRODUCT_ID } from '../../lib/product-config.js';
import { BadRequestError, NotFoundError } from '../../lib/errors.js';
import { extractAuth } from '../../lib/auth.js';
import * as repo from './repository.js';
import {
CreateMemoryItemSchema,
ListMemoryItemsQuerySchema,
PatchMemoryItemSchema,
ReassignMemoryItemSchema,
type MemoryItemDoc,
type TriageResult,
} from './types.js';
function defaultTriage(rawContent: string): TriageResult {
return {
contentType: 'memory',
summary: rawContent.slice(0, 200),
urgencyScore: 0.3,
emotionScore: 0,
confidenceScore: 0.5,
suggestedBrainId: 'global',
entities: [],
suggestedActions: [],
};
}
export async function memoryRoutes(app: FastifyInstance) {
// List memory items
app.get('/memory-items', async req => {
const auth = await extractAuth(req);
const parsed = ListMemoryItemsQuerySchema.safeParse(req.query);
if (!parsed.success) {
throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; '));
}
const q = parsed.data;
const pid = q.productId || DEFAULT_PRODUCT_ID;
const { items } = await repo.list({
productId: pid,
userId: auth.sub,
brainId: q.brainId,
filter: q.filter,
limit: q.limit,
offset: q.offset,
});
return { items, limit: q.limit, offset: q.offset };
});
// Create memory item
app.post('/memory-items', async (req, reply) => {
const auth = await extractAuth(req);
const parsed = CreateMemoryItemSchema.safeParse(req.body);
if (!parsed.success) {
throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; '));
}
const input = parsed.data;
const pid = input.productId || DEFAULT_PRODUCT_ID;
const now = new Date().toISOString();
const triage = input.triageResult ?? defaultTriage(input.rawContent);
const brainIds = input.brainIds && input.brainIds.length > 0 ? input.brainIds : [triage.suggestedBrainId];
const doc: MemoryItemDoc = {
id: `mem_${Date.now()}_${randomUUID()}`,
productId: pid,
userId: auth.sub,
sourceType: input.sourceType,
captureSurface: input.captureSurface,
rawContent: input.rawContent,
triageResult: triage,
brainIds,
...(input.reminderAt && { reminderAt: input.reminderAt }),
actedOn: false,
actedOnAt: null,
nudgeCount: 0,
userCorrection: null,
isSensitive: input.isSensitive ?? false,
createdAt: now,
updatedAt: now,
};
const created = await repo.create(doc);
reply.code(201);
return created;
});
// Reassign
app.put('/memory-items/:id/reassign', async req => {
const auth = await extractAuth(req);
const { id } = req.params as { id: string };
const parsed = ReassignMemoryItemSchema.safeParse(req.body);
if (!parsed.success) {
throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; '));
}
const pid = (req.query as { productId?: string }).productId || DEFAULT_PRODUCT_ID;
const item = await repo.getById(id, auth.sub, pid);
if (!item) throw new NotFoundError('Memory item not found');
const updated: MemoryItemDoc = {
...item,
brainIds: [parsed.data.newBrainId],
userCorrection: parsed.data.newBrainId,
updatedAt: new Date().toISOString(),
};
return repo.replace(updated);
});
// Patch actions
app.patch('/memory-items/:id', async req => {
const auth = await extractAuth(req);
const { id } = req.params as { id: string };
const parsed = PatchMemoryItemSchema.safeParse(req.body);
if (!parsed.success) {
throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; '));
}
const pid = (req.query as { productId?: string }).productId || DEFAULT_PRODUCT_ID;
const item = await repo.getById(id, auth.sub, pid);
if (!item) throw new NotFoundError('Memory item not found');
const action = parsed.data.action;
const now = new Date().toISOString();
let updated: MemoryItemDoc = { ...item, updatedAt: now };
if (action === 'mark_done') {
updated = { ...updated, actedOn: true, actedOnAt: now };
} else if (action === 'mark_undone') {
updated = { ...updated, actedOn: false, actedOnAt: null };
} else if (action === 'increment_nudge') {
updated = { ...updated, nudgeCount: item.nudgeCount + 1 };
} else if (action === 'set_reminder') {
if (!parsed.data.reminderAt) {
throw new BadRequestError('reminderAt is required for set_reminder');
}
updated = { ...updated, reminderAt: parsed.data.reminderAt };
}
return repo.replace(updated);
});
// Delete
app.delete('/memory-items/:id', async req => {
const auth = await extractAuth(req);
const { id } = req.params as { id: string };
const pid = (req.query as { productId?: string }).productId || DEFAULT_PRODUCT_ID;
const item = await repo.getById(id, auth.sub, pid);
if (!item) throw new NotFoundError('Memory item not found');
await repo.remove(id, auth.sub);
return { success: true };
});
}

View File

@ -0,0 +1,90 @@
/**
* MindLyst-style "memory items" persisted capture + triage results.
*
* Container: memory_items (partition key: /userId)
*
* This is intentionally product-agnostic: every doc includes productId.
*/
import { z } from 'zod';
export const SourceTypeSchema = z.enum(['voice', 'image', 'link', 'email', 'text']);
export const CaptureSurfaceSchema = z.enum([
'app',
'widget',
'share_sheet',
'siri',
'email',
'web',
'notification_reply',
]);
export const ContentTypeSchema = z.enum(['task', 'reminder', 'memory', 'idea', 'risk', 'reference']);
export const TriageResultSchema = z.object({
contentType: ContentTypeSchema,
summary: z.string().min(1).max(2000),
urgencyScore: z.number().min(0).max(1),
emotionScore: z.number().min(-1).max(1),
confidenceScore: z.number().min(0).max(1),
suggestedBrainId: z.string().min(1).max(128),
entities: z.array(z.string().min(1).max(128)).default([]),
suggestedActions: z.array(z.string().min(1).max(256)).default([]),
});
export const ListMemoryItemsQuerySchema = z.object({
productId: z.string().min(1).max(64).optional(),
brainId: z.string().min(1).max(128).optional(),
filter: z.enum(['forgotten', 'completed_today']).optional(),
limit: z.coerce.number().int().min(1).max(200).default(50),
offset: z.coerce.number().int().min(0).max(50_000).default(0),
});
export const CreateMemoryItemSchema = z.object({
productId: z.string().min(1).max(64).optional(),
sourceType: SourceTypeSchema.default('text'),
captureSurface: CaptureSurfaceSchema.default('app'),
rawContent: z.string().min(1).max(50_000),
triageResult: TriageResultSchema.optional(),
brainIds: z.array(z.string().min(1).max(128)).optional(),
isSensitive: z.boolean().optional(),
reminderAt: z
.string()
.refine(v => !Number.isNaN(Date.parse(v)), 'reminderAt must be an ISO date string')
.optional(),
});
export const ReassignMemoryItemSchema = z.object({
newBrainId: z.string().min(1).max(128),
});
export const PatchMemoryItemSchema = z.object({
action: z.enum(['mark_done', 'mark_undone', 'increment_nudge', 'set_reminder']),
reminderAt: z
.string()
.refine(v => !Number.isNaN(Date.parse(v)), 'reminderAt must be an ISO date string')
.optional(),
});
export type TriageResult = z.infer<typeof TriageResultSchema>;
export type MemoryItemDoc = {
id: string;
productId: string;
userId: string;
sourceType: z.infer<typeof SourceTypeSchema>;
captureSurface: z.infer<typeof CaptureSurfaceSchema>;
rawContent: string;
triageResult: TriageResult;
brainIds: string[];
reminderAt?: string;
actedOn: boolean;
actedOnAt: string | null;
nudgeCount: number;
userCorrection: string | null;
isSensitive: boolean;
createdAt: string;
updatedAt: string;
};

View File

@ -39,6 +39,7 @@ import { stripeRoutes } from './modules/stripe/routes.js';
import { itemRoutes } from './modules/items/routes.js';
import { commentRoutes } from './modules/comments/routes.js';
import { voteRoutes } from './modules/votes/routes.js';
import { memoryRoutes } from './modules/memory/routes.js';
import { publicRoutes } from './modules/public/routes.js';
import { initCosmosIfNeeded } from './lib/cosmos-init.js';
import { config } from './lib/config.js';
@ -97,6 +98,8 @@ await app.register(stripeRoutes, { prefix: '/api' });
await app.register(itemRoutes, { prefix: '/api' });
await app.register(commentRoutes, { prefix: '/api' });
await app.register(voteRoutes, { prefix: '/api' });
// Mobile capture modules
await app.register(memoryRoutes, { prefix: '/api' });
// Public routes — no auth, registered at top level
await app.register(publicRoutes, { prefix: '/api' });