diff --git a/README.md b/README.md index 00304eb..0aff089 100644 --- a/README.md +++ b/README.md @@ -82,6 +82,14 @@ Tags, memories, project instructions, conversation history—anything Loominary This is still taking shape. While the architecture supports it, the integration surface is still in its early stages. If you're interested in building on top of this—or have ideas about how to bridge Loominary with other AI clients—[open an issue](https://github.com/Laumss/Loominary/issues) and let's figure it out together. +Sprint 2 adds the first concrete version of that surface: + + * a versioned local archive contract for conversations, branches, tags, favorites, and captured context + * a small local HTTP service with MCP-style tool calls for listing, searching, and fetching archive data + * consumer docs and example query flows for other local AI tools + +See [docs/local-archive-contract.md](docs/local-archive-contract.md) and [docs/local-service-consumer-guide.md](docs/local-service-consumer-guide.md). + ----- ## Privacy @@ -98,4 +106,4 @@ Loominary is a ground-up rewrite of the original Lyra Exporter. The old codebase ## Contributing -A contributing guide and development roadmap are on the way. In the meantime, if you have any idea, please [open an discussion](https://github.com/Laumss/Loominary/discussions). \ No newline at end of file +A contributing guide and development roadmap are on the way. In the meantime, if you have any idea, please [open an discussion](https://github.com/Laumss/Loominary/discussions). diff --git a/docs/local-archive-contract.md b/docs/local-archive-contract.md new file mode 100644 index 0000000..1e12e12 --- /dev/null +++ b/docs/local-archive-contract.md @@ -0,0 +1,212 @@ +# Loominary Local Archive Contract v1 + +This sprint adds the first stable local archive surface for Loominary. The goal is to keep the protocol small, provider-agnostic, and durable enough for other local AI tools to consume directly. + +## Design goals + +- Local-first source of truth. +- Provider-agnostic normalized records. +- Separate immutable-ish archive records from rebuildable indexes. +- Expose captured context beside the conversation it belongs to. +- Keep auth simple for local-only use, with an opt-in token for stricter setups. + +## On-disk layout + +```text +archive-root/ + manifest.json + annotations.json + conversations/ + .json + contexts/ + .json +``` + +## Source of truth vs derived state + +Source of truth: + +- `manifest.json` +- `annotations.json` +- `conversations/*.json` +- `contexts/*.json` + +Derived state: + +- Full-text search indexes +- Conversation summary views +- Branch tree projections + +Derived state can be rebuilt at service startup. Consumers should not treat indexes as canonical. + +## `manifest.json` + +```json +{ + "schemaVersion": "loominary.archive/v1", + "archiveId": "workspace-or-user-archive", + "createdAt": "2026-05-08T00:00:00.000Z", + "updatedAt": "2026-05-08T00:00:00.000Z", + "layout": { + "conversationsDir": "conversations", + "contextsDir": "contexts", + "annotationsFile": "annotations.json" + }, + "lifecycle": { + "sourceOfTruth": [ + "manifest.json", + "conversations/*.json", + "contexts/*.json", + "annotations.json" + ], + "derivedIndexes": [ + "rebuild-in-memory" + ] + }, + "trust": { + "defaultBind": "127.0.0.1", + "authMode": "loopback-or-token" + } +} +``` + +## Conversation record + +Each conversation is a self-contained normalized record: + +```json +{ + "schemaVersion": "loominary.conversation/v1", + "conversation": { + "id": "conv-alpha", + "title": "Sprint architecture notes", + "platform": "claude", + "provider": "anthropic", + "providerConversationId": "provider-alpha", + "createdAt": "2026-05-07T12:00:00.000Z", + "updatedAt": "2026-05-08T02:00:00.000Z", + "favorite": true + }, + "branches": [ + { + "id": "main", + "rootMessageId": "msg-1", + "leafMessageIds": ["msg-3", "msg-4"] + } + ], + "messages": [ + { + "id": "msg-1", + "parentId": null, + "branchId": "main", + "role": "user", + "createdAt": "2026-05-07T12:00:00.000Z", + "text": "Summarize the local archive shape for Loominary.", + "content": [ + { "type": "text", "text": "Summarize the local archive shape for Loominary." } + ] + } + ] +} +``` + +Normalized rules: + +- `conversation.id` is Loominary-local and stable within the archive. +- `providerConversationId` is optional provider metadata and not the primary key. +- `messages[].text` is the consumer-friendly plain text projection. +- `messages[].content` preserves extensible typed blocks. +- `branches[]` describes branch membership without exposing provider-specific tree formats. + +## Context record + +Context lives beside the conversation, not mixed into message bodies: + +```json +{ + "schemaVersion": "loominary.context/v1", + "conversationId": "conv-alpha", + "project": { + "id": "project-1", + "name": "Loominary Sprint 2", + "description": "First local integration surface.", + "instructions": "Keep outputs provider-agnostic and durable.", + "knowledgeFiles": [ + { + "id": "kf-1", + "name": "README.md", + "summary": "Product promise and archive framing." + } + ] + }, + "memories": { + "global": [], + "project": [], + "saved": [] + } +} +``` + +This covers the captured context called out in the README: + +- project descriptions and instructions +- project memories +- saved memories +- knowledge-file metadata + +## Annotations record + +Tags and favorites are normalized into a single annotations file: + +```json +{ + "schemaVersion": "loominary.annotations/v1", + "favorites": ["conv-alpha"], + "tags": [ + { + "tag": "important", + "conversationId": "conv-alpha", + "messageId": "msg-2", + "createdAt": "2026-05-08T00:00:00.000Z", + "source": "loominary" + } + ] +} +``` + +Rules: + +- Favorites are conversation-level only. +- Tags can target a whole conversation or a specific message. +- Message tags stay stable via `messageId`, not array index. + +## Trust and auth model + +Default trust model: + +- Bind only to `127.0.0.1`. +- Treat loopback traffic as trusted when no token is configured. +- If `LOOMINARY_LOCAL_TOKEN` is set, require `Authorization: Bearer ` even on loopback. + +Non-goals for v1: + +- multi-user auth +- remote exposure +- browser-session identity forwarding + +## Storage and index lifecycle + +- Archive JSON files are the durable layer. +- Search and branch projections are rebuilt when the service starts. +- If a conversation or context file changes, restarting the service is enough to pick it up. +- Future sprint work can add file watching or persisted indexes without changing the record contract. + +## Mapping from current Loominary state + +This v1 contract is designed to absorb current product concepts cleanly: + +- browser `localStorage` favorites map -> `annotations.favorites` +- browser mark/tag state -> `annotations.tags` +- normalized parser output `chat_history` -> `messages` +- detected branches -> `branches` +- export-time project/memory payloads -> `contexts/.json` diff --git a/docs/local-service-consumer-guide.md b/docs/local-service-consumer-guide.md new file mode 100644 index 0000000..ad6d405 --- /dev/null +++ b/docs/local-service-consumer-guide.md @@ -0,0 +1,125 @@ +# Loominary Local Service Consumer Guide + +## Start the service + +```bash +node server/loominary-local-service.mjs --archive ./tests/fixtures/archive-v1 --port 3788 +``` + +Optional auth: + +```bash +LOOMINARY_LOCAL_TOKEN=dev-token node server/loominary-local-service.mjs --archive ./tests/fixtures/archive-v1 +``` + +## HTTP surface + +Endpoints: + +- `GET /health` +- `GET /v1/conversations` +- `GET /v1/search?q=` +- `GET /v1/conversations/:conversationId` +- `GET /v1/conversations/:conversationId/tree` +- `GET /v1/conversations/:conversationId/context` +- `GET /v1/tags` +- `GET /v1/favorites` +- `POST /mcp` + +## Example HTTP query flows + +List recent conversations: + +```bash +curl http://127.0.0.1:3788/v1/conversations +``` + +Search across titles, messages, tags, and context: + +```bash +curl "http://127.0.0.1:3788/v1/search?q=provider-agnostic" +``` + +Fetch a full conversation and then its branch tree: + +```bash +curl http://127.0.0.1:3788/v1/conversations/conv-alpha +curl http://127.0.0.1:3788/v1/conversations/conv-alpha/tree +``` + +Fetch project and memory context: + +```bash +curl http://127.0.0.1:3788/v1/conversations/conv-alpha/context +``` + +Filter to favorites or a tag: + +```bash +curl "http://127.0.0.1:3788/v1/conversations?favoriteOnly=true" +curl "http://127.0.0.1:3788/v1/conversations?tag=research" +``` + +## Example MCP flows + +List available tools: + +```bash +curl -X POST http://127.0.0.1:3788/mcp \ + -H "Content-Type: application/json" \ + -d '{ + "jsonrpc": "2.0", + "id": 1, + "method": "tools/list" + }' +``` + +Search conversations via MCP: + +```bash +curl -X POST http://127.0.0.1:3788/mcp \ + -H "Content-Type: application/json" \ + -d '{ + "jsonrpc": "2.0", + "id": 2, + "method": "tools/call", + "params": { + "name": "search_conversations", + "arguments": { + "query": "project memories" + } + } + }' +``` + +Fetch attached context via MCP: + +```bash +curl -X POST http://127.0.0.1:3788/mcp \ + -H "Content-Type: application/json" \ + -d '{ + "jsonrpc": "2.0", + "id": 3, + "method": "tools/call", + "params": { + "name": "get_context", + "arguments": { + "conversationId": "conv-alpha" + } + } + }' +``` + +## Consumer expectations + +- Treat conversation IDs as Loominary-local IDs. +- Prefer `messages[].text` for simple prompt/context assembly. +- Use `messages[].content` when you need typed rendering later. +- Pull context separately so tools can choose when to include project instructions or saved memories. +- Do not depend on provider-specific payloads being present. + +## Current limitations + +- Archive creation is not yet automated from the browser app. +- Search is in-memory and rebuilt at startup. +- The MCP surface is tool-call oriented and intentionally narrow for v1. diff --git a/package.json b/package.json index 1b605e5..c74a4d9 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,8 @@ "start": "cross-env HOST=0.0.0.0 PORT=3789 PUBLIC_URL=/lyra-exporter react-scripts start", "start:local": "cross-env PORT=3789 react-scripts start", "start:network": "cross-env HOST=0.0.0.0 PORT=3789 react-scripts start", + "local-service": "node server/loominary-local-service.mjs", + "test:local-service": "node --test tests/*.test.mjs", "build": "cross-env NODE_OPTIONS=--max-old-space-size=4096 react-scripts build", "postbuild": "node -e \"const fs=require('fs');fs.mkdirSync('build/welcome',{recursive:true});let h=fs.readFileSync('build/index.html','utf8');h=h.replace(/\\.\\//g,'../');fs.writeFileSync('build/welcome/index.html',h)\"", "build:extension": "cross-env NODE_OPTIONS=--max-old-space-size=4096 react-scripts build", diff --git a/server/archive/contract.mjs b/server/archive/contract.mjs new file mode 100644 index 0000000..35ef12a --- /dev/null +++ b/server/archive/contract.mjs @@ -0,0 +1,84 @@ +export const ARCHIVE_SCHEMA_VERSION = 'loominary.archive/v1'; +export const CONVERSATION_SCHEMA_VERSION = 'loominary.conversation/v1'; +export const CONTEXT_SCHEMA_VERSION = 'loominary.context/v1'; +export const ANNOTATIONS_SCHEMA_VERSION = 'loominary.annotations/v1'; + +export function validateManifest(manifest) { + if (!manifest || typeof manifest !== 'object') { + throw new Error('Archive manifest must be an object.'); + } + + if (manifest.schemaVersion !== ARCHIVE_SCHEMA_VERSION) { + throw new Error(`Unsupported archive schema version: ${manifest.schemaVersion || 'missing'}`); + } + + if (!manifest.archiveId || typeof manifest.archiveId !== 'string') { + throw new Error('Archive manifest is missing archiveId.'); + } + + if (!manifest.layout || typeof manifest.layout !== 'object') { + throw new Error('Archive manifest is missing layout.'); + } + + return manifest; +} + +export function validateConversationRecord(record) { + if (!record || typeof record !== 'object') { + throw new Error('Conversation record must be an object.'); + } + + if (record.schemaVersion !== CONVERSATION_SCHEMA_VERSION) { + throw new Error(`Unsupported conversation schema version: ${record.schemaVersion || 'missing'}`); + } + + if (!record.conversation?.id) { + throw new Error('Conversation record is missing conversation.id.'); + } + + if (!Array.isArray(record.messages)) { + throw new Error(`Conversation ${record.conversation.id} is missing messages.`); + } + + if (!Array.isArray(record.branches)) { + throw new Error(`Conversation ${record.conversation.id} is missing branches.`); + } + + return record; +} + +export function validateContextRecord(record) { + if (!record || typeof record !== 'object') { + throw new Error('Context record must be an object.'); + } + + if (record.schemaVersion !== CONTEXT_SCHEMA_VERSION) { + throw new Error(`Unsupported context schema version: ${record.schemaVersion || 'missing'}`); + } + + if (!record.conversationId || typeof record.conversationId !== 'string') { + throw new Error('Context record is missing conversationId.'); + } + + return record; +} + +export function validateAnnotations(record) { + if (!record || typeof record !== 'object') { + throw new Error('Annotations record must be an object.'); + } + + if (record.schemaVersion !== ANNOTATIONS_SCHEMA_VERSION) { + throw new Error(`Unsupported annotations schema version: ${record.schemaVersion || 'missing'}`); + } + + if (!Array.isArray(record.favorites)) { + throw new Error('Annotations record is missing favorites.'); + } + + if (!Array.isArray(record.tags)) { + throw new Error('Annotations record is missing tags.'); + } + + return record; +} diff --git a/server/archive/loadArchive.mjs b/server/archive/loadArchive.mjs new file mode 100644 index 0000000..d16c7fa --- /dev/null +++ b/server/archive/loadArchive.mjs @@ -0,0 +1,225 @@ +import { readFile, readdir } from 'node:fs/promises'; +import path from 'node:path'; + +import { + validateAnnotations, + validateContextRecord, + validateConversationRecord, + validateManifest +} from './contract.mjs'; +import { buildConversationIndex, searchConversationIndex } from './searchIndex.mjs'; + +async function readJson(filePath) { + const raw = await readFile(filePath, 'utf8'); + return JSON.parse(raw); +} + +function ensureDirectoryPath(rootPath, relativeDir) { + return path.resolve(rootPath, relativeDir); +} + +function summarizeConversation(record, { favoriteSet, tagsByConversation }) { + const tags = tagsByConversation.get(record.conversation.id) || []; + const favorite = favoriteSet.has(record.conversation.id) || !!record.conversation.favorite; + + return { + id: record.conversation.id, + title: record.conversation.title, + platform: record.conversation.platform, + createdAt: record.conversation.createdAt, + updatedAt: record.conversation.updatedAt, + messageCount: record.messages.length, + branchCount: record.branches.length, + favorite, + tags, + provider: record.conversation.provider || null + }; +} + +function buildBranchTree(conversationRecord) { + const messageMap = new Map(conversationRecord.messages.map(message => [message.id, message])); + const childrenByParent = new Map(); + + for (const message of conversationRecord.messages) { + const parentId = message.parentId || null; + if (!childrenByParent.has(parentId)) { + childrenByParent.set(parentId, []); + } + childrenByParent.get(parentId).push(message.id); + } + + return { + conversationId: conversationRecord.conversation.id, + branches: conversationRecord.branches, + rootMessageIds: childrenByParent.get(null) || [], + nodes: conversationRecord.messages.map(message => ({ + id: message.id, + parentId: message.parentId || null, + branchId: message.branchId || null, + role: message.role, + createdAt: message.createdAt || null, + childIds: childrenByParent.get(message.id) || [], + isBranchPoint: (childrenByParent.get(message.id) || []).length > 1, + preview: message.text || '' + })), + missingParents: conversationRecord.messages + .filter(message => message.parentId && !messageMap.has(message.parentId)) + .map(message => message.id) + }; +} + +export class LoomArchive { + constructor(rootPath, manifest, conversations, contexts, annotations) { + this.rootPath = rootPath; + this.manifest = manifest; + this.conversations = conversations; + this.contexts = contexts; + this.annotations = annotations; + + this.favoriteSet = new Set(annotations.favorites); + this.tagsByConversation = new Map(); + + for (const tag of annotations.tags) { + if (!this.tagsByConversation.has(tag.conversationId)) { + this.tagsByConversation.set(tag.conversationId, []); + } + this.tagsByConversation.get(tag.conversationId).push(tag); + } + + this.indexEntries = Array.from(conversations.values()).map(record => + buildConversationIndex( + record, + this.tagsByConversation.get(record.conversation.id) || [], + contexts.get(record.conversation.id) || null + ) + ); + } + + listConversations({ limit = 50, cursor = 0, favoriteOnly = false, tag = null } = {}) { + const summaries = Array.from(this.conversations.values()) + .map(record => summarizeConversation(record, this)) + .filter(summary => { + if (favoriteOnly && !summary.favorite) { + return false; + } + + if (tag && !summary.tags.some(item => item.tag === tag)) { + return false; + } + + return true; + }) + .sort((a, b) => (b.updatedAt || '').localeCompare(a.updatedAt || '')); + + const start = Number(cursor) || 0; + const end = start + Number(limit || 50); + + return { + items: summaries.slice(start, end), + nextCursor: end < summaries.length ? end : null, + total: summaries.length + }; + } + + searchConversations(query, options = {}) { + const hits = searchConversationIndex(this.indexEntries, query, { + favoriteOnly: !!options.favoriteOnly, + favoriteSet: this.favoriteSet + }); + + return hits.map(hit => { + const record = this.conversations.get(hit.conversationId); + return { + ...summarizeConversation(record, this), + score: hit.score, + excerpt: hit.excerpt + }; + }); + } + + getConversation(conversationId) { + const record = this.conversations.get(conversationId); + if (!record) { + return null; + } + + return { + ...record, + favorite: this.favoriteSet.has(conversationId) || !!record.conversation.favorite, + tags: this.tagsByConversation.get(conversationId) || [], + contextAvailable: this.contexts.has(conversationId) + }; + } + + getBranchTree(conversationId) { + const record = this.conversations.get(conversationId); + return record ? buildBranchTree(record) : null; + } + + getContext(conversationId) { + return this.contexts.get(conversationId) || null; + } + + getFavorites() { + return Array.from(this.favoriteSet).map(conversationId => { + const record = this.conversations.get(conversationId); + return record ? summarizeConversation(record, this) : { id: conversationId }; + }); + } + + getTags() { + const byTag = new Map(); + for (const tag of this.annotations.tags) { + if (!byTag.has(tag.tag)) { + byTag.set(tag.tag, []); + } + byTag.get(tag.tag).push(tag); + } + + return Array.from(byTag.entries()) + .map(([tag, items]) => ({ + tag, + usageCount: items.length, + conversations: Array.from(new Set(items.map(item => item.conversationId))) + })) + .sort((a, b) => a.tag.localeCompare(b.tag)); + } +} + +export async function loadArchive(rootPath) { + const manifestPath = path.join(rootPath, 'manifest.json'); + const manifest = validateManifest(await readJson(manifestPath)); + + const conversationsDir = ensureDirectoryPath(rootPath, manifest.layout.conversationsDir); + const contextsDir = ensureDirectoryPath(rootPath, manifest.layout.contextsDir); + const annotationsPath = path.join(rootPath, manifest.layout.annotationsFile); + + const conversationFiles = (await readdir(conversationsDir)) + .filter(name => name.endsWith('.json')) + .sort(); + + const conversations = new Map(); + for (const name of conversationFiles) { + const record = validateConversationRecord(await readJson(path.join(conversationsDir, name))); + conversations.set(record.conversation.id, record); + } + + const contexts = new Map(); + try { + const contextFiles = (await readdir(contextsDir)) + .filter(name => name.endsWith('.json')) + .sort(); + + for (const name of contextFiles) { + const record = validateContextRecord(await readJson(path.join(contextsDir, name))); + contexts.set(record.conversationId, record); + } + } catch (error) { + if (error.code !== 'ENOENT') { + throw error; + } + } + + const annotations = validateAnnotations(await readJson(annotationsPath)); + return new LoomArchive(rootPath, manifest, conversations, contexts, annotations); +} diff --git a/server/archive/searchIndex.mjs b/server/archive/searchIndex.mjs new file mode 100644 index 0000000..93e8939 --- /dev/null +++ b/server/archive/searchIndex.mjs @@ -0,0 +1,109 @@ +function normalizeText(value) { + return typeof value === 'string' ? value.toLowerCase() : ''; +} + +function flattenContent(contentBlocks = []) { + return contentBlocks + .filter(block => block && typeof block === 'object') + .map(block => { + if (typeof block.text === 'string') { + return block.text; + } + + if (typeof block.value === 'string') { + return block.value; + } + + return ''; + }) + .filter(Boolean) + .join('\n'); +} + +export function buildConversationIndex(conversationRecord, tags = [], contextRecord = null) { + const messageTexts = conversationRecord.messages + .map(message => message.text || flattenContent(message.content)) + .filter(Boolean); + + const contextTexts = []; + if (contextRecord?.project) { + contextTexts.push( + contextRecord.project.name || '', + contextRecord.project.description || '', + contextRecord.project.instructions || '' + ); + + for (const file of contextRecord.project.knowledgeFiles || []) { + contextTexts.push(file.name || '', file.summary || '', file.content || ''); + } + } + + for (const memory of contextRecord?.memories?.saved || []) { + contextTexts.push(memory.title || '', memory.content || ''); + } + + for (const memory of contextRecord?.memories?.global || []) { + contextTexts.push(memory.title || '', memory.content || ''); + } + + for (const memory of contextRecord?.memories?.project || []) { + contextTexts.push(memory.title || '', memory.content || ''); + } + + const tagTexts = tags.map(tag => tag.tag); + const title = conversationRecord.conversation.title || ''; + + return { + conversationId: conversationRecord.conversation.id, + title, + searchText: normalizeText([ + title, + ...messageTexts, + ...contextTexts, + ...tagTexts + ].join('\n')), + previewSource: messageTexts.join('\n') + }; +} + +export function searchConversationIndex(indexEntries, query, { favoriteOnly = false, favoriteSet = new Set() } = {}) { + const normalizedQuery = normalizeText(query).trim(); + if (!normalizedQuery) { + return []; + } + + const results = []; + for (const entry of indexEntries) { + if (favoriteOnly && !favoriteSet.has(entry.conversationId)) { + continue; + } + + const firstIndex = entry.searchText.indexOf(normalizedQuery); + if (firstIndex === -1) { + continue; + } + + const count = entry.searchText.split(normalizedQuery).length - 1; + results.push({ + conversationId: entry.conversationId, + score: count * 10 + Math.max(0, 50 - firstIndex), + excerpt: buildExcerpt(entry.previewSource, normalizedQuery) + }); + } + + return results.sort((a, b) => b.score - a.score); +} + +export function buildExcerpt(text, query, radius = 90) { + const normalized = normalizeText(text); + const index = normalized.indexOf(query); + if (index === -1) { + return text.slice(0, radius * 2).trim(); + } + + const start = Math.max(0, index - radius); + const end = Math.min(text.length, index + query.length + radius); + const prefix = start > 0 ? '...' : ''; + const suffix = end < text.length ? '...' : ''; + return `${prefix}${text.slice(start, end).trim()}${suffix}`; +} diff --git a/server/loominary-local-service.mjs b/server/loominary-local-service.mjs new file mode 100644 index 0000000..4ea1d55 --- /dev/null +++ b/server/loominary-local-service.mjs @@ -0,0 +1,43 @@ +import path from 'node:path'; + +import { loadArchive } from './archive/loadArchive.mjs'; +import { createServer } from './service/httpServer.mjs'; + +function readArgument(name, fallback = null) { + const index = process.argv.indexOf(name); + if (index === -1) { + return fallback; + } + + return process.argv[index + 1] || fallback; +} + +async function main() { + const archiveRoot = path.resolve(readArgument('--archive', process.env.LOOMINARY_ARCHIVE_PATH || './example-archive')); + const host = readArgument('--host', process.env.LOOMINARY_LOCAL_HOST || '127.0.0.1'); + const port = Number(readArgument('--port', process.env.LOOMINARY_LOCAL_PORT || '3788')); + const token = process.env.LOOMINARY_LOCAL_TOKEN || ''; + + const archive = await loadArchive(archiveRoot); + const service = createServer({ archive, host, port, token }); + const address = await service.listen(); + + process.stdout.write( + JSON.stringify( + { + ok: true, + archiveId: archive.manifest.archiveId, + host: address.address, + port: address.port, + auth: token ? 'bearer-token' : 'loopback-only' + }, + null, + 2 + ) + '\n' + ); +} + +main().catch(error => { + process.stderr.write(`${error.stack}\n`); + process.exitCode = 1; +}); diff --git a/server/service/httpServer.mjs b/server/service/httpServer.mjs new file mode 100644 index 0000000..b265253 --- /dev/null +++ b/server/service/httpServer.mjs @@ -0,0 +1,160 @@ +import http from 'node:http'; +import { URL } from 'node:url'; + +import { handleMcpRequest } from './mcpSurface.mjs'; + +function sendJson(response, statusCode, payload) { + response.writeHead(statusCode, { + 'content-type': 'application/json; charset=utf-8' + }); + response.end(JSON.stringify(payload, null, 2)); +} + +function notFound(response) { + sendJson(response, 404, { error: 'Not found' }); +} + +function unauthorized(response) { + sendJson(response, 401, { error: 'Unauthorized' }); +} + +async function readJsonBody(request) { + const chunks = []; + for await (const chunk of request) { + chunks.push(chunk); + } + + const body = Buffer.concat(chunks).toString('utf8'); + return body ? JSON.parse(body) : {}; +} + +function isLoopback(remoteAddress = '') { + return ( + remoteAddress === '127.0.0.1' || + remoteAddress === '::1' || + remoteAddress === '::ffff:127.0.0.1' + ); +} + +function authorize(request, token) { + if (isLoopback(request.socket.remoteAddress) && !token) { + return true; + } + + if (isLoopback(request.socket.remoteAddress) && token) { + return request.headers.authorization === `Bearer ${token}`; + } + + return false; +} + +export function createServer({ archive, token = '', host = '127.0.0.1', port = 3788 }) { + const server = http.createServer(async (request, response) => { + try { + if (!authorize(request, token)) { + unauthorized(response); + return; + } + + const url = new URL(request.url, `http://${request.headers.host || `${host}:${port}`}`); + const pathname = url.pathname; + + if (request.method === 'GET' && pathname === '/health') { + sendJson(response, 200, { + ok: true, + archiveId: archive.manifest.archiveId, + schemaVersion: archive.manifest.schemaVersion + }); + return; + } + + if (request.method === 'GET' && pathname === '/v1/conversations') { + sendJson(response, 200, archive.listConversations({ + limit: Number(url.searchParams.get('limit') || 50), + cursor: Number(url.searchParams.get('cursor') || 0), + favoriteOnly: url.searchParams.get('favoriteOnly') === 'true', + tag: url.searchParams.get('tag') + })); + return; + } + + if (request.method === 'GET' && pathname === '/v1/search') { + sendJson(response, 200, { + items: archive.searchConversations(url.searchParams.get('q') || '', { + favoriteOnly: url.searchParams.get('favoriteOnly') === 'true' + }) + }); + return; + } + + if (request.method === 'GET' && pathname === '/v1/tags') { + sendJson(response, 200, { items: archive.getTags() }); + return; + } + + if (request.method === 'GET' && pathname === '/v1/favorites') { + sendJson(response, 200, { items: archive.getFavorites() }); + return; + } + + if (request.method === 'POST' && pathname === '/mcp') { + const body = await readJsonBody(request); + sendJson(response, 200, handleMcpRequest(archive, body)); + return; + } + + const conversationMatch = pathname.match(/^\/v1\/conversations\/([^/]+)$/); + if (request.method === 'GET' && conversationMatch) { + const record = archive.getConversation(decodeURIComponent(conversationMatch[1])); + if (!record) { + notFound(response); + return; + } + sendJson(response, 200, record); + return; + } + + const branchTreeMatch = pathname.match(/^\/v1\/conversations\/([^/]+)\/tree$/); + if (request.method === 'GET' && branchTreeMatch) { + const record = archive.getBranchTree(decodeURIComponent(branchTreeMatch[1])); + if (!record) { + notFound(response); + return; + } + sendJson(response, 200, record); + return; + } + + const contextMatch = pathname.match(/^\/v1\/conversations\/([^/]+)\/context$/); + if (request.method === 'GET' && contextMatch) { + const record = archive.getContext(decodeURIComponent(contextMatch[1])); + if (!record) { + notFound(response); + return; + } + sendJson(response, 200, record); + return; + } + + notFound(response); + } catch (error) { + sendJson(response, 500, { + error: error.message + }); + } + }); + + return { + server, + listen() { + return new Promise(resolve => { + server.listen(port, host, () => resolve(server.address())); + }); + }, + close() { + return new Promise((resolve, reject) => { + server.close(error => error ? reject(error) : resolve()); + }); + } + }; +} diff --git a/server/service/mcpSurface.mjs b/server/service/mcpSurface.mjs new file mode 100644 index 0000000..e7f7bd4 --- /dev/null +++ b/server/service/mcpSurface.mjs @@ -0,0 +1,186 @@ +function jsonRpcResult(id, result) { + return { jsonrpc: '2.0', id, result }; +} + +function jsonRpcError(id, code, message) { + return { jsonrpc: '2.0', id, error: { code, message } }; +} + +function parseArguments(args) { + if (!args) { + return {}; + } + + if (typeof args === 'string') { + return JSON.parse(args); + } + + return args; +} + +export function handleMcpRequest(archive, body) { + const { id = null, method, params = {} } = body || {}; + + if (method === 'initialize') { + return jsonRpcResult(id, { + protocolVersion: '2024-11-05', + serverInfo: { + name: 'loominary-local-archive', + version: '0.1.0' + }, + capabilities: { + tools: {} + } + }); + } + + if (method === 'tools/list') { + return jsonRpcResult(id, { + tools: [ + { + name: 'list_conversations', + description: 'List archived conversations with optional favorites/tag filters.', + inputSchema: { + type: 'object', + properties: { + limit: { type: 'number' }, + cursor: { type: 'number' }, + favoriteOnly: { type: 'boolean' }, + tag: { type: 'string' } + } + } + }, + { + name: 'search_conversations', + description: 'Full-text search across titles, messages, tags, and captured context.', + inputSchema: { + type: 'object', + required: ['query'], + properties: { + query: { type: 'string' }, + favoriteOnly: { type: 'boolean' } + } + } + }, + { + name: 'get_conversation', + description: 'Fetch a full normalized conversation record.', + inputSchema: { + type: 'object', + required: ['conversationId'], + properties: { + conversationId: { type: 'string' } + } + } + }, + { + name: 'get_branch_tree', + description: 'Fetch the branch tree for a conversation.', + inputSchema: { + type: 'object', + required: ['conversationId'], + properties: { + conversationId: { type: 'string' } + } + } + }, + { + name: 'get_tags', + description: 'List all tags and their usage.', + inputSchema: { type: 'object', properties: {} } + }, + { + name: 'get_favorites', + description: 'List favorite conversations.', + inputSchema: { type: 'object', properties: {} } + }, + { + name: 'get_context', + description: 'Fetch attached project or memory context for a conversation.', + inputSchema: { + type: 'object', + required: ['conversationId'], + properties: { + conversationId: { type: 'string' } + } + } + } + ] + }); + } + + if (method === 'tools/call') { + const { name, arguments: rawArguments } = params; + const args = parseArguments(rawArguments); + + switch (name) { + case 'list_conversations': + return jsonRpcResult(id, { + content: [ + { + type: 'text', + text: JSON.stringify(archive.listConversations(args)) + } + ] + }); + case 'search_conversations': + return jsonRpcResult(id, { + content: [ + { + type: 'text', + text: JSON.stringify(archive.searchConversations(args.query, args)) + } + ] + }); + case 'get_conversation': + return jsonRpcResult(id, { + content: [ + { + type: 'text', + text: JSON.stringify(archive.getConversation(args.conversationId)) + } + ] + }); + case 'get_branch_tree': + return jsonRpcResult(id, { + content: [ + { + type: 'text', + text: JSON.stringify(archive.getBranchTree(args.conversationId)) + } + ] + }); + case 'get_tags': + return jsonRpcResult(id, { + content: [ + { + type: 'text', + text: JSON.stringify(archive.getTags()) + } + ] + }); + case 'get_favorites': + return jsonRpcResult(id, { + content: [ + { + type: 'text', + text: JSON.stringify(archive.getFavorites()) + } + ] + }); + case 'get_context': + return jsonRpcResult(id, { + content: [ + { + type: 'text', + text: JSON.stringify(archive.getContext(args.conversationId)) + } + ] + }); + default: + return jsonRpcError(id, -32601, `Unknown tool: ${name}`); + } + } + + return jsonRpcError(id, -32601, `Unknown method: ${method}`); +} diff --git a/tests/archive-loader.test.mjs b/tests/archive-loader.test.mjs new file mode 100644 index 0000000..ebfffca --- /dev/null +++ b/tests/archive-loader.test.mjs @@ -0,0 +1,37 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import path from 'node:path'; + +import { loadArchive } from '../server/archive/loadArchive.mjs'; + +const fixtureRoot = path.resolve('tests/fixtures/archive-v1'); + +test('loadArchive reads manifest, conversations, contexts, and annotations', async () => { + const archive = await loadArchive(fixtureRoot); + + assert.equal(archive.manifest.archiveId, 'fixture-archive'); + assert.equal(archive.listConversations().total, 2); + assert.equal(archive.getFavorites().length, 1); + assert.equal(archive.getTags().length, 3); + assert.equal(archive.getContext('conv-alpha').project.name, 'Loominary Sprint 2'); +}); + +test('searchConversations matches message and context content', async () => { + const archive = await loadArchive(fixtureRoot); + + const archiveShapeResults = archive.searchConversations('archive shape'); + assert.equal(archiveShapeResults[0].id, 'conv-alpha'); + + const memoryResults = archive.searchConversations('project memories'); + assert.equal(memoryResults[0].id, 'conv-alpha'); +}); + +test('getBranchTree returns parent-child structure and branch points', async () => { + const archive = await loadArchive(fixtureRoot); + const tree = archive.getBranchTree('conv-alpha'); + + assert.deepEqual(tree.rootMessageIds, ['msg-1']); + const branchPoint = tree.nodes.find(node => node.id === 'msg-2'); + assert.equal(branchPoint.isBranchPoint, true); + assert.deepEqual(branchPoint.childIds, ['msg-3', 'msg-4']); +}); diff --git a/tests/fixtures/archive-v1/annotations.json b/tests/fixtures/archive-v1/annotations.json new file mode 100644 index 0000000..100745e --- /dev/null +++ b/tests/fixtures/archive-v1/annotations.json @@ -0,0 +1,27 @@ +{ + "schemaVersion": "loominary.annotations/v1", + "favorites": [ + "conv-alpha" + ], + "tags": [ + { + "tag": "important", + "conversationId": "conv-alpha", + "messageId": "msg-2", + "createdAt": "2026-05-08T00:00:00.000Z", + "source": "loominary" + }, + { + "tag": "research", + "conversationId": "conv-alpha", + "createdAt": "2026-05-08T00:00:00.000Z", + "source": "loominary" + }, + { + "tag": "follow-up", + "conversationId": "conv-beta", + "createdAt": "2026-05-08T00:00:00.000Z", + "source": "loominary" + } + ] +} diff --git a/tests/fixtures/archive-v1/contexts/conv-alpha.json b/tests/fixtures/archive-v1/contexts/conv-alpha.json new file mode 100644 index 0000000..3335d23 --- /dev/null +++ b/tests/fixtures/archive-v1/contexts/conv-alpha.json @@ -0,0 +1,40 @@ +{ + "schemaVersion": "loominary.context/v1", + "conversationId": "conv-alpha", + "project": { + "id": "project-1", + "name": "Loominary Sprint 2", + "description": "First local integration surface.", + "instructions": "Keep outputs provider-agnostic and durable.", + "knowledgeFiles": [ + { + "id": "kf-1", + "name": "README.md", + "summary": "Product promise and archive framing." + } + ] + }, + "memories": { + "global": [ + { + "id": "memory-global-1", + "title": "User preference", + "content": "Favor local-first storage." + } + ], + "project": [ + { + "id": "memory-project-1", + "title": "Sprint constraint", + "content": "Keep the protocol narrow." + } + ], + "saved": [ + { + "id": "memory-saved-1", + "title": "Named memory", + "content": "Project memories should be queryable beside the chat." + } + ] + } +} diff --git a/tests/fixtures/archive-v1/conversations/conv-alpha.json b/tests/fixtures/archive-v1/conversations/conv-alpha.json new file mode 100644 index 0000000..fa51934 --- /dev/null +++ b/tests/fixtures/archive-v1/conversations/conv-alpha.json @@ -0,0 +1,88 @@ +{ + "schemaVersion": "loominary.conversation/v1", + "conversation": { + "id": "conv-alpha", + "title": "Sprint architecture notes", + "platform": "claude", + "provider": "anthropic", + "providerConversationId": "provider-alpha", + "createdAt": "2026-05-07T12:00:00.000Z", + "updatedAt": "2026-05-08T02:00:00.000Z", + "favorite": true + }, + "branches": [ + { + "id": "main", + "rootMessageId": "msg-1", + "leafMessageIds": [ + "msg-3", + "msg-4" + ] + }, + { + "id": "branch-a", + "parentBranchId": "main", + "rootMessageId": "msg-4", + "leafMessageIds": [ + "msg-4" + ] + } + ], + "messages": [ + { + "id": "msg-1", + "role": "user", + "branchId": "main", + "createdAt": "2026-05-07T12:00:00.000Z", + "text": "Summarize the local archive shape for Loominary.", + "content": [ + { + "type": "text", + "text": "Summarize the local archive shape for Loominary." + } + ] + }, + { + "id": "msg-2", + "parentId": "msg-1", + "role": "assistant", + "branchId": "main", + "createdAt": "2026-05-07T12:01:00.000Z", + "text": "Use a narrow, provider-agnostic archive contract.", + "content": [ + { + "type": "text", + "text": "Use a narrow, provider-agnostic archive contract." + } + ] + }, + { + "id": "msg-3", + "parentId": "msg-2", + "role": "user", + "branchId": "main", + "createdAt": "2026-05-07T12:02:00.000Z", + "text": "What about project memories?", + "content": [ + { + "type": "text", + "text": "What about project memories?" + } + ] + }, + { + "id": "msg-4", + "parentId": "msg-2", + "role": "assistant", + "branchId": "branch-a", + "createdAt": "2026-05-07T12:03:00.000Z", + "text": "Store project and saved memories in a dedicated context record.", + "content": [ + { + "type": "text", + "text": "Store project and saved memories in a dedicated context record." + } + ] + } + ] +} diff --git a/tests/fixtures/archive-v1/conversations/conv-beta.json b/tests/fixtures/archive-v1/conversations/conv-beta.json new file mode 100644 index 0000000..3f4396e --- /dev/null +++ b/tests/fixtures/archive-v1/conversations/conv-beta.json @@ -0,0 +1,51 @@ +{ + "schemaVersion": "loominary.conversation/v1", + "conversation": { + "id": "conv-beta", + "title": "Search quality backlog", + "platform": "chatgpt", + "provider": "openai", + "providerConversationId": "provider-beta", + "createdAt": "2026-05-06T12:00:00.000Z", + "updatedAt": "2026-05-07T16:00:00.000Z", + "favorite": false + }, + "branches": [ + { + "id": "main", + "rootMessageId": "beta-1", + "leafMessageIds": [ + "beta-2" + ] + } + ], + "messages": [ + { + "id": "beta-1", + "role": "user", + "branchId": "main", + "createdAt": "2026-05-06T12:00:00.000Z", + "text": "Find a better excerpting strategy for conversation search.", + "content": [ + { + "type": "text", + "text": "Find a better excerpting strategy for conversation search." + } + ] + }, + { + "id": "beta-2", + "parentId": "beta-1", + "role": "assistant", + "branchId": "main", + "createdAt": "2026-05-06T12:01:00.000Z", + "text": "Weight title matches and tags higher than raw body hits.", + "content": [ + { + "type": "text", + "text": "Weight title matches and tags higher than raw body hits." + } + ] + } + ] +} diff --git a/tests/fixtures/archive-v1/manifest.json b/tests/fixtures/archive-v1/manifest.json new file mode 100644 index 0000000..af4473d --- /dev/null +++ b/tests/fixtures/archive-v1/manifest.json @@ -0,0 +1,26 @@ +{ + "schemaVersion": "loominary.archive/v1", + "archiveId": "fixture-archive", + "createdAt": "2026-05-08T00:00:00.000Z", + "updatedAt": "2026-05-08T00:00:00.000Z", + "layout": { + "conversationsDir": "conversations", + "contextsDir": "contexts", + "annotationsFile": "annotations.json" + }, + "lifecycle": { + "sourceOfTruth": [ + "manifest.json", + "conversations/*.json", + "contexts/*.json", + "annotations.json" + ], + "derivedIndexes": [ + "rebuild-in-memory" + ] + }, + "trust": { + "defaultBind": "127.0.0.1", + "authMode": "loopback-or-token" + } +} diff --git a/tests/http-service.test.mjs b/tests/http-service.test.mjs new file mode 100644 index 0000000..3cf410b --- /dev/null +++ b/tests/http-service.test.mjs @@ -0,0 +1,96 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import path from 'node:path'; + +import { loadArchive } from '../server/archive/loadArchive.mjs'; +import { createServer } from '../server/service/httpServer.mjs'; + +const fixtureRoot = path.resolve('tests/fixtures/archive-v1'); + +async function startFixtureServer(token = '') { + const archive = await loadArchive(fixtureRoot); + const service = createServer({ + archive, + token, + port: 0 + }); + const address = await service.listen(); + return { + baseUrl: `http://127.0.0.1:${address.port}`, + close: () => service.close() + }; +} + +test('HTTP endpoints list, search, and fetch context', async () => { + const server = await startFixtureServer(); + + try { + const listResponse = await fetch(`${server.baseUrl}/v1/conversations`); + const listPayload = await listResponse.json(); + assert.equal(listPayload.total, 2); + + const searchResponse = await fetch(`${server.baseUrl}/v1/search?q=provider-agnostic`); + const searchPayload = await searchResponse.json(); + assert.equal(searchPayload.items[0].id, 'conv-alpha'); + + const contextResponse = await fetch(`${server.baseUrl}/v1/conversations/conv-alpha/context`); + const contextPayload = await contextResponse.json(); + assert.equal(contextPayload.memories.saved[0].id, 'memory-saved-1'); + } finally { + await server.close(); + } +}); + +test('MCP endpoint exposes tools and serves tool calls', async () => { + const server = await startFixtureServer('secret-token'); + + try { + const toolsResponse = await fetch(`${server.baseUrl}/mcp`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + authorization: 'Bearer secret-token' + }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'tools/list' + }) + }); + const toolsPayload = await toolsResponse.json(); + assert.ok(toolsPayload.result.tools.some(tool => tool.name === 'get_context')); + + const callResponse = await fetch(`${server.baseUrl}/mcp`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + authorization: 'Bearer secret-token' + }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 2, + method: 'tools/call', + params: { + name: 'get_favorites', + arguments: {} + } + }) + }); + const callPayload = await callResponse.json(); + const favorites = JSON.parse(callPayload.result.content[0].text); + assert.equal(favorites[0].id, 'conv-alpha'); + } finally { + await server.close(); + } +}); + +test('token mode rejects unauthenticated requests', async () => { + const server = await startFixtureServer('secret-token'); + + try { + const response = await fetch(`${server.baseUrl}/v1/conversations`); + assert.equal(response.status, 401); + } finally { + await server.close(); + } +});