diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index bfce72e..bda5485 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -30,6 +30,15 @@ jobs: cache: pnpm registry-url: https://registry.npmjs.org + - name: Validate npm publish token + run: | + if [ -z "$NODE_AUTH_TOKEN" ]; then + echo "NPM_TOKEN secret is required for npm publishing" + exit 1 + fi + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + - name: Install dependencies run: pnpm install --frozen-lockfile diff --git a/.gitignore b/.gitignore index eb829d4..82ade11 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,10 @@ node_modules/ .memory/ .ai-memory/ .idea/ +.env +.env.* +!.env.example +!apps/**/.env.example *.log *.sqlite *.sqlite-shm diff --git a/README.md b/README.md index 10d81c9..8c30349 100644 --- a/README.md +++ b/README.md @@ -148,13 +148,24 @@ const model = openAICompatible({ - `@agent-memory/gemini`: Gemini official SDK adapter. - `@agent-memory/xai`: xAI adapter using the documented OpenAI SDK-compatible client path. -## Playground +## Examples ```sh pnpm --filter @agent-memory/playground dev ``` -The playground is private to the repository and is not included in npm packages. +The local playground uses an echo model and local JSON memory. + +For a real OpenAI call with SQLite memory: + +```sh +cp apps/openai-sqlite-demo/.env.example apps/openai-sqlite-demo/.env +pnpm --filter @agent-memory/openai-sqlite-demo dev +``` + +Set `OPENAI_API_KEY` in `apps/openai-sqlite-demo/.env` or in your server environment. The key stays server-side, and memory persists to `.memory/openai-demo.sqlite` by default. + +Both example apps are private to the repository and are not included in npm packages. ## Development diff --git a/apps/openai-sqlite-demo/.env.example b/apps/openai-sqlite-demo/.env.example new file mode 100644 index 0000000..38dd7dd --- /dev/null +++ b/apps/openai-sqlite-demo/.env.example @@ -0,0 +1,5 @@ +OPENAI_API_KEY= +OPENAI_MODEL=gpt-5.4-mini +AGENT_MEMORY_DEMO_SQLITE_PATH=.memory/openai-demo.sqlite +AGENT_MEMORY_DEMO_CONTEXT_BUDGET=1400 +PORT=4318 diff --git a/apps/openai-sqlite-demo/README.md b/apps/openai-sqlite-demo/README.md new file mode 100644 index 0000000..3b0f3e1 --- /dev/null +++ b/apps/openai-sqlite-demo/README.md @@ -0,0 +1,41 @@ +# OpenAI SQLite Demo + +Interactive server-side example that calls OpenAI with `OPENAI_API_KEY` and stores compounding memory in SQLite through `sqliteMemory()`. + +The browser never receives the API key. The server reads `OPENAI_API_KEY`, creates an `openai()` model provider, and persists memory to `.memory/openai-demo.sqlite` by default. + +## Run Locally + +```sh +cp apps/openai-sqlite-demo/.env.example apps/openai-sqlite-demo/.env +pnpm --filter @agent-memory/openai-sqlite-demo dev +``` + +Then open [http://localhost:4318](http://localhost:4318). + +## Interactive Scenarios + +The demo includes four real-life walkthroughs: + +- Customer support: remembers reply tone and refund escalation rules. +- Sales CRM: remembers buyer communication preferences and follow-up constraints. +- Personal assistant: remembers planning style and durable user facts. +- Product ops: remembers launch decisions and operational constraints. + +Each scenario has "Run scenario step" actions that teach memory and a "Try recall" action that asks the model to use what was stored. The response panels show the assistant answer, memories used, memories created, ignored memory candidates, recent events, and a plain-English explanation of how the SDK handled recall and learning. + +Set these values in `apps/openai-sqlite-demo/.env` or in your deployment environment: + +```sh +OPENAI_API_KEY= +OPENAI_MODEL=gpt-5.4-mini +AGENT_MEMORY_DEMO_SQLITE_PATH=.memory/openai-demo.sqlite +AGENT_MEMORY_DEMO_CONTEXT_BUDGET=1400 +PORT=4318 +``` + +## Deployment + +Deploy this demo to a server runtime, not a static-only host. Good targets include a VM, container host, Codespaces-style development environment, or any Node server platform that can set environment variables securely. + +Do not put `OPENAI_API_KEY` in browser code, static hosting config, checked-in files, or client-side environment variables. diff --git a/apps/openai-sqlite-demo/package.json b/apps/openai-sqlite-demo/package.json new file mode 100644 index 0000000..9470c15 --- /dev/null +++ b/apps/openai-sqlite-demo/package.json @@ -0,0 +1,11 @@ +{ + "name": "@agent-memory/openai-sqlite-demo", + "private": true, + "type": "module", + "scripts": { + "dev": "node server.mjs" + }, + "dependencies": { + "agent-memory": "workspace:*" + } +} diff --git a/apps/openai-sqlite-demo/server.mjs b/apps/openai-sqlite-demo/server.mjs new file mode 100644 index 0000000..786992f --- /dev/null +++ b/apps/openai-sqlite-demo/server.mjs @@ -0,0 +1,828 @@ +import { createServer } from "node:http" +import { readFile } from "node:fs/promises" +import { existsSync } from "node:fs" +import { dirname, resolve, relative } from "node:path" +import { fileURLToPath } from "node:url" +import { createAgent, openai, sqliteMemory } from "agent-memory" + +const appDir = dirname(fileURLToPath(import.meta.url)) +const rootDir = resolve(appDir, "../..") + +await loadEnvFile(resolve(rootDir, ".env")) +await loadEnvFile(resolve(appDir, ".env")) + +const port = Number(process.env.PORT ?? 4318) +const model = process.env.OPENAI_MODEL || "gpt-5.4-mini" +const contextBudget = Number(process.env.AGENT_MEMORY_DEMO_CONTEXT_BUDGET ?? 1400) +const memoryPath = resolve(rootDir, process.env.AGENT_MEMORY_DEMO_SQLITE_PATH ?? ".memory/openai-demo.sqlite") +const hasOpenAIKey = () => Boolean(process.env.OPENAI_API_KEY?.trim()) + +const agent = createAgent({ + model: openai(model), + memory: { + store: sqliteMemory({ + path: memoryPath + }), + contextBudget + } +}) + +const scenarios = [ + { + id: "customer-support", + title: "Customer support", + summary: "Teach the agent tone, escalation rules, and customer context.", + scope: { + userId: "support-agent-maya", + orgId: "acme-support", + threadId: "refund-thread", + operationId: "ticket-4482" + }, + steps: [ + { + id: "support-tone", + label: "Remember tone", + message: "Remember that I prefer customer replies that are calm, concise, and include one clear next step." + }, + { + id: "support-escalation", + label: "Remember escalation rule", + message: "Do not promise refunds over $500 without escalating to billing operations." + }, + { + id: "support-recall", + label: "Try recall", + recall: true, + message: "Draft a reply for a customer asking for a $900 refund after a delayed shipment." + } + ] + }, + { + id: "sales-crm", + title: "Sales CRM", + summary: "Show buyer preferences, follow-up constraints, and account context.", + scope: { + userId: "ae-jordan", + orgId: "northstar-sales", + threadId: "atlas-account", + operationId: "q3-renewal" + }, + steps: [ + { + id: "sales-buyer", + label: "Remember buyer", + message: "Remember that Atlas Bank prefers ROI summaries before technical architecture details." + }, + { + id: "sales-follow-up", + label: "Remember follow-up", + message: "Do not schedule follow-ups on Fridays for Atlas Bank." + }, + { + id: "sales-recall", + label: "Try recall", + recall: true, + message: "Write the next follow-up email for Atlas Bank after a pricing call." + } + ] + }, + { + id: "personal-assistant", + title: "Personal assistant", + summary: "Capture schedule style, reporting preferences, and durable personal facts.", + scope: { + userId: "founder-alex", + orgId: "", + threadId: "daily-planning", + operationId: "weekly-review" + }, + steps: [ + { + id: "assistant-style", + label: "Remember style", + message: "Remember that I prefer daily plans grouped by energy level, not by hour." + }, + { + id: "assistant-work", + label: "Remember fact", + message: "Remember that I work at Northstar Labs." + }, + { + id: "assistant-recall", + label: "Try recall", + recall: true, + message: "Create tomorrow's planning brief for me." + } + ] + }, + { + id: "product-ops", + title: "Product ops", + summary: "Track product decisions and constraints across a longer operation.", + scope: { + userId: "pm-rina", + orgId: "agent-memory", + threadId: "demo-product", + operationId: "launch-readiness" + }, + steps: [ + { + id: "ops-decision", + label: "Remember decision", + message: "Remember that the first launch demo should prioritize SQLite memory over Postgres setup." + }, + { + id: "ops-constraint", + label: "Remember constraint", + message: "Do not expose provider API keys in browser code or static deployment settings." + }, + { + id: "ops-recall", + label: "Try recall", + recall: true, + message: "Summarize the launch demo plan and call out the most important constraint." + } + ] + } +] + +createServer(async (req, res) => { + try { + const url = new URL(req.url ?? "/", `http://${req.headers.host}`) + + if (req.method === "GET" && url.pathname === "/") { + return sendHtml(res, page()) + } + + if (req.method === "GET" && url.pathname === "/api/status") { + return sendJson(res, statusPayload()) + } + + if (req.method === "GET" && url.pathname === "/api/scenarios") { + return sendJson(res, { scenarios }) + } + + if (req.method === "GET" && url.pathname === "/api/memory") { + const scope = scopeFromUrl(url) + const [memories, exported] = await Promise.all([ + agent.memory.list({ ...scope, limit: 50 }), + agent.memory.export(scope) + ]) + return sendJson(res, { + memories, + events: recentEvents(exported.events) + }) + } + + if (req.method === "GET" && url.pathname === "/api/export") { + return sendJson(res, await agent.memory.export(scopeFromUrl(url))) + } + + if (req.method === "POST" && url.pathname === "/api/chat") { + if (!hasOpenAIKey()) return sendJson(res, missingKeyPayload(), 400) + + const body = await readJson(req) + const scope = scopeFromBody(body) + const message = String(body.message ?? "").trim() + if (!message) return sendJson(res, { error: "Message is required." }, 400) + + const result = await agent.generate({ + ...scope, + system: systemPrompt(), + messages: [{ role: "user", content: message }], + temperature: numberOrUndefined(body.temperature), + maxTokens: numberOrUndefined(body.maxTokens), + debug: true, + memory: { + recall: body.recall !== false, + learn: body.learn !== false, + contextBudget: numberOrUndefined(body.contextBudget) ?? contextBudget + } + }) + const [memories, exported] = await Promise.all([ + agent.memory.list({ ...scope, limit: 50 }), + agent.memory.export(scope) + ]) + + return sendJson(res, { + text: result.text, + usage: result.usage, + finishReason: result.finishReason, + memory: result.memory, + memories, + events: recentEvents(exported.events), + memoryFlow: buildMemoryFlow({ + body, + scope, + memories, + result, + events: recentEvents(exported.events) + }), + scenarioTimeline: buildScenarioTimeline(body.scenarioId, body.stepId), + explanation: explainInteraction({ + body, + result, + memories, + events: recentEvents(exported.events) + }), + status: statusPayload() + }) + } + + if (req.method === "POST" && url.pathname === "/api/forget") { + const body = await readJson(req) + await agent.memory.forget(scopeFromBody(body)) + return sendJson(res, { ok: true }) + } + + return sendJson(res, { error: "Not found" }, 404) + } catch (error) { + return sendJson(res, { error: error instanceof Error ? error.message : String(error) }, 500) + } +}).listen(port, () => { + console.log(`agent-memory OpenAI SQLite demo: http://localhost:${port}`) +}) + +async function loadEnvFile(path) { + if (!existsSync(path)) return + + const content = await readFile(path, "utf8") + for (const line of content.split(/\r?\n/)) { + const trimmed = line.trim() + if (!trimmed || trimmed.startsWith("#")) continue + + const separator = trimmed.indexOf("=") + if (separator === -1) continue + + const key = trimmed.slice(0, separator).trim() + const value = unquoteEnvValue(trimmed.slice(separator + 1).trim()) + if (key && process.env[key] === undefined) process.env[key] = value + } +} + +function unquoteEnvValue(value) { + if ((value.startsWith("\"") && value.endsWith("\"")) || (value.startsWith("'") && value.endsWith("'"))) { + return value.slice(1, -1) + } + return value +} + +function systemPrompt() { + return [ + "You are the agent-memory OpenAI SQLite demo assistant.", + "Use recalled memory when it is relevant, but do not invent memory.", + "Keep responses concise and mention when a remembered preference changed your answer." + ].join(" ") +} + +function scopeFromUrl(url) { + return { + userId: url.searchParams.get("userId") || undefined, + orgId: url.searchParams.get("orgId") || undefined, + threadId: url.searchParams.get("threadId") || undefined, + operationId: url.searchParams.get("operationId") || undefined + } +} + +function scopeFromBody(body) { + return { + userId: cleanOptionalString(body.userId), + orgId: cleanOptionalString(body.orgId), + threadId: cleanOptionalString(body.threadId), + operationId: cleanOptionalString(body.operationId) + } +} + +function cleanOptionalString(value) { + const text = String(value ?? "").trim() + return text || undefined +} + +function numberOrUndefined(value) { + if (value === undefined || value === null || value === "") return undefined + const number = Number(value) + return Number.isFinite(number) ? number : undefined +} + +async function readJson(req) { + let body = "" + for await (const chunk of req) body += chunk + return body ? JSON.parse(body) : {} +} + +function recentEvents(events) { + return [...events] + .sort((a, b) => String(b.createdAt).localeCompare(String(a.createdAt))) + .slice(0, 20) +} + +function statusPayload() { + return { + hasOpenAIKey: hasOpenAIKey(), + model, + memoryPath: relative(rootDir, memoryPath), + contextBudget + } +} + +function missingKeyPayload() { + return { + error: "OPENAI_API_KEY is not configured.", + setup: "Copy apps/openai-sqlite-demo/.env.example to apps/openai-sqlite-demo/.env and set OPENAI_API_KEY on the server." + } +} + +function buildMemoryFlow(input) { + const recallEnabled = input.body.recall !== false + const learnEnabled = input.body.learn !== false + const used = input.result.memory?.used ?? [] + const created = input.result.memory?.created ?? [] + const ignored = input.result.memory?.ignored ?? [] + + return { + scope: input.scope, + model, + contextBudget: numberOrUndefined(input.body.contextBudget) ?? contextBudget, + recallEnabled, + learnEnabled, + requestStoredAsEvent: true, + assistantReplyStoredAsEvent: true, + memoriesUsed: used, + memoriesCreated: created, + memoriesIgnored: ignored, + activeMemoryCount: input.memories.length, + recentEventCount: input.events.length + } +} + +function buildScenarioTimeline(scenarioId, stepId) { + const scenario = scenarios.find((item) => item.id === scenarioId) + if (!scenario) return [] + + return scenario.steps.map((step, index) => ({ + id: step.id, + label: step.label, + status: step.id === stepId ? "current" : index < scenario.steps.findIndex((item) => item.id === stepId) ? "previous" : "upcoming", + recall: step.recall === true + })) +} + +function explainInteraction(input) { + const used = input.result.memory?.used ?? [] + const created = input.result.memory?.created ?? [] + const ignored = input.result.memory?.ignored ?? [] + + return { + whatHappened: [ + "The server accepted the browser message without exposing the OpenAI API key.", + `agent-memory searched the current scope and injected ${used.length} relevant memories into the model request.`, + "The assistant response was returned and persisted as an event alongside the user message.", + `The deterministic compiler created ${created.length} memories and ignored ${ignored.length} candidates.` + ], + howItWorks: [ + "Scope fields decide which memory buckets can be recalled.", + "Recall controls whether active memories are packed into the model context.", + "Learn controls whether this interaction can create durable memories.", + "SQLite persists events and memory records under the configured server-side database path." + ] + } +} + +function sendJson(res, value, status = 200) { + res.writeHead(status, { "content-type": "application/json" }) + res.end(JSON.stringify(value, null, 2)) +} + +function sendHtml(res, html) { + res.writeHead(200, { "content-type": "text/html; charset=utf-8" }) + res.end(html) +} + +function page() { + return ` + + + + + OpenAI SQLite Memory Demo + + + +
+
+

OpenAI SQLite Memory Demo

+
+ Key missing + model + database +
+
+ +
+
+
+

Real-life scenarios

+
+
+
+ +
+

Scope

+
+ + +
+
+ + +
+
+ + +
+ +
+ + +
+
+ + + +
+
+ + +
+
+
+ +
+

Conversation

+
+ +
+ + +
+
+ +
+

Memory

+
+

Response data

+
+

What happened

+
    +

    How it works

    +
      +

      Debug

      +
      {}
      +
      +
      +
      + + + +` +} diff --git a/packages/openai/src/index.ts b/packages/openai/src/index.ts index ca7def7..bcde0dc 100644 --- a/packages/openai/src/index.ts +++ b/packages/openai/src/index.ts @@ -29,6 +29,7 @@ export function openai( return createOpenAIChatProvider({ idPrefix: "openai", + tokenParameter: "max_completion_tokens", apiKey: process.env.OPENAI_API_KEY, ...config }) @@ -37,11 +38,14 @@ export function openai( export function openAICompatible(config: OpenAICompatibleOptions): ModelProvider { return createOpenAIChatProvider({ idPrefix: "openai-compatible", + tokenParameter: "max_tokens", ...config }) } -function createOpenAIChatProvider(config: OpenAICompatibleOptions & { idPrefix: string }): ModelProvider { +function createOpenAIChatProvider( + config: OpenAICompatibleOptions & { idPrefix: string; tokenParameter: TokenParameter } +): ModelProvider { if (!config.model) { throw new Error(`${config.idPrefix} provider requires a model`) } @@ -67,7 +71,7 @@ function createOpenAIChatProvider(config: OpenAICompatibleOptions & { idPrefix: jsonSchema: true }, async generate(request: ModelRequest): Promise { - const completion = await sdk().chat.completions.create(chatCompletionInput(config.model, request, false)) + const completion = await sdk().chat.completions.create(chatCompletionInput(config, request, false)) const json = completion as OpenAIChatCompletion const choice = json.choices?.[0] return { @@ -80,7 +84,7 @@ function createOpenAIChatProvider(config: OpenAICompatibleOptions & { idPrefix: } }, async *stream(request: ModelRequest): AsyncIterable { - const stream = await sdk().chat.completions.create(chatCompletionInput(config.model, request, true)) + const stream = await sdk().chat.completions.create(chatCompletionInput(config, request, true)) for await (const chunk of stream as AsyncIterable) { const text = chunk.choices?.[0]?.delta?.content @@ -90,17 +94,28 @@ function createOpenAIChatProvider(config: OpenAICompatibleOptions & { idPrefix: }) } -function chatCompletionInput(model: string, request: ModelRequest, stream: boolean): ChatCompletionInput { +function chatCompletionInput( + config: OpenAICompatibleOptions & { tokenParameter: TokenParameter }, + request: ModelRequest, + stream: boolean +): ChatCompletionInput { return { - model, + model: config.model, messages: request.messages.map(openAIMessage), ...(request.tools ? { tools: request.tools } : {}), ...(request.temperature === undefined ? {} : { temperature: request.temperature }), - ...(request.maxTokens === undefined ? {} : { max_tokens: request.maxTokens }), + ...tokenLimit(config.tokenParameter, request.maxTokens), stream } } +function tokenLimit(parameter: TokenParameter, maxTokens: number | undefined): Partial { + if (maxTokens === undefined) return {} + return parameter === "max_completion_tokens" + ? { max_completion_tokens: maxTokens } + : { max_tokens: maxTokens } +} + function openAIMessage(message: AgentMessage): Record { return { role: message.role, @@ -146,5 +161,8 @@ type ChatCompletionInput = { tools?: unknown[] temperature?: number max_tokens?: number + max_completion_tokens?: number stream: boolean } + +type TokenParameter = "max_tokens" | "max_completion_tokens" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bb10104..0e64d22 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -24,6 +24,12 @@ importers: specifier: ^8.48.1 version: 8.61.0(eslint@9.39.4)(typescript@5.9.3) + apps/openai-sqlite-demo: + dependencies: + agent-memory: + specifier: workspace:* + version: link:../../packages/agent-memory + apps/playground: dependencies: agent-memory: diff --git a/test/automation.test.mjs b/test/automation.test.mjs index 24fcda5..32acf86 100644 --- a/test/automation.test.mjs +++ b/test/automation.test.mjs @@ -42,6 +42,8 @@ test("github publish workflow is tag gated and syncs package version from tag", assert.match(workflow, /'v\*'/) assert.match(workflow, /NODE_AUTH_TOKEN/) assert.match(workflow, /NPM_TOKEN/) + assert.match(workflow, /Validate npm publish token/) + assert.match(workflow, /NPM_TOKEN secret is required for npm publishing/) assert.match(workflow, /scripts\/sync-package-version-from-tag\.mjs/) assert.match(workflow, /pnpm build/) assert.match(workflow, /pnpm --filter @agent-memory\/core publish --access public --no-git-checks/) diff --git a/test/openai-sdk-adapter.test.mjs b/test/openai-sdk-adapter.test.mjs index 9afa5fc..a2db94c 100644 --- a/test/openai-sdk-adapter.test.mjs +++ b/test/openai-sdk-adapter.test.mjs @@ -87,3 +87,68 @@ test("openAICompatible streams through an official-SDK-shaped client", async () assert.deepEqual(chunks, ["one", " two"]) }) + +test("openai uses max_completion_tokens for first-party OpenAI chat models", async () => { + const { openai } = await import("../packages/openai/dist/index.js") + const calls = [] + const provider = openai({ + model: "gpt-5.4-mini", + client: { + chat: { + completions: { + async create(input) { + calls.push(input) + return { + choices: [{ + message: { content: "openai response" }, + finish_reason: "stop" + }] + } + } + } + } + } + }) + + await provider.generate({ + messages: [{ role: "user", content: "hello" }], + maxTokens: 128 + }) + + assert.equal(calls.length, 1) + assert.equal(calls[0].max_completion_tokens, 128) + assert.equal("max_tokens" in calls[0], false) +}) + +test("openai streams with max_completion_tokens for first-party OpenAI chat models", async () => { + const { openai } = await import("../packages/openai/dist/index.js") + const calls = [] + const provider = openai({ + model: "gpt-5.4-mini", + client: { + chat: { + completions: { + async create(input) { + calls.push(input) + return (async function * streamChunks() { + yield { choices: [{ delta: { content: "ok" } }] } + })() + } + } + } + } + }) + const chunks = [] + + for await (const chunk of provider.stream({ + messages: [{ role: "user", content: "stream" }], + maxTokens: 96 + })) { + chunks.push(chunk) + } + + assert.deepEqual(chunks, ["ok"]) + assert.equal(calls.length, 1) + assert.equal(calls[0].max_completion_tokens, 96) + assert.equal("max_tokens" in calls[0], false) +}) diff --git a/test/package-boundary.test.mjs b/test/package-boundary.test.mjs index 041498b..46db6d7 100644 --- a/test/package-boundary.test.mjs +++ b/test/package-boundary.test.mjs @@ -21,9 +21,11 @@ async function listFiles(dir) { test("root and playground packages are private", async () => { const rootPackage = await readJson("package.json") const playgroundPackage = await readJson("apps/playground/package.json") + const openAISqliteDemoPackage = await readJson("apps/openai-sqlite-demo/package.json") assert.equal(rootPackage.private, true) assert.equal(playgroundPackage.private, true) + assert.equal(openAISqliteDemoPackage.private, true) }) test("published agent-memory package uses a restrictive files allowlist", async () => { diff --git a/test/project-guidance.test.mjs b/test/project-guidance.test.mjs index c0f77d7..de50f57 100644 --- a/test/project-guidance.test.mjs +++ b/test/project-guidance.test.mjs @@ -37,3 +37,43 @@ test("agent-memory skill captures SDK usage and extension patterns", async () => assert.match(content, /official `openai` SDK/) assert.match(content, /official SDK/) }) + +test("real OpenAI SQLite demo documents server-side setup", async () => { + const readme = await read("apps/openai-sqlite-demo/README.md") + const envExample = await read("apps/openai-sqlite-demo/.env.example") + const server = await read("apps/openai-sqlite-demo/server.mjs") + + assert.match(readme, /OPENAI_API_KEY/) + assert.match(readme, /server-side/) + assert.match(readme, /sqliteMemory/) + assert.match(envExample, /^OPENAI_API_KEY=$/m) + assert.doesNotMatch(envExample, /sk-[A-Za-z0-9]/) + assert.match(server, /process\.env\.OPENAI_API_KEY/) + assert.match(server, /openai\(/) + assert.match(server, /sqliteMemory\(/) + assert.match(server, /hasOpenAIKey/) + assert.doesNotMatch(server.match(/function page\(\) \{[^]*$/)?.[0] ?? "", /OPENAI_API_KEY/) +}) + +test("real OpenAI SQLite demo teaches scenarios and memory flow", async () => { + const readme = await read("apps/openai-sqlite-demo/README.md") + const server = await read("apps/openai-sqlite-demo/server.mjs") + + assert.match(readme, /Customer support/) + assert.match(readme, /Sales CRM/) + assert.match(readme, /Personal assistant/) + assert.match(readme, /Product ops/) + assert.match(server, /const scenarios = \[/) + assert.match(server, /customer-support/) + assert.match(server, /sales-crm/) + assert.match(server, /personal-assistant/) + assert.match(server, /product-ops/) + assert.match(server, /scenarioTimeline/) + assert.match(server, /memoryFlow/) + assert.match(server, /whatHappened/) + assert.match(server, /howItWorks/) + assert.match(server, /memoriesUsed/) + assert.match(server, /memoriesCreated/) + assert.match(server, /Run scenario step/) + assert.match(server, /Try recall/) +}) diff --git a/test/repository-hygiene.test.mjs b/test/repository-hygiene.test.mjs index 4856f07..d73c0e1 100644 --- a/test/repository-hygiene.test.mjs +++ b/test/repository-hygiene.test.mjs @@ -22,6 +22,8 @@ async function listFiles(dir = ".") { .filter((path) => !path.includes("node_modules")) .filter((path) => !path.includes(`${join(".", "dist")}`)) .filter((path) => !path.includes(`${join(".", ".git")}`)) + .filter((path) => !path.endsWith(`${join(".", ".env")}`)) + .filter((path) => !/\.env\.(?!example$)/.test(path)) } test("root README and MIT license are present", async () => { @@ -68,6 +70,10 @@ test("gitignore excludes local IDE project settings", async () => { const gitignore = await read(".gitignore") assert.match(gitignore, /^\.idea\/$/m) + assert.match(gitignore, /^\.env$/m) + assert.match(gitignore, /^\.env\.\*$/m) + assert.match(gitignore, /^!\.env\.example$/m) + assert.match(gitignore, /^!apps\/\*\*\/\.env\.example$/m) }) test("public repo files do not mention assistant-specific tooling", async () => {