Compare commits
5 Commits
202d9316a5
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 4baf056cd9 | |||
| baea23b8b0 | |||
| 684b6af5be | |||
| 4bcd80c33d | |||
| 6eb6024e51 |
116
.gitea/workflows/npm-release.yml
Normal file
116
.gitea/workflows/npm-release.yml
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
name: npm release
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- 'v*'
|
||||||
|
- '[0-9]*'
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
shell: bash
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
verify:
|
||||||
|
name: verify
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
container:
|
||||||
|
image: node:20-bookworm
|
||||||
|
timeout-minutes: 30
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Install release tools
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
apt-get update
|
||||||
|
apt-get install -y git curl ca-certificates
|
||||||
|
curl -fsSL https://bun.sh/install | bash -s -- bun-v1.3.13
|
||||||
|
install -m 0755 /root/.bun/bin/bun /usr/local/bin/bun
|
||||||
|
node --version
|
||||||
|
npm --version
|
||||||
|
bun --version
|
||||||
|
|
||||||
|
- name: Clone tagged source
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
REPO_URL="${{ gitea.server_url }}/${{ gitea.repository }}.git"
|
||||||
|
AUTH_HEADER="$(printf '%s' '${{ gitea.actor }}:${{ secrets.GITEA_TOKEN }}' | base64 -w0)"
|
||||||
|
git -c http.extraHeader="Authorization: Basic $AUTH_HEADER" clone --depth 1 --branch "${{ gitea.ref_name }}" "$REPO_URL" repo
|
||||||
|
git -C repo rev-parse HEAD
|
||||||
|
|
||||||
|
- name: Verify release tag matches package version
|
||||||
|
working-directory: repo
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
TAG_NAME="${{ gitea.ref_name }}"
|
||||||
|
PACKAGE_VERSION="$(node -p "require('./package.json').version")"
|
||||||
|
|
||||||
|
if [ "$TAG_NAME" = "v$PACKAGE_VERSION" ] || [ "$TAG_NAME" = "$PACKAGE_VERSION" ]; then
|
||||||
|
echo "Release tag $TAG_NAME matches package version $PACKAGE_VERSION"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Tag $TAG_NAME does not match package.json version $PACKAGE_VERSION" >&2
|
||||||
|
exit 1
|
||||||
|
|
||||||
|
- name: Run verify pipeline
|
||||||
|
working-directory: repo
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
bun install --frozen-lockfile
|
||||||
|
bun run test
|
||||||
|
bun run check
|
||||||
|
bun run build
|
||||||
|
|
||||||
|
release:
|
||||||
|
name: publish to npm
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
container:
|
||||||
|
image: node:20-bookworm
|
||||||
|
timeout-minutes: 30
|
||||||
|
needs:
|
||||||
|
- verify
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Install release tools
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
apt-get update
|
||||||
|
apt-get install -y git curl ca-certificates
|
||||||
|
curl -fsSL https://bun.sh/install | bash -s -- bun-v1.3.13
|
||||||
|
install -m 0755 /root/.bun/bin/bun /usr/local/bin/bun
|
||||||
|
node --version
|
||||||
|
npm --version
|
||||||
|
bun --version
|
||||||
|
|
||||||
|
- name: Clone tagged source
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
REPO_URL="${{ gitea.server_url }}/${{ gitea.repository }}.git"
|
||||||
|
AUTH_HEADER="$(printf '%s' '${{ gitea.actor }}:${{ secrets.GITEA_TOKEN }}' | base64 -w0)"
|
||||||
|
git -c http.extraHeader="Authorization: Basic $AUTH_HEADER" clone --depth 1 --branch "${{ gitea.ref_name }}" "$REPO_URL" repo
|
||||||
|
git -C repo rev-parse HEAD
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
working-directory: repo
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
bun install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Build package
|
||||||
|
working-directory: repo
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
bun run build
|
||||||
|
|
||||||
|
- name: Publish package to npm
|
||||||
|
working-directory: repo
|
||||||
|
env:
|
||||||
|
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
printf '//registry.npmjs.org/:_authToken=%s\n' "$NODE_AUTH_TOKEN" > ~/.npmrc
|
||||||
|
npm publish
|
||||||
12
CHANGELOG.md
Normal file
12
CHANGELOG.md
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
## 0.1.1
|
||||||
|
|
||||||
|
- add an optional two-stage conversation memory pipeline with classifier and extractor models
|
||||||
|
- store approved inbound and first-pass outbound memories back into the persona IdentityDB space with turn-trace metadata
|
||||||
|
- skip re-persisting exact duplicate extracted memories by domain and statement
|
||||||
|
- expose the new conversation memory pipeline through the public `conversation` module exports
|
||||||
|
|
||||||
|
## 0.1.0
|
||||||
|
|
||||||
|
- initial public BoxBrain framework release
|
||||||
32
README.md
32
README.md
@@ -16,6 +16,7 @@ The project is framework-first rather than product-first. The current core libra
|
|||||||
- availability snapshots with current + next transition calculation
|
- availability snapshots with current + next transition calculation
|
||||||
- DM-style conversation orchestration for inbound replies and proactive openings
|
- DM-style conversation orchestration for inbound replies and proactive openings
|
||||||
- delegated mandatory/contextual memory retrieval pipelines for conversation turns
|
- delegated mandatory/contextual memory retrieval pipelines for conversation turns
|
||||||
|
- optional two-stage conversation memory extraction pipeline for durable inbound/outbound memories
|
||||||
- human-like first-reply delay and typing delay utilities
|
- human-like first-reply delay and typing delay utilities
|
||||||
- farewell-style refusal flows that can trigger availability-changing tool calls
|
- farewell-style refusal flows that can trigger availability-changing tool calls
|
||||||
|
|
||||||
@@ -34,6 +35,37 @@ bun run check
|
|||||||
bun run build
|
bun run build
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Source layout
|
||||||
|
|
||||||
|
The library is now grouped by domain under `src/`:
|
||||||
|
|
||||||
|
- `src/core/` — shared adapter, type, and IdentityDB helper contracts
|
||||||
|
- `src/persona/` — persona initialization service
|
||||||
|
- `src/schedule/` — schedule generation and pruning service
|
||||||
|
- `src/availability/` — availability state service
|
||||||
|
- `src/conversation/` — DM turn orchestration service
|
||||||
|
- `src/memory/` — fact-draft persistence service
|
||||||
|
- `src/timing/` — typing/reply timing profile helpers
|
||||||
|
- `src/providers/grok/` — Grok API client and adapter bundle
|
||||||
|
|
||||||
|
Each domain now exposes a class-based service API in addition to the existing functional helpers so consumers can organize stateful integrations more cleanly.
|
||||||
|
|
||||||
|
## Conversation memory pipeline
|
||||||
|
|
||||||
|
Conversation turns can now optionally run a two-stage durable-memory pipeline:
|
||||||
|
|
||||||
|
1. a `classifierModel` decides whether each inbound or first-pass outbound message is worth remembering
|
||||||
|
2. an `extractorModel` converts only approved messages into IdentityDB-ready fact drafts
|
||||||
|
3. extracted facts are stored back into the persona space with conversation-turn trace metadata
|
||||||
|
|
||||||
|
The optional `memoryPipeline` input is available on both `replyToConversation(...)` and `startConversation(...)`, so app integrations can enable long-term relationship memory without changing their storage layer.
|
||||||
|
|
||||||
|
## Release
|
||||||
|
|
||||||
|
Tagging `vX.Y.Z` or `X.Y.Z` triggers the Gitea npm release workflow under `.gitea/workflows/npm-release.yml`.
|
||||||
|
|
||||||
|
BoxBrain now consumes the published `identitydb` package from npm at version `0.2.0`, and `trustedDependencies` keeps Bun lifecycle scripts enabled for `better-sqlite3` and `esbuild` during clean installs.
|
||||||
|
|
||||||
## Current status
|
## Current status
|
||||||
|
|
||||||
The repository now contains the framework core for persona initialization, schedule/status management, conversation orchestration, and a ready-made Grok adapter set. See the implementation plan:
|
The repository now contains the framework core for persona initialization, schedule/status management, conversation orchestration, and a ready-made Grok adapter set. See the implementation plan:
|
||||||
|
|||||||
14
bun.lock
14
bun.lock
@@ -5,7 +5,7 @@
|
|||||||
"": {
|
"": {
|
||||||
"name": "boxbrain",
|
"name": "boxbrain",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"identitydb": "file:../IdentityDB",
|
"identitydb": "0.2.0",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^24.0.0",
|
"@types/node": "^24.0.0",
|
||||||
@@ -15,6 +15,10 @@
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
"trustedDependencies": [
|
||||||
|
"esbuild",
|
||||||
|
"better-sqlite3",
|
||||||
|
],
|
||||||
"packages": {
|
"packages": {
|
||||||
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.7", "", { "os": "aix", "cpu": "ppc64" }, "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg=="],
|
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.7", "", { "os": "aix", "cpu": "ppc64" }, "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg=="],
|
||||||
|
|
||||||
@@ -126,8 +130,6 @@
|
|||||||
|
|
||||||
"@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.60.3", "", { "os": "win32", "cpu": "x64" }, "sha512-hPt/bgL5cE+Qp+/TPHBqptcAgPzgj46mPcg/16zNUmbQk0j+mOEQV/+Lqu8QRtDV3Ek95Q6FeFITpuhl6OTsAA=="],
|
"@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.60.3", "", { "os": "win32", "cpu": "x64" }, "sha512-hPt/bgL5cE+Qp+/TPHBqptcAgPzgj46mPcg/16zNUmbQk0j+mOEQV/+Lqu8QRtDV3Ek95Q6FeFITpuhl6OTsAA=="],
|
||||||
|
|
||||||
"@types/better-sqlite3": ["@types/better-sqlite3@7.6.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA=="],
|
|
||||||
|
|
||||||
"@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="],
|
"@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="],
|
||||||
|
|
||||||
"@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="],
|
"@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="],
|
||||||
@@ -136,8 +138,6 @@
|
|||||||
|
|
||||||
"@types/node": ["@types/node@24.12.3", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-8oljBDGun9cIsZRJR6fkihn0TSXJI0UDOOhncYaERq6M0JMDoPLxyscwruJcb4GKS6dvK/d8xebYBg27h/duaQ=="],
|
"@types/node": ["@types/node@24.12.3", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-8oljBDGun9cIsZRJR6fkihn0TSXJI0UDOOhncYaERq6M0JMDoPLxyscwruJcb4GKS6dvK/d8xebYBg27h/duaQ=="],
|
||||||
|
|
||||||
"@types/pg": ["@types/pg@8.20.0", "", { "dependencies": { "@types/node": "*", "pg-protocol": "*", "pg-types": "^2.2.0" } }, "sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow=="],
|
|
||||||
|
|
||||||
"@vitest/expect": ["@vitest/expect@3.2.4", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/spy": "3.2.4", "@vitest/utils": "3.2.4", "chai": "^5.2.0", "tinyrainbow": "^2.0.0" } }, "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig=="],
|
"@vitest/expect": ["@vitest/expect@3.2.4", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/spy": "3.2.4", "@vitest/utils": "3.2.4", "chai": "^5.2.0", "tinyrainbow": "^2.0.0" } }, "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig=="],
|
||||||
|
|
||||||
"@vitest/mocker": ["@vitest/mocker@3.2.4", "", { "dependencies": { "@vitest/spy": "3.2.4", "estree-walker": "^3.0.3", "magic-string": "^0.30.17" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" }, "optionalPeers": ["msw", "vite"] }, "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ=="],
|
"@vitest/mocker": ["@vitest/mocker@3.2.4", "", { "dependencies": { "@vitest/spy": "3.2.4", "estree-walker": "^3.0.3", "magic-string": "^0.30.17" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" }, "optionalPeers": ["msw", "vite"] }, "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ=="],
|
||||||
@@ -228,7 +228,7 @@
|
|||||||
|
|
||||||
"iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="],
|
"iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="],
|
||||||
|
|
||||||
"identitydb": ["identitydb@file:../IdentityDB", { "dependencies": { "better-sqlite3": "^12.1.1", "kysely": "^0.28.8", "mysql2": "^3.15.3", "pg": "^8.16.0" }, "devDependencies": { "@types/better-sqlite3": "^7.6.13", "@types/node": "^24.0.0", "@types/pg": "^8.20.0", "tsup": "^8.5.0", "typescript": "^5.8.3", "vitest": "^3.2.4" } }],
|
"identitydb": ["identitydb@0.2.0", "", { "dependencies": { "better-sqlite3": "^12.1.1", "kysely": "^0.28.8", "mysql2": "^3.15.3", "pg": "^8.16.0" } }, "sha512-dXLueo2zx3Lki6R4QJJhMYYGK6jpFecXj8K16AR3Tyq/udH/jw5qAl+s6JPKFPrj24BNl5yAA6CSXS4qFORpQA=="],
|
||||||
|
|
||||||
"ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],
|
"ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],
|
||||||
|
|
||||||
@@ -417,7 +417,5 @@
|
|||||||
"wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="],
|
"wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="],
|
||||||
|
|
||||||
"xtend": ["xtend@4.0.2", "", {}, "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="],
|
"xtend": ["xtend@4.0.2", "", {}, "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="],
|
||||||
|
|
||||||
"estree-walker/@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="],
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
11
package.json
11
package.json
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "boxbrain",
|
"name": "boxbrain",
|
||||||
"version": "0.1.0",
|
"version": "0.1.1",
|
||||||
"description": "IdentityDB-backed framework for synthetic human-like personas and DM-style LLM harnesses.",
|
"description": "IdentityDB-backed framework for synthetic human-like personas and DM-style LLM harnesses.",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
@@ -16,6 +16,7 @@
|
|||||||
"files": [
|
"files": [
|
||||||
"dist",
|
"dist",
|
||||||
"README.md",
|
"README.md",
|
||||||
|
"CHANGELOG.md",
|
||||||
"LICENSE"
|
"LICENSE"
|
||||||
],
|
],
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -37,12 +38,16 @@
|
|||||||
"simulation"
|
"simulation"
|
||||||
],
|
],
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"identitydb": "file:../IdentityDB"
|
"identitydb": "0.2.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^24.0.0",
|
"@types/node": "^24.0.0",
|
||||||
"tsup": "^8.5.0",
|
"tsup": "^8.5.0",
|
||||||
"typescript": "^5.8.3",
|
"typescript": "^5.8.3",
|
||||||
"vitest": "^3.2.4"
|
"vitest": "^3.2.4"
|
||||||
}
|
},
|
||||||
|
"trustedDependencies": [
|
||||||
|
"better-sqlite3",
|
||||||
|
"esbuild"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
import type { Fact, IdentityDB, JsonValue } from 'identitydb';
|
import type { Fact, IdentityDB, JsonValue } from 'identitydb';
|
||||||
import { dateOnly, getFactDomain, getJsonObject, jsonObject, listFactsInSpace } from './facts';
|
import { dateOnly, getFactDomain, getJsonObject, jsonObject, listFactsInSpace } from '../core/facts';
|
||||||
import { persistFactDrafts } from './memory';
|
import { persistFactDrafts } from '../memory';
|
||||||
import type {
|
import type {
|
||||||
BoxBrainAvailabilityEntry,
|
BoxBrainAvailabilityEntry,
|
||||||
BoxBrainAvailabilityMode,
|
BoxBrainAvailabilityMode,
|
||||||
BoxBrainAvailabilitySnapshot,
|
BoxBrainAvailabilitySnapshot,
|
||||||
BoxBrainAvailabilitySourceType,
|
BoxBrainAvailabilitySourceType,
|
||||||
} from './types';
|
} from '../core/types';
|
||||||
|
|
||||||
export interface SetAvailabilityStatusInput {
|
export interface SetAvailabilityStatusInput {
|
||||||
spaceName: string;
|
spaceName: string;
|
||||||
@@ -35,7 +35,27 @@ const EXPLICIT_SOURCE_PRIORITY: Record<Exclude<BoxBrainAvailabilitySourceType, '
|
|||||||
tool: 3,
|
tool: 3,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export class AvailabilityService {
|
||||||
|
constructor(private readonly db: IdentityDB) {}
|
||||||
|
|
||||||
|
async setStatus(input: SetAvailabilityStatusInput): Promise<BoxBrainAvailabilityEntry> {
|
||||||
|
return setAvailabilityStatusWithDb(this.db, input);
|
||||||
|
}
|
||||||
|
|
||||||
|
async listEntries(input: ListAvailabilityEntriesInput): Promise<BoxBrainAvailabilityEntry[]> {
|
||||||
|
return listAvailabilityEntriesWithDb(this.db, input);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getSnapshot(input: GetAvailabilitySnapshotInput): Promise<BoxBrainAvailabilitySnapshot> {
|
||||||
|
return getAvailabilitySnapshotWithDb(this.db, input);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function setAvailabilityStatus(db: IdentityDB, input: SetAvailabilityStatusInput): Promise<BoxBrainAvailabilityEntry> {
|
export async function setAvailabilityStatus(db: IdentityDB, input: SetAvailabilityStatusInput): Promise<BoxBrainAvailabilityEntry> {
|
||||||
|
return new AvailabilityService(db).setStatus(input);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setAvailabilityStatusWithDb(db: IdentityDB, input: SetAvailabilityStatusInput): Promise<BoxBrainAvailabilityEntry> {
|
||||||
assertAvailabilityMode(input.mode);
|
assertAvailabilityMode(input.mode);
|
||||||
assertChronology(input.effectiveFrom, input.until);
|
assertChronology(input.effectiveFrom, input.until);
|
||||||
|
|
||||||
@@ -80,6 +100,13 @@ export async function setAvailabilityStatus(db: IdentityDB, input: SetAvailabili
|
|||||||
export async function listAvailabilityEntries(
|
export async function listAvailabilityEntries(
|
||||||
db: IdentityDB,
|
db: IdentityDB,
|
||||||
input: ListAvailabilityEntriesInput,
|
input: ListAvailabilityEntriesInput,
|
||||||
|
): Promise<BoxBrainAvailabilityEntry[]> {
|
||||||
|
return new AvailabilityService(db).listEntries(input);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listAvailabilityEntriesWithDb(
|
||||||
|
db: IdentityDB,
|
||||||
|
input: ListAvailabilityEntriesInput,
|
||||||
): Promise<BoxBrainAvailabilityEntry[]> {
|
): Promise<BoxBrainAvailabilityEntry[]> {
|
||||||
const facts = await listFactsInSpace(db, input.spaceName);
|
const facts = await listFactsInSpace(db, input.spaceName);
|
||||||
const deletedScheduleEventIds = collectDeletedScheduleEventIds(facts);
|
const deletedScheduleEventIds = collectDeletedScheduleEventIds(facts);
|
||||||
@@ -94,6 +121,13 @@ export async function listAvailabilityEntries(
|
|||||||
export async function getAvailabilitySnapshot(
|
export async function getAvailabilitySnapshot(
|
||||||
db: IdentityDB,
|
db: IdentityDB,
|
||||||
input: GetAvailabilitySnapshotInput,
|
input: GetAvailabilitySnapshotInput,
|
||||||
|
): Promise<BoxBrainAvailabilitySnapshot> {
|
||||||
|
return new AvailabilityService(db).getSnapshot(input);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getAvailabilitySnapshotWithDb(
|
||||||
|
db: IdentityDB,
|
||||||
|
input: GetAvailabilitySnapshotInput,
|
||||||
): Promise<BoxBrainAvailabilitySnapshot> {
|
): Promise<BoxBrainAvailabilitySnapshot> {
|
||||||
const entries = await listAvailabilityEntries(db, { spaceName: input.spaceName });
|
const entries = await listAvailabilityEntries(db, { spaceName: input.spaceName });
|
||||||
const current = selectAvailabilityAt(entries, input.at) ?? createDefaultOnlineAvailability(input.at);
|
const current = selectAvailabilityAt(entries, input.at) ?? createDefaultOnlineAvailability(input.at);
|
||||||
@@ -1,23 +1,44 @@
|
|||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
import type { Fact, IdentityDB, JsonValue } from 'identitydb';
|
import type { Fact, IdentityDB, JsonValue } from 'identitydb';
|
||||||
import type { StructuredModelAdapter } from './adapters';
|
import type { StructuredModelAdapter } from '../core/adapters';
|
||||||
import { getAvailabilitySnapshot, setAvailabilityStatus } from './availability';
|
import { getAvailabilitySnapshot, setAvailabilityStatus } from '../availability';
|
||||||
import { dateOnly, getFactDomain, getJsonObject, jsonObject, listFactsInSpace, resolvePersonaProfile, shiftIsoDate } from './facts';
|
import { dateOnly, getFactDomain, getJsonObject, jsonObject, listFactsInSpace, resolvePersonaProfile, shiftIsoDate } from '../core/facts';
|
||||||
import { persistFactDrafts } from './memory';
|
import { persistFactDrafts } from '../memory';
|
||||||
import { createReplyDelay, createTypingDelay } from './timing';
|
import { createReplyDelay, createTypingDelay } from '../timing';
|
||||||
import type {
|
import type {
|
||||||
BoxBrainAvailabilityMode,
|
BoxBrainAvailabilityMode,
|
||||||
BoxBrainConversationDirection,
|
BoxBrainConversationDirection,
|
||||||
BoxBrainConversationEntry,
|
BoxBrainConversationEntry,
|
||||||
|
BoxBrainFactDomain,
|
||||||
|
BoxBrainFactDraft,
|
||||||
BoxBrainMemoryReference,
|
BoxBrainMemoryReference,
|
||||||
BoxBrainMessage,
|
BoxBrainMessage,
|
||||||
BoxBrainToolCall,
|
BoxBrainToolCall,
|
||||||
} from './types';
|
} from '../core/types';
|
||||||
|
|
||||||
export interface ConversationMemorySelectionResult {
|
export interface ConversationMemorySelectionResult {
|
||||||
memoryIds: string[];
|
memoryIds: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ConversationMemoryClassificationResult {
|
||||||
|
shouldRemember: boolean;
|
||||||
|
reason?: string | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConversationMemoryExtractedFactDraft extends BoxBrainFactDraft {
|
||||||
|
domain?: BoxBrainFactDomain | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConversationMemoryExtractionResult {
|
||||||
|
facts: ConversationMemoryExtractedFactDraft[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConversationMemoryPipelineOptions {
|
||||||
|
classifierModel: StructuredModelAdapter;
|
||||||
|
extractorModel: StructuredModelAdapter;
|
||||||
|
source?: string | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
export interface SetAvailabilityToolArguments extends Record<string, JsonValue> {
|
export interface SetAvailabilityToolArguments extends Record<string, JsonValue> {
|
||||||
mode: BoxBrainAvailabilityMode;
|
mode: BoxBrainAvailabilityMode;
|
||||||
reason?: string;
|
reason?: string;
|
||||||
@@ -43,6 +64,7 @@ interface BaseConversationInput {
|
|||||||
mandatoryMemoryModel: StructuredModelAdapter;
|
mandatoryMemoryModel: StructuredModelAdapter;
|
||||||
contextualMemoryModel: StructuredModelAdapter;
|
contextualMemoryModel: StructuredModelAdapter;
|
||||||
responseModel: StructuredModelAdapter;
|
responseModel: StructuredModelAdapter;
|
||||||
|
memoryPipeline?: ConversationMemoryPipelineOptions | undefined;
|
||||||
rng?: (() => number) | undefined;
|
rng?: (() => number) | undefined;
|
||||||
activeExchangeWindowSeconds?: number | undefined;
|
activeExchangeWindowSeconds?: number | undefined;
|
||||||
isFirstReplyInExchange?: boolean | undefined;
|
isFirstReplyInExchange?: boolean | undefined;
|
||||||
@@ -70,7 +92,27 @@ export interface ListConversationEntriesInput {
|
|||||||
until?: string | undefined;
|
until?: string | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class ConversationService {
|
||||||
|
constructor(private readonly db: IdentityDB) {}
|
||||||
|
|
||||||
|
async reply(input: ReplyToConversationInput): Promise<ConversationTurnResult> {
|
||||||
|
return replyToConversationWithDb(this.db, input);
|
||||||
|
}
|
||||||
|
|
||||||
|
async start(input: StartConversationInput): Promise<ConversationTurnResult> {
|
||||||
|
return startConversationWithDb(this.db, input);
|
||||||
|
}
|
||||||
|
|
||||||
|
async listEntries(input: ListConversationEntriesInput): Promise<BoxBrainConversationEntry[]> {
|
||||||
|
return listConversationEntriesWithDb(this.db, input);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function replyToConversation(db: IdentityDB, input: ReplyToConversationInput): Promise<ConversationTurnResult> {
|
export async function replyToConversation(db: IdentityDB, input: ReplyToConversationInput): Promise<ConversationTurnResult> {
|
||||||
|
return new ConversationService(db).reply(input);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function replyToConversationWithDb(db: IdentityDB, input: ReplyToConversationInput): Promise<ConversationTurnResult> {
|
||||||
const turnId = randomUUID();
|
const turnId = randomUUID();
|
||||||
await persistConversationEntry(db, {
|
await persistConversationEntry(db, {
|
||||||
spaceName: input.spaceName,
|
spaceName: input.spaceName,
|
||||||
@@ -93,6 +135,10 @@ export async function replyToConversation(db: IdentityDB, input: ReplyToConversa
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function startConversation(db: IdentityDB, input: StartConversationInput): Promise<ConversationTurnResult> {
|
export async function startConversation(db: IdentityDB, input: StartConversationInput): Promise<ConversationTurnResult> {
|
||||||
|
return new ConversationService(db).start(input);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startConversationWithDb(db: IdentityDB, input: StartConversationInput): Promise<ConversationTurnResult> {
|
||||||
return generateConversationTurn(db, {
|
return generateConversationTurn(db, {
|
||||||
...input,
|
...input,
|
||||||
proactive: true,
|
proactive: true,
|
||||||
@@ -103,6 +149,13 @@ export async function startConversation(db: IdentityDB, input: StartConversation
|
|||||||
export async function listConversationEntries(
|
export async function listConversationEntries(
|
||||||
db: IdentityDB,
|
db: IdentityDB,
|
||||||
input: ListConversationEntriesInput,
|
input: ListConversationEntriesInput,
|
||||||
|
): Promise<BoxBrainConversationEntry[]> {
|
||||||
|
return new ConversationService(db).listEntries(input);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listConversationEntriesWithDb(
|
||||||
|
db: IdentityDB,
|
||||||
|
input: ListConversationEntriesInput,
|
||||||
): Promise<BoxBrainConversationEntry[]> {
|
): Promise<BoxBrainConversationEntry[]> {
|
||||||
const facts = await listFactsInSpace(db, input.spaceName);
|
const facts = await listFactsInSpace(db, input.spaceName);
|
||||||
return facts
|
return facts
|
||||||
@@ -134,7 +187,14 @@ async function generateConversationTurn(
|
|||||||
spaceName: input.spaceName,
|
spaceName: input.spaceName,
|
||||||
at: input.currentTime,
|
at: input.currentTime,
|
||||||
});
|
});
|
||||||
|
const persona = await resolvePersonaProfile(db, input.spaceName);
|
||||||
|
const memoryCandidates: ConversationMemoryCandidate[] = [];
|
||||||
|
if (input.inboundMessage) {
|
||||||
|
memoryCandidates.push(createConversationMemoryCandidate(input, 'inbound', input.inboundMessage));
|
||||||
|
}
|
||||||
|
|
||||||
if (availability.current.mode === 'offline') {
|
if (availability.current.mode === 'offline') {
|
||||||
|
await maybePersistConversationMemories(db, input.spaceName, persona.displayName, memoryCandidates, input.memoryPipeline);
|
||||||
return {
|
return {
|
||||||
blocked: true,
|
blocked: true,
|
||||||
blockedReason: availability.current.reason,
|
blockedReason: availability.current.reason,
|
||||||
@@ -145,7 +205,6 @@ async function generateConversationTurn(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const persona = await resolvePersonaProfile(db, input.spaceName);
|
|
||||||
const candidateMemories = await buildMemoryCandidates(db, input.spaceName, input.counterpartId, input.counterpartDisplayName, input.currentTime);
|
const candidateMemories = await buildMemoryCandidates(db, input.spaceName, input.counterpartId, input.counterpartDisplayName, input.currentTime);
|
||||||
const candidateMap = new Map<string, BoxBrainMemoryReference>(
|
const candidateMap = new Map<string, BoxBrainMemoryReference>(
|
||||||
candidateMemories.map((memory, index) => [`m${index + 1}`, memory]),
|
candidateMemories.map((memory, index) => [`m${index + 1}`, memory]),
|
||||||
@@ -197,6 +256,7 @@ async function generateConversationTurn(
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (replyDelaySeconds === null) {
|
if (replyDelaySeconds === null) {
|
||||||
|
await maybePersistConversationMemories(db, input.spaceName, persona.displayName, memoryCandidates, input.memoryPipeline);
|
||||||
return {
|
return {
|
||||||
blocked: true,
|
blocked: true,
|
||||||
blockedReason: availability.current.reason,
|
blockedReason: availability.current.reason,
|
||||||
@@ -230,8 +290,10 @@ async function generateConversationTurn(
|
|||||||
turnId: input.turnId,
|
turnId: input.turnId,
|
||||||
source: `${input.responseModel.provider}:${input.responseModel.model}`,
|
source: `${input.responseModel.provider}:${input.responseModel.model}`,
|
||||||
});
|
});
|
||||||
|
memoryCandidates.push(createConversationMemoryCandidate(input, 'outbound', message.text));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await maybePersistConversationMemories(db, input.spaceName, persona.displayName, memoryCandidates, input.memoryPipeline);
|
||||||
const toolCallsExecuted = await executeToolCalls(db, input.spaceName, input.currentTime, plan.toolCalls ?? []);
|
const toolCallsExecuted = await executeToolCalls(db, input.spaceName, input.currentTime, plan.toolCalls ?? []);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -364,6 +426,198 @@ function buildConversationPrompt(
|
|||||||
.join('\n');
|
.join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ConversationMemoryCandidate {
|
||||||
|
turnId: string;
|
||||||
|
direction: BoxBrainConversationDirection;
|
||||||
|
text: string;
|
||||||
|
occurredAt: string;
|
||||||
|
counterpartId: string;
|
||||||
|
counterpartDisplayName?: string | undefined;
|
||||||
|
proactive: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createConversationMemoryCandidate(
|
||||||
|
input: BaseConversationInput & { proactive: boolean; turnId: string },
|
||||||
|
direction: BoxBrainConversationDirection,
|
||||||
|
text: string,
|
||||||
|
): ConversationMemoryCandidate {
|
||||||
|
return {
|
||||||
|
turnId: input.turnId,
|
||||||
|
direction,
|
||||||
|
text,
|
||||||
|
occurredAt: input.currentTime,
|
||||||
|
counterpartId: input.counterpartId,
|
||||||
|
counterpartDisplayName: input.counterpartDisplayName,
|
||||||
|
proactive: input.proactive,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function maybePersistConversationMemories(
|
||||||
|
db: IdentityDB,
|
||||||
|
spaceName: string,
|
||||||
|
personaDisplayName: string,
|
||||||
|
candidates: ConversationMemoryCandidate[],
|
||||||
|
pipeline?: ConversationMemoryPipelineOptions,
|
||||||
|
): Promise<void> {
|
||||||
|
if (!pipeline || candidates.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingFacts = await listFactsInSpace(db, spaceName);
|
||||||
|
const dedupeKeys = new Set(
|
||||||
|
existingFacts
|
||||||
|
.map((fact) => buildConversationMemoryDedupKey(getFactDomain(fact), fact.statement))
|
||||||
|
.filter((key): key is string => Boolean(key)),
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
const classification = assertConversationMemoryClassificationResult(
|
||||||
|
await pipeline.classifierModel.generateObject<ConversationMemoryClassificationResult>({
|
||||||
|
prompt: buildConversationMemoryClassificationPrompt(personaDisplayName, candidate),
|
||||||
|
schema: { type: 'object', required: ['shouldRemember'] },
|
||||||
|
metadata: {
|
||||||
|
boxbrainTask: 'persona.conversation.classify_memory',
|
||||||
|
counterpartId: candidate.counterpartId,
|
||||||
|
direction: candidate.direction,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!classification.shouldRemember) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const extraction = assertConversationMemoryExtractionResult(
|
||||||
|
await pipeline.extractorModel.generateObject<ConversationMemoryExtractionResult>({
|
||||||
|
prompt: buildConversationMemoryExtractionPrompt(personaDisplayName, candidate, classification.reason),
|
||||||
|
schema: { type: 'object', required: ['facts'] },
|
||||||
|
metadata: {
|
||||||
|
boxbrainTask: 'persona.conversation.extract_memory',
|
||||||
|
counterpartId: candidate.counterpartId,
|
||||||
|
direction: candidate.direction,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const factsByDomain = new Map<BoxBrainFactDomain, BoxBrainFactDraft[]>();
|
||||||
|
for (const extractedFact of extraction.facts) {
|
||||||
|
const domain = normalizeConversationMemoryDomain(extractedFact.domain, candidate.direction);
|
||||||
|
const dedupeKey = buildConversationMemoryDedupKey(domain, extractedFact.statement);
|
||||||
|
if (dedupeKeys.has(dedupeKey)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
dedupeKeys.add(dedupeKey);
|
||||||
|
|
||||||
|
const draft = toConversationMemoryDraft(extractedFact, candidate, classification.reason);
|
||||||
|
const existingDrafts = factsByDomain.get(domain) ?? [];
|
||||||
|
existingDrafts.push(draft);
|
||||||
|
factsByDomain.set(domain, existingDrafts);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [domain, facts] of Array.from(factsByDomain.entries())) {
|
||||||
|
await persistFactDrafts(db, {
|
||||||
|
spaceName,
|
||||||
|
domain,
|
||||||
|
source: pipeline.source ?? `${pipeline.extractorModel.provider}:${pipeline.extractorModel.model}`,
|
||||||
|
facts,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildConversationMemoryClassificationPrompt(
|
||||||
|
personaDisplayName: string,
|
||||||
|
candidate: ConversationMemoryCandidate,
|
||||||
|
): string {
|
||||||
|
return [
|
||||||
|
`Decide whether this DM message is worth storing as durable memory for ${personaDisplayName}.`,
|
||||||
|
'Approve only if the message contains a stable preference, biography detail, relationship fact, recurring routine, or other future-useful memory.',
|
||||||
|
'Reject ephemeral small talk, filler, acknowledgements, and one-off chatter.',
|
||||||
|
`Occurred at: ${candidate.occurredAt}`,
|
||||||
|
`Counterpart: ${candidate.counterpartDisplayName ?? candidate.counterpartId}`,
|
||||||
|
`Direction: ${candidate.direction}`,
|
||||||
|
`Proactive: ${candidate.proactive}`,
|
||||||
|
`Message: ${candidate.text}`,
|
||||||
|
'Return { shouldRemember, reason? }.',
|
||||||
|
].join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildConversationMemoryExtractionPrompt(
|
||||||
|
personaDisplayName: string,
|
||||||
|
candidate: ConversationMemoryCandidate,
|
||||||
|
classifierReason?: string,
|
||||||
|
): string {
|
||||||
|
const defaultDomain = normalizeConversationMemoryDomain(undefined, candidate.direction);
|
||||||
|
return [
|
||||||
|
`Extract IdentityDB-ready durable facts from this approved DM message for ${personaDisplayName}.`,
|
||||||
|
'Each fact must have a concise statement and at least one topic.',
|
||||||
|
'Use domain persona.relationship for durable facts about the counterpart or the relationship.',
|
||||||
|
'Use domain persona.biography for durable facts about the persona.',
|
||||||
|
`Default domain for this message if unsure: ${defaultDomain}`,
|
||||||
|
`Occurred at: ${candidate.occurredAt}`,
|
||||||
|
`Counterpart: ${candidate.counterpartDisplayName ?? candidate.counterpartId}`,
|
||||||
|
`Direction: ${candidate.direction}`,
|
||||||
|
`Proactive: ${candidate.proactive}`,
|
||||||
|
classifierReason ? `Approved because: ${classifierReason}` : undefined,
|
||||||
|
`Message: ${candidate.text}`,
|
||||||
|
'Return { facts } where facts is an array of { domain?, statement, summary?, confidence?, topics }.',
|
||||||
|
]
|
||||||
|
.filter((line): line is string => Boolean(line))
|
||||||
|
.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeConversationMemoryDomain(
|
||||||
|
domain: BoxBrainFactDomain | undefined,
|
||||||
|
direction: BoxBrainConversationDirection,
|
||||||
|
): BoxBrainFactDomain {
|
||||||
|
if (typeof domain === 'string' && domain.trim().length > 0) {
|
||||||
|
return domain;
|
||||||
|
}
|
||||||
|
|
||||||
|
return direction === 'inbound' ? 'persona.relationship' : 'persona.biography';
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildConversationMemoryDedupKey(domain: BoxBrainFactDomain | null, statement: string): string {
|
||||||
|
return `${domain ?? 'unknown'}::${statement.trim().toLowerCase()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toConversationMemoryDraft(
|
||||||
|
extractedFact: ConversationMemoryExtractedFactDraft,
|
||||||
|
candidate: ConversationMemoryCandidate,
|
||||||
|
classifierReason?: string,
|
||||||
|
): BoxBrainFactDraft {
|
||||||
|
const metadata = getJsonObject(extractedFact.metadata);
|
||||||
|
const draft: BoxBrainFactDraft = {
|
||||||
|
statement: extractedFact.statement,
|
||||||
|
topics: extractedFact.topics,
|
||||||
|
metadata: jsonObject({
|
||||||
|
...(metadata ?? {}),
|
||||||
|
conversationMemory: jsonObject({
|
||||||
|
turnId: candidate.turnId,
|
||||||
|
direction: candidate.direction,
|
||||||
|
occurredAt: candidate.occurredAt,
|
||||||
|
counterpartId: candidate.counterpartId,
|
||||||
|
counterpartDisplayName: candidate.counterpartDisplayName,
|
||||||
|
proactive: candidate.proactive,
|
||||||
|
sourceMessage: candidate.text,
|
||||||
|
classifierReason,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (extractedFact.summary !== undefined) {
|
||||||
|
draft.summary = extractedFact.summary;
|
||||||
|
}
|
||||||
|
if (extractedFact.source !== undefined) {
|
||||||
|
draft.source = extractedFact.source;
|
||||||
|
}
|
||||||
|
if (extractedFact.confidence !== undefined) {
|
||||||
|
draft.confidence = extractedFact.confidence;
|
||||||
|
}
|
||||||
|
|
||||||
|
return draft;
|
||||||
|
}
|
||||||
|
|
||||||
function renderCandidateMemories(candidateMap: Map<string, BoxBrainMemoryReference>): string {
|
function renderCandidateMemories(candidateMap: Map<string, BoxBrainMemoryReference>): string {
|
||||||
return Array.from(candidateMap.entries())
|
return Array.from(candidateMap.entries())
|
||||||
.map(([id, memory]) => `${id}: [${memory.domain}] ${memory.summary}`)
|
.map(([id, memory]) => `${id}: [${memory.domain}] ${memory.summary}`)
|
||||||
@@ -378,6 +632,55 @@ function assertConversationMemorySelectionResult(value: ConversationMemorySelect
|
|||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function assertConversationMemoryClassificationResult(
|
||||||
|
value: ConversationMemoryClassificationResult,
|
||||||
|
): ConversationMemoryClassificationResult {
|
||||||
|
if (!value || typeof value.shouldRemember !== 'boolean') {
|
||||||
|
throw new Error('Conversation memory classification output must include a shouldRemember boolean.');
|
||||||
|
}
|
||||||
|
if (value.reason !== undefined && typeof value.reason !== 'string') {
|
||||||
|
throw new Error('Conversation memory classification reason must be a string when provided.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertConversationMemoryExtractionResult(
|
||||||
|
value: ConversationMemoryExtractionResult,
|
||||||
|
): ConversationMemoryExtractionResult {
|
||||||
|
if (!value || !Array.isArray(value.facts)) {
|
||||||
|
throw new Error('Conversation memory extraction output must include a facts array.');
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const fact of value.facts) {
|
||||||
|
if (!fact || typeof fact.statement !== 'string' || fact.statement.trim().length === 0) {
|
||||||
|
throw new Error('Extracted conversation memory facts must include a non-empty statement.');
|
||||||
|
}
|
||||||
|
if (fact.domain !== undefined && (typeof fact.domain !== 'string' || fact.domain.trim().length === 0)) {
|
||||||
|
throw new Error('Extracted conversation memory fact domains must be non-empty strings when provided.');
|
||||||
|
}
|
||||||
|
if (!Array.isArray(fact.topics) || fact.topics.length === 0) {
|
||||||
|
throw new Error('Extracted conversation memory facts must include at least one topic.');
|
||||||
|
}
|
||||||
|
for (const topic of fact.topics) {
|
||||||
|
if (!topic || typeof topic.name !== 'string' || topic.name.trim().length === 0) {
|
||||||
|
throw new Error('Extracted conversation memory fact topics must include a non-empty name.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (fact.summary !== undefined && typeof fact.summary !== 'string') {
|
||||||
|
throw new Error('Extracted conversation memory fact summaries must be strings when provided.');
|
||||||
|
}
|
||||||
|
if (fact.source !== undefined && typeof fact.source !== 'string') {
|
||||||
|
throw new Error('Extracted conversation memory fact sources must be strings when provided.');
|
||||||
|
}
|
||||||
|
if (fact.confidence !== undefined && typeof fact.confidence !== 'number') {
|
||||||
|
throw new Error('Extracted conversation memory fact confidence values must be numbers when provided.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
function assertConversationTurnPlan(value: ConversationTurnPlan): ConversationTurnPlan {
|
function assertConversationTurnPlan(value: ConversationTurnPlan): ConversationTurnPlan {
|
||||||
if (!value || (value.mode !== 'reply' && value.mode !== 'refuse')) {
|
if (!value || (value.mode !== 'reply' && value.mode !== 'refuse')) {
|
||||||
throw new Error('Conversation turn plan must include a mode of reply or refuse.');
|
throw new Error('Conversation turn plan must include a mode of reply or refuse.');
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
export * from './adapters';
|
export * from './core/adapters';
|
||||||
|
export * from './core/types';
|
||||||
export * from './availability';
|
export * from './availability';
|
||||||
export * from './conversation';
|
export * from './conversation';
|
||||||
export * from './grok';
|
|
||||||
export * from './memory';
|
export * from './memory';
|
||||||
export * from './persona';
|
export * from './persona';
|
||||||
|
export * from './providers/grok';
|
||||||
export * from './schedule';
|
export * from './schedule';
|
||||||
export * from './timing';
|
export * from './timing';
|
||||||
export * from './types';
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { AddFactInput, Fact, IdentityDB, JsonValue, TopicCategory } from 'identitydb';
|
import type { AddFactInput, Fact, IdentityDB, JsonValue, TopicCategory } from 'identitydb';
|
||||||
import type { BoxBrainFactDomain, BoxBrainFactDraft, BoxBrainTopicDraft } from './types';
|
import type { BoxBrainFactDomain, BoxBrainFactDraft, BoxBrainTopicDraft } from '../core/types';
|
||||||
|
|
||||||
export interface PersistFactDraftsInput {
|
export interface PersistFactDraftsInput {
|
||||||
spaceName: string;
|
spaceName: string;
|
||||||
@@ -10,19 +10,27 @@ export interface PersistFactDraftsInput {
|
|||||||
|
|
||||||
const IDENTITYDB_TOPIC_CATEGORIES = new Set<TopicCategory>(['entity', 'concept', 'temporal', 'custom']);
|
const IDENTITYDB_TOPIC_CATEGORIES = new Set<TopicCategory>(['entity', 'concept', 'temporal', 'custom']);
|
||||||
|
|
||||||
export async function persistFactDrafts(db: IdentityDB, input: PersistFactDraftsInput): Promise<Fact[]> {
|
export class FactDraftMemoryStore {
|
||||||
|
constructor(private readonly db: IdentityDB) {}
|
||||||
|
|
||||||
|
async persist(input: PersistFactDraftsInput): Promise<Fact[]> {
|
||||||
if (input.facts.length === 0) {
|
if (input.facts.length === 0) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
await db.upsertSpace({ name: input.spaceName });
|
await this.db.upsertSpace({ name: input.spaceName });
|
||||||
|
|
||||||
const persisted: Fact[] = [];
|
const persisted: Fact[] = [];
|
||||||
for (const draft of input.facts) {
|
for (const draft of input.facts) {
|
||||||
persisted.push(await db.addFact(toAddFactInput(draft, input)));
|
persisted.push(await this.db.addFact(toAddFactInput(draft, input)));
|
||||||
}
|
}
|
||||||
|
|
||||||
return persisted;
|
return persisted;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function persistFactDrafts(db: IdentityDB, input: PersistFactDraftsInput): Promise<Fact[]> {
|
||||||
|
return new FactDraftMemoryStore(db).persist(input);
|
||||||
}
|
}
|
||||||
|
|
||||||
function toAddFactInput(draft: BoxBrainFactDraft, input: PersistFactDraftsInput): AddFactInput {
|
function toAddFactInput(draft: BoxBrainFactDraft, input: PersistFactDraftsInput): AddFactInput {
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
import type { IdentityDB } from 'identitydb';
|
import type { IdentityDB } from 'identitydb';
|
||||||
import type { ImageModelAdapter, StructuredModelAdapter } from './adapters';
|
import type { ImageModelAdapter, StructuredModelAdapter } from '../core/adapters';
|
||||||
import { persistFactDrafts } from './memory';
|
import { persistFactDrafts } from '../memory';
|
||||||
import type { BoxBrainFactDraft, BoxBrainPersonaProfile } from './types';
|
import type { BoxBrainFactDraft, BoxBrainPersonaProfile } from '../core/types';
|
||||||
|
|
||||||
export interface PersonaRelationshipInput {
|
export interface PersonaRelationshipInput {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -74,7 +74,19 @@ const PERSONA_FACT_EXTRACTION_SCHEMA = {
|
|||||||
},
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
export class PersonaService {
|
||||||
|
constructor(private readonly db: IdentityDB) {}
|
||||||
|
|
||||||
|
async initialize(input: InitializePersonaInput): Promise<InitializedPersona> {
|
||||||
|
return initializePersonaWithDb(this.db, input);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function initializePersona(db: IdentityDB, input: InitializePersonaInput): Promise<InitializedPersona> {
|
export async function initializePersona(db: IdentityDB, input: InitializePersonaInput): Promise<InitializedPersona> {
|
||||||
|
return new PersonaService(db).initialize(input);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function initializePersonaWithDb(db: IdentityDB, input: InitializePersonaInput): Promise<InitializedPersona> {
|
||||||
assertPersonaInitializationInput(input);
|
assertPersonaInitializationInput(input);
|
||||||
|
|
||||||
const id = input.id ?? createPersonaId(input.displayName);
|
const id = input.id ?? createPersonaId(input.displayName);
|
||||||
@@ -5,7 +5,7 @@ import type {
|
|||||||
StructuredModelAdapter,
|
StructuredModelAdapter,
|
||||||
TextGenerationRequest,
|
TextGenerationRequest,
|
||||||
TextModelAdapter,
|
TextModelAdapter,
|
||||||
} from './adapters';
|
} from '../../core/adapters';
|
||||||
|
|
||||||
type GrokFetch = typeof fetch;
|
type GrokFetch = typeof fetch;
|
||||||
|
|
||||||
@@ -32,6 +32,43 @@ export interface GrokAdapterBundleOptions {
|
|||||||
const DEFAULT_BASE_URL = 'https://api.x.ai/v1';
|
const DEFAULT_BASE_URL = 'https://api.x.ai/v1';
|
||||||
const GROK_PROVIDER = 'xai-grok';
|
const GROK_PROVIDER = 'xai-grok';
|
||||||
|
|
||||||
|
export class GrokApiClient {
|
||||||
|
private readonly baseUrl: string;
|
||||||
|
private readonly fetchImpl: GrokFetch;
|
||||||
|
|
||||||
|
constructor(private readonly options: Pick<GrokAdapterOptions, 'apiKey' | 'baseUrl' | 'fetch' | 'extraHeaders'>) {
|
||||||
|
this.baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\/$/, '');
|
||||||
|
const fetchImpl = options.fetch ?? globalThis.fetch;
|
||||||
|
if (!fetchImpl) {
|
||||||
|
throw new Error('Grok adapter requires a fetch implementation.');
|
||||||
|
}
|
||||||
|
this.fetchImpl = fetchImpl;
|
||||||
|
}
|
||||||
|
|
||||||
|
async postJson(path: string, body: JsonObject): Promise<JsonObject> {
|
||||||
|
const response = await this.fetchImpl(`${this.baseUrl}${path}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
authorization: `Bearer ${this.options.apiKey}`,
|
||||||
|
'content-type': 'application/json',
|
||||||
|
...(this.options.extraHeaders ?? {}),
|
||||||
|
},
|
||||||
|
body: JSON.stringify(removeUndefined(body)),
|
||||||
|
});
|
||||||
|
|
||||||
|
const text = await response.text();
|
||||||
|
const parsed = text.length > 0 ? tryParseJson(text) : {};
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Grok API request failed (${response.status}): ${text}`);
|
||||||
|
}
|
||||||
|
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||||
|
throw new Error('Grok API response must be a JSON object.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function createGrokTextModelAdapter(options: GrokAdapterOptions): TextModelAdapter {
|
export function createGrokTextModelAdapter(options: GrokAdapterOptions): TextModelAdapter {
|
||||||
const runtime = createRuntime(options);
|
const runtime = createRuntime(options);
|
||||||
|
|
||||||
@@ -132,39 +169,8 @@ export function createGrokAdapters(options: GrokAdapterBundleOptions): {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function createRuntime(options: Pick<GrokAdapterOptions, 'apiKey' | 'baseUrl' | 'fetch' | 'extraHeaders'>): {
|
function createRuntime(options: Pick<GrokAdapterOptions, 'apiKey' | 'baseUrl' | 'fetch' | 'extraHeaders'>): GrokApiClient {
|
||||||
postJson: (path: string, body: JsonObject) => Promise<JsonObject>;
|
return new GrokApiClient(options);
|
||||||
} {
|
|
||||||
const baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\/$/, '');
|
|
||||||
const fetchImpl = options.fetch ?? globalThis.fetch;
|
|
||||||
if (!fetchImpl) {
|
|
||||||
throw new Error('Grok adapter requires a fetch implementation.');
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
async postJson(path, body) {
|
|
||||||
const response = await fetchImpl(`${baseUrl}${path}`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
authorization: `Bearer ${options.apiKey}`,
|
|
||||||
'content-type': 'application/json',
|
|
||||||
...(options.extraHeaders ?? {}),
|
|
||||||
},
|
|
||||||
body: JSON.stringify(removeUndefined(body)),
|
|
||||||
});
|
|
||||||
|
|
||||||
const text = await response.text();
|
|
||||||
const parsed = text.length > 0 ? tryParseJson(text) : {};
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`Grok API request failed (${response.status}): ${text}`);
|
|
||||||
}
|
|
||||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
||||||
throw new Error('Grok API response must be a JSON object.');
|
|
||||||
}
|
|
||||||
|
|
||||||
return parsed;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildMessages(system: string | undefined, prompt: string): Array<{ role: 'system' | 'user'; content: string }> {
|
function buildMessages(system: string | undefined, prompt: string): Array<{ role: 'system' | 'user'; content: string }> {
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
import type { Fact, IdentityDB, JsonValue } from 'identitydb';
|
import type { Fact, IdentityDB, JsonValue } from 'identitydb';
|
||||||
import type { SpecialDateProvider, StructuredModelAdapter } from './adapters';
|
import type { SpecialDateProvider, StructuredModelAdapter } from '../core/adapters';
|
||||||
import { setAvailabilityStatus } from './availability';
|
import { setAvailabilityStatus } from '../availability';
|
||||||
import { dateOnly, getFactDomain, getJsonObject, jsonObject, listFactsInSpace, uniqueStrings } from './facts';
|
import { dateOnly, getFactDomain, getJsonObject, jsonObject, listFactsInSpace, uniqueStrings } from '../core/facts';
|
||||||
import { persistFactDrafts } from './memory';
|
import { persistFactDrafts } from '../memory';
|
||||||
import type {
|
import type {
|
||||||
BoxBrainAvailabilityMode,
|
BoxBrainAvailabilityMode,
|
||||||
BoxBrainAvailabilityEntry,
|
BoxBrainAvailabilityEntry,
|
||||||
@@ -11,7 +11,7 @@ import type {
|
|||||||
BoxBrainScheduleEventKind,
|
BoxBrainScheduleEventKind,
|
||||||
BoxBrainScheduleScope,
|
BoxBrainScheduleScope,
|
||||||
BoxBrainTopicDraft,
|
BoxBrainTopicDraft,
|
||||||
} from './types';
|
} from '../core/types';
|
||||||
|
|
||||||
export interface ScheduleEventDraft {
|
export interface ScheduleEventDraft {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -60,9 +60,36 @@ export interface SchedulePruneResult {
|
|||||||
deletedEventIds: string[];
|
deletedEventIds: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class ScheduleService {
|
||||||
|
constructor(private readonly db: IdentityDB) {}
|
||||||
|
|
||||||
|
async generate(input: GenerateScheduleInput): Promise<{ events: BoxBrainScheduleEvent[]; availabilityEntries: BoxBrainAvailabilityEntry[] }> {
|
||||||
|
return generateScheduleWithDb(this.db, input);
|
||||||
|
}
|
||||||
|
|
||||||
|
async listEvents(input: ListScheduleEventsInput): Promise<BoxBrainScheduleEvent[]> {
|
||||||
|
return listScheduleEventsWithDb(this.db, input);
|
||||||
|
}
|
||||||
|
|
||||||
|
async pruneExpired(input: PruneExpiredScheduleInput): Promise<SchedulePruneResult> {
|
||||||
|
return pruneExpiredScheduleWithDb(this.db, input);
|
||||||
|
}
|
||||||
|
|
||||||
|
async pruneBefore(input: PruneScheduleBeforeInput): Promise<SchedulePruneResult> {
|
||||||
|
return pruneScheduleBeforeWithDb(this.db, input);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function generateSchedule(
|
export async function generateSchedule(
|
||||||
db: IdentityDB,
|
db: IdentityDB,
|
||||||
input: GenerateScheduleInput,
|
input: GenerateScheduleInput,
|
||||||
|
): Promise<{ events: BoxBrainScheduleEvent[]; availabilityEntries: BoxBrainAvailabilityEntry[] }> {
|
||||||
|
return new ScheduleService(db).generate(input);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function generateScheduleWithDb(
|
||||||
|
db: IdentityDB,
|
||||||
|
input: GenerateScheduleInput,
|
||||||
): Promise<{ events: BoxBrainScheduleEvent[]; availabilityEntries: BoxBrainAvailabilityEntry[] }> {
|
): Promise<{ events: BoxBrainScheduleEvent[]; availabilityEntries: BoxBrainAvailabilityEntry[] }> {
|
||||||
await ensurePersonaSpace(db, input.spaceName, input.displayName);
|
await ensurePersonaSpace(db, input.spaceName, input.displayName);
|
||||||
|
|
||||||
@@ -134,6 +161,10 @@ export async function generateSchedule(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function listScheduleEvents(db: IdentityDB, input: ListScheduleEventsInput): Promise<BoxBrainScheduleEvent[]> {
|
export async function listScheduleEvents(db: IdentityDB, input: ListScheduleEventsInput): Promise<BoxBrainScheduleEvent[]> {
|
||||||
|
return new ScheduleService(db).listEvents(input);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listScheduleEventsWithDb(db: IdentityDB, input: ListScheduleEventsInput): Promise<BoxBrainScheduleEvent[]> {
|
||||||
const facts = await listFactsInSpace(db, input.spaceName);
|
const facts = await listFactsInSpace(db, input.spaceName);
|
||||||
const deletedIds = new Set(
|
const deletedIds = new Set(
|
||||||
facts
|
facts
|
||||||
@@ -156,6 +187,10 @@ export async function listScheduleEvents(db: IdentityDB, input: ListScheduleEven
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function pruneExpiredSchedule(db: IdentityDB, input: PruneExpiredScheduleInput): Promise<SchedulePruneResult> {
|
export async function pruneExpiredSchedule(db: IdentityDB, input: PruneExpiredScheduleInput): Promise<SchedulePruneResult> {
|
||||||
|
return new ScheduleService(db).pruneExpired(input);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pruneExpiredScheduleWithDb(db: IdentityDB, input: PruneExpiredScheduleInput): Promise<SchedulePruneResult> {
|
||||||
const graceMs = (input.graceSeconds ?? 0) * 1000;
|
const graceMs = (input.graceSeconds ?? 0) * 1000;
|
||||||
const cutoffMs = Date.parse(input.referenceTime) - graceMs;
|
const cutoffMs = Date.parse(input.referenceTime) - graceMs;
|
||||||
const events = await listScheduleEvents(db, { spaceName: input.spaceName });
|
const events = await listScheduleEvents(db, { spaceName: input.spaceName });
|
||||||
@@ -166,6 +201,10 @@ export async function pruneExpiredSchedule(db: IdentityDB, input: PruneExpiredSc
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function pruneScheduleBefore(db: IdentityDB, input: PruneScheduleBeforeInput): Promise<SchedulePruneResult> {
|
export async function pruneScheduleBefore(db: IdentityDB, input: PruneScheduleBeforeInput): Promise<SchedulePruneResult> {
|
||||||
|
return new ScheduleService(db).pruneBefore(input);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pruneScheduleBeforeWithDb(db: IdentityDB, input: PruneScheduleBeforeInput): Promise<SchedulePruneResult> {
|
||||||
const cutoffMs = Date.parse(input.before);
|
const cutoffMs = Date.parse(input.before);
|
||||||
const events = await listScheduleEvents(db, { spaceName: input.spaceName });
|
const events = await listScheduleEvents(db, { spaceName: input.spaceName });
|
||||||
const toDelete = events.filter((event) => Date.parse(event.startAt) < cutoffMs);
|
const toDelete = events.filter((event) => Date.parse(event.startAt) < cutoffMs);
|
||||||
@@ -1,78 +0,0 @@
|
|||||||
import type { BoxBrainAvailability } from './types';
|
|
||||||
|
|
||||||
export type RandomSource = () => number;
|
|
||||||
|
|
||||||
export interface TypingDelayOptions {
|
|
||||||
rng?: RandomSource | undefined;
|
|
||||||
minSecondsPerCharacter?: number | undefined;
|
|
||||||
maxSecondsPerCharacter?: number | undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ReplyDelayOptions {
|
|
||||||
isFirstReplyInExchange: boolean;
|
|
||||||
rng?: RandomSource | undefined;
|
|
||||||
onlineMinSeconds?: number | undefined;
|
|
||||||
onlineMaxSeconds?: number | undefined;
|
|
||||||
dndReplyProbability?: number | undefined;
|
|
||||||
dndMinSeconds?: number | undefined;
|
|
||||||
dndMaxSeconds?: number | undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ONLINE_AVAILABILITY: BoxBrainAvailability = { mode: 'online' };
|
|
||||||
export const DND_AVAILABILITY: BoxBrainAvailability = { mode: 'do_not_disturb' };
|
|
||||||
export const OFFLINE_AVAILABILITY: BoxBrainAvailability = { mode: 'offline' };
|
|
||||||
|
|
||||||
export function createTypingDelay(message: string, options: TypingDelayOptions = {}): number {
|
|
||||||
if (message.length === 0) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
const rng = options.rng ?? Math.random;
|
|
||||||
const min = options.minSecondsPerCharacter ?? 0.05;
|
|
||||||
const max = options.maxSecondsPerCharacter ?? 0.08;
|
|
||||||
const secondsPerCharacter = interpolate(min, max, clampUnit(rng()));
|
|
||||||
|
|
||||||
return roundSeconds(message.length * secondsPerCharacter);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createReplyDelay(
|
|
||||||
availability: BoxBrainAvailability,
|
|
||||||
options: ReplyDelayOptions,
|
|
||||||
): number | null {
|
|
||||||
if (availability.mode === 'offline') {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!options.isFirstReplyInExchange) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
const rng = options.rng ?? Math.random;
|
|
||||||
|
|
||||||
if (availability.mode === 'do_not_disturb') {
|
|
||||||
const probability = options.dndReplyProbability ?? 0.2;
|
|
||||||
if (clampUnit(rng()) > probability) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return roundSeconds(interpolate(options.dndMinSeconds ?? 60, options.dndMaxSeconds ?? 600, clampUnit(rng())));
|
|
||||||
}
|
|
||||||
|
|
||||||
return roundSeconds(interpolate(options.onlineMinSeconds ?? 1, options.onlineMaxSeconds ?? 12, clampUnit(rng())));
|
|
||||||
}
|
|
||||||
|
|
||||||
function interpolate(min: number, max: number, ratio: number): number {
|
|
||||||
return min + (max - min) * ratio;
|
|
||||||
}
|
|
||||||
|
|
||||||
function clampUnit(value: number): number {
|
|
||||||
if (Number.isNaN(value)) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
return Math.min(1, Math.max(0, value));
|
|
||||||
}
|
|
||||||
|
|
||||||
function roundSeconds(value: number): number {
|
|
||||||
return Math.round(value * 1_000_000) / 1_000_000;
|
|
||||||
}
|
|
||||||
87
src/timing/index.ts
Normal file
87
src/timing/index.ts
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
import type { BoxBrainAvailability } from '../core/types';
|
||||||
|
|
||||||
|
export type RandomSource = () => number;
|
||||||
|
|
||||||
|
export interface TypingDelayOptions {
|
||||||
|
rng?: RandomSource | undefined;
|
||||||
|
minSecondsPerCharacter?: number | undefined;
|
||||||
|
maxSecondsPerCharacter?: number | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReplyDelayOptions {
|
||||||
|
isFirstReplyInExchange: boolean;
|
||||||
|
rng?: RandomSource | undefined;
|
||||||
|
onlineMinSeconds?: number | undefined;
|
||||||
|
onlineMaxSeconds?: number | undefined;
|
||||||
|
dndReplyProbability?: number | undefined;
|
||||||
|
dndMinSeconds?: number | undefined;
|
||||||
|
dndMaxSeconds?: number | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ONLINE_AVAILABILITY: BoxBrainAvailability = { mode: 'online' };
|
||||||
|
export const DND_AVAILABILITY: BoxBrainAvailability = { mode: 'do_not_disturb' };
|
||||||
|
export const OFFLINE_AVAILABILITY: BoxBrainAvailability = { mode: 'offline' };
|
||||||
|
|
||||||
|
export class TimingProfile {
|
||||||
|
createTypingDelay(message: string, options: TypingDelayOptions = {}): number {
|
||||||
|
if (message.length === 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rng = options.rng ?? Math.random;
|
||||||
|
const min = options.minSecondsPerCharacter ?? 0.05;
|
||||||
|
const max = options.maxSecondsPerCharacter ?? 0.08;
|
||||||
|
const secondsPerCharacter = interpolate(min, max, clampUnit(rng()));
|
||||||
|
|
||||||
|
return roundSeconds(message.length * secondsPerCharacter);
|
||||||
|
}
|
||||||
|
|
||||||
|
createReplyDelay(availability: BoxBrainAvailability, options: ReplyDelayOptions): number | null {
|
||||||
|
if (availability.mode === 'offline') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!options.isFirstReplyInExchange) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rng = options.rng ?? Math.random;
|
||||||
|
|
||||||
|
if (availability.mode === 'do_not_disturb') {
|
||||||
|
const probability = options.dndReplyProbability ?? 0.2;
|
||||||
|
if (clampUnit(rng()) > probability) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return roundSeconds(interpolate(options.dndMinSeconds ?? 60, options.dndMaxSeconds ?? 600, clampUnit(rng())));
|
||||||
|
}
|
||||||
|
|
||||||
|
return roundSeconds(interpolate(options.onlineMinSeconds ?? 1, options.onlineMaxSeconds ?? 12, clampUnit(rng())));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_TIMING_PROFILE = new TimingProfile();
|
||||||
|
|
||||||
|
export function createTypingDelay(message: string, options: TypingDelayOptions = {}): number {
|
||||||
|
return DEFAULT_TIMING_PROFILE.createTypingDelay(message, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createReplyDelay(availability: BoxBrainAvailability, options: ReplyDelayOptions): number | null {
|
||||||
|
return DEFAULT_TIMING_PROFILE.createReplyDelay(availability, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
function interpolate(min: number, max: number, ratio: number): number {
|
||||||
|
return min + (max - min) * ratio;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clampUnit(value: number): number {
|
||||||
|
if (Number.isNaN(value)) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Math.min(1, Math.max(0, value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function roundSeconds(value: number): number {
|
||||||
|
return Math.round(value * 1_000_000) / 1_000_000;
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { afterEach, describe, expect, it } from 'vitest';
|
import { afterEach, describe, expect, it } from 'vitest';
|
||||||
|
import type { Fact } from 'identitydb';
|
||||||
import {
|
import {
|
||||||
generateSchedule,
|
generateSchedule,
|
||||||
getAvailabilitySnapshot,
|
getAvailabilitySnapshot,
|
||||||
@@ -6,6 +7,8 @@ import {
|
|||||||
replyToConversation,
|
replyToConversation,
|
||||||
setAvailabilityStatus,
|
setAvailabilityStatus,
|
||||||
startConversation,
|
startConversation,
|
||||||
|
type ConversationMemoryClassificationResult,
|
||||||
|
type ConversationMemoryExtractionResult,
|
||||||
type ConversationMemorySelectionResult,
|
type ConversationMemorySelectionResult,
|
||||||
type ConversationTurnPlan,
|
type ConversationTurnPlan,
|
||||||
type ScheduleGenerationResult,
|
type ScheduleGenerationResult,
|
||||||
@@ -118,6 +121,162 @@ describe('conversation APIs', () => {
|
|||||||
expect(history[0]?.proactive).toBe(true);
|
expect(history[0]?.proactive).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('classifies inbound and outbound conversation messages, extracts only approved memories, and stores trace metadata', async () => {
|
||||||
|
const db = await createDb();
|
||||||
|
await seedPersonaMemory(db);
|
||||||
|
const classifierPrompts: string[] = [];
|
||||||
|
const extractorPrompts: string[] = [];
|
||||||
|
|
||||||
|
await replyToConversation(db, {
|
||||||
|
spaceName: 'persona:minji',
|
||||||
|
counterpartId: 'user:shinwoo',
|
||||||
|
counterpartDisplayName: 'Shinwoo',
|
||||||
|
message: '이번주말에 등산 가고 싶어',
|
||||||
|
currentTime: '2026-05-12T12:00:00.000Z',
|
||||||
|
mandatoryMemoryModel: createSelectionModel(['m1']),
|
||||||
|
contextualMemoryModel: createSelectionModel([]),
|
||||||
|
responseModel: createResponseModel({
|
||||||
|
mode: 'reply',
|
||||||
|
messages: ['좋다 나도 산 좋아해', '저녁엔가족이랑먹어'],
|
||||||
|
}),
|
||||||
|
rng: () => 0,
|
||||||
|
memoryPipeline: {
|
||||||
|
classifierModel: createMemoryClassifier([
|
||||||
|
{ shouldRemember: true, reason: 'stores a durable user preference' },
|
||||||
|
{ shouldRemember: false, reason: 'small talk only' },
|
||||||
|
{ shouldRemember: true, reason: 'reveals a stable persona routine' },
|
||||||
|
], classifierPrompts),
|
||||||
|
extractorModel: createMemoryExtractor([
|
||||||
|
{
|
||||||
|
facts: [
|
||||||
|
{
|
||||||
|
domain: 'persona.relationship',
|
||||||
|
statement: 'Shinwoo wants to go hiking on weekends.',
|
||||||
|
topics: [{ name: 'Shinwoo' }, { name: 'hiking' }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
facts: [
|
||||||
|
{
|
||||||
|
domain: 'persona.biography',
|
||||||
|
statement: 'Minji often has family dinner in the evening.',
|
||||||
|
topics: [{ name: 'Minji' }, { name: 'family dinner' }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
], extractorPrompts),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(classifierPrompts).toHaveLength(3);
|
||||||
|
expect(classifierPrompts[0]).toContain('Direction: inbound');
|
||||||
|
expect(classifierPrompts[0]).toContain('이번주말에 등산 가고 싶어');
|
||||||
|
expect(classifierPrompts[1]).toContain('Direction: outbound');
|
||||||
|
expect(extractorPrompts).toHaveLength(2);
|
||||||
|
expect(extractorPrompts[0]).toContain('stores a durable user preference');
|
||||||
|
expect(extractorPrompts[1]).toContain('reveals a stable persona routine');
|
||||||
|
|
||||||
|
const facts = await listFactsForSpace(db, 'persona:minji');
|
||||||
|
const hikingFact = facts.find((fact) => fact.statement === 'Shinwoo wants to go hiking on weekends.');
|
||||||
|
const dinnerFact = facts.find((fact) => fact.statement === 'Minji often has family dinner in the evening.');
|
||||||
|
|
||||||
|
expect(hikingFact?.metadata).toMatchObject({
|
||||||
|
boxbrain: {
|
||||||
|
domain: 'persona.relationship',
|
||||||
|
},
|
||||||
|
conversationMemory: {
|
||||||
|
turnId: expect.any(String),
|
||||||
|
direction: 'inbound',
|
||||||
|
counterpartId: 'user:shinwoo',
|
||||||
|
counterpartDisplayName: 'Shinwoo',
|
||||||
|
occurredAt: '2026-05-12T12:00:00.000Z',
|
||||||
|
proactive: false,
|
||||||
|
sourceMessage: '이번주말에 등산 가고 싶어',
|
||||||
|
classifierReason: 'stores a durable user preference',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(dinnerFact?.metadata).toMatchObject({
|
||||||
|
boxbrain: {
|
||||||
|
domain: 'persona.biography',
|
||||||
|
},
|
||||||
|
conversationMemory: {
|
||||||
|
turnId: expect.any(String),
|
||||||
|
direction: 'outbound',
|
||||||
|
counterpartId: 'user:shinwoo',
|
||||||
|
counterpartDisplayName: 'Shinwoo',
|
||||||
|
occurredAt: '2026-05-12T12:00:00.000Z',
|
||||||
|
proactive: false,
|
||||||
|
sourceMessage: '저녁엔가족이랑먹어',
|
||||||
|
classifierReason: 'reveals a stable persona routine',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deduplicates repeated extracted conversation memories by statement and domain', async () => {
|
||||||
|
const db = await createDb();
|
||||||
|
await seedPersonaMemory(db);
|
||||||
|
|
||||||
|
await replyToConversation(db, {
|
||||||
|
spaceName: 'persona:minji',
|
||||||
|
counterpartId: 'user:shinwoo',
|
||||||
|
counterpartDisplayName: 'Shinwoo',
|
||||||
|
message: '나는 민트초코 좋아해',
|
||||||
|
currentTime: '2026-05-12T12:00:00.000Z',
|
||||||
|
mandatoryMemoryModel: createSelectionModel(['m1']),
|
||||||
|
contextualMemoryModel: createSelectionModel([]),
|
||||||
|
responseModel: createResponseModel({ mode: 'reply', messages: ['오 진짜?'] }),
|
||||||
|
memoryPipeline: {
|
||||||
|
classifierModel: createMemoryClassifier([
|
||||||
|
{ shouldRemember: true, reason: 'stable preference' },
|
||||||
|
{ shouldRemember: false, reason: 'reply is not worth storing' },
|
||||||
|
]),
|
||||||
|
extractorModel: createMemoryExtractor([
|
||||||
|
{
|
||||||
|
facts: [
|
||||||
|
{
|
||||||
|
domain: 'persona.relationship',
|
||||||
|
statement: 'Shinwoo likes mint chocolate.',
|
||||||
|
topics: [{ name: 'Shinwoo' }, { name: 'mint chocolate' }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await replyToConversation(db, {
|
||||||
|
spaceName: 'persona:minji',
|
||||||
|
counterpartId: 'user:shinwoo',
|
||||||
|
counterpartDisplayName: 'Shinwoo',
|
||||||
|
message: '나 아직도 민트초코 좋아해',
|
||||||
|
currentTime: '2026-05-12T13:00:00.000Z',
|
||||||
|
mandatoryMemoryModel: createSelectionModel(['m1']),
|
||||||
|
contextualMemoryModel: createSelectionModel([]),
|
||||||
|
responseModel: createResponseModel({ mode: 'reply', messages: ['기억하고있어'] }),
|
||||||
|
memoryPipeline: {
|
||||||
|
classifierModel: createMemoryClassifier([
|
||||||
|
{ shouldRemember: true, reason: 'same stable preference' },
|
||||||
|
{ shouldRemember: false, reason: 'reply is not worth storing' },
|
||||||
|
]),
|
||||||
|
extractorModel: createMemoryExtractor([
|
||||||
|
{
|
||||||
|
facts: [
|
||||||
|
{
|
||||||
|
domain: 'persona.relationship',
|
||||||
|
statement: 'Shinwoo likes mint chocolate.',
|
||||||
|
topics: [{ name: 'Shinwoo' }, { name: 'mint chocolate' }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const facts = await listFactsForSpace(db, 'persona:minji');
|
||||||
|
expect(facts.filter((fact) => fact.statement === 'Shinwoo likes mint chocolate.')).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
it('executes availability tool calls after farewell-style refusal messages', async () => {
|
it('executes availability tool calls after farewell-style refusal messages', async () => {
|
||||||
const db = await createDb();
|
const db = await createDb();
|
||||||
await seedPersonaMemory(db);
|
await seedPersonaMemory(db);
|
||||||
@@ -208,6 +367,55 @@ function createResponseModel(plan: ConversationTurnPlan, prompts: string[] = [])
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createMemoryClassifier(
|
||||||
|
results: ConversationMemoryClassificationResult[],
|
||||||
|
prompts: string[] = [],
|
||||||
|
): StructuredModelAdapter {
|
||||||
|
const queue = [...results];
|
||||||
|
return {
|
||||||
|
provider: 'fake-structured',
|
||||||
|
model: 'memory-classifier',
|
||||||
|
async generateObject<TOutput>(request: { prompt: string }): Promise<TOutput> {
|
||||||
|
prompts.push(request.prompt);
|
||||||
|
const result = queue.shift();
|
||||||
|
if (!result) {
|
||||||
|
throw new Error('No queued conversation memory classification result.');
|
||||||
|
}
|
||||||
|
return result as TOutput;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createMemoryExtractor(
|
||||||
|
results: ConversationMemoryExtractionResult[],
|
||||||
|
prompts: string[] = [],
|
||||||
|
): StructuredModelAdapter {
|
||||||
|
const queue = [...results];
|
||||||
|
return {
|
||||||
|
provider: 'fake-structured',
|
||||||
|
model: 'memory-extractor',
|
||||||
|
async generateObject<TOutput>(request: { prompt: string }): Promise<TOutput> {
|
||||||
|
prompts.push(request.prompt);
|
||||||
|
const result = queue.shift();
|
||||||
|
if (!result) {
|
||||||
|
throw new Error('No queued conversation memory extraction result.');
|
||||||
|
}
|
||||||
|
return result as TOutput;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listFactsForSpace(db: Awaited<ReturnType<typeof createDb>>, spaceName: string): Promise<Fact[]> {
|
||||||
|
const topics = await db.listTopics({ includeFacts: true, spaceName });
|
||||||
|
const byId = new Map<string, Fact>();
|
||||||
|
for (const topic of topics) {
|
||||||
|
for (const fact of topic.facts) {
|
||||||
|
byId.set(fact.id, fact);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Array.from(byId.values());
|
||||||
|
}
|
||||||
|
|
||||||
async function seedPersonaMemory(db: Awaited<ReturnType<typeof createDb>>) {
|
async function seedPersonaMemory(db: Awaited<ReturnType<typeof createDb>>) {
|
||||||
await db.upsertSpace({
|
await db.upsertSpace({
|
||||||
name: 'persona:minji',
|
name: 'persona:minji',
|
||||||
|
|||||||
@@ -10,6 +10,13 @@ import {
|
|||||||
type SpecialDateProvider,
|
type SpecialDateProvider,
|
||||||
type TextModelAdapter,
|
type TextModelAdapter,
|
||||||
} from '../src';
|
} from '../src';
|
||||||
|
import { AvailabilityService } from '../src/availability';
|
||||||
|
import { ConversationService } from '../src/conversation';
|
||||||
|
import { FactDraftMemoryStore } from '../src/memory';
|
||||||
|
import { PersonaService } from '../src/persona';
|
||||||
|
import { GrokApiClient } from '../src/providers/grok';
|
||||||
|
import { ScheduleService } from '../src/schedule';
|
||||||
|
import { TimingProfile } from '../src/timing';
|
||||||
|
|
||||||
describe('public API', () => {
|
describe('public API', () => {
|
||||||
it('exports timing helpers and runtime availability constants', () => {
|
it('exports timing helpers and runtime availability constants', () => {
|
||||||
@@ -39,7 +46,7 @@ describe('public API', () => {
|
|||||||
expect(fact.topics.map((topic) => topic.name)).toEqual(['Mina', 'quiet cafés']);
|
expect(fact.topics.map((topic) => topic.name)).toEqual(['Mina', 'quiet cafés']);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('exports schedule, conversation, Grok, and external special-date adapter contracts', () => {
|
it('exports grouped service classes and provider runtime helpers', () => {
|
||||||
const specialDateProvider: SpecialDateProvider = {
|
const specialDateProvider: SpecialDateProvider = {
|
||||||
async listSpecialDates() {
|
async listSpecialDates() {
|
||||||
return [{ date: '2026-05-08', title: 'Parents Day' }];
|
return [{ date: '2026-05-08', title: 'Parents Day' }];
|
||||||
@@ -50,5 +57,13 @@ describe('public API', () => {
|
|||||||
expect(typeof replyToConversation).toBe('function');
|
expect(typeof replyToConversation).toBe('function');
|
||||||
expect(typeof createGrokAdapters).toBe('function');
|
expect(typeof createGrokAdapters).toBe('function');
|
||||||
expect(specialDateProvider.listSpecialDates).toBeTypeOf('function');
|
expect(specialDateProvider.listSpecialDates).toBeTypeOf('function');
|
||||||
|
|
||||||
|
expect(AvailabilityService).toBeTypeOf('function');
|
||||||
|
expect(ConversationService).toBeTypeOf('function');
|
||||||
|
expect(FactDraftMemoryStore).toBeTypeOf('function');
|
||||||
|
expect(PersonaService).toBeTypeOf('function');
|
||||||
|
expect(GrokApiClient).toBeTypeOf('function');
|
||||||
|
expect(ScheduleService).toBeTypeOf('function');
|
||||||
|
expect(TimingProfile).toBeTypeOf('function');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
29
tests/release-config.test.ts
Normal file
29
tests/release-config.test.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
describe('release config', () => {
|
||||||
|
it('depends on the published identitydb 0.2.0 npm package', () => {
|
||||||
|
const packageJson = JSON.parse(
|
||||||
|
readFileSync(join(process.cwd(), 'package.json'), 'utf8'),
|
||||||
|
) as {
|
||||||
|
dependencies?: Record<string, string>;
|
||||||
|
trustedDependencies?: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(packageJson.dependencies?.identitydb).toBe('0.2.0');
|
||||||
|
expect(packageJson.trustedDependencies).toEqual(
|
||||||
|
expect.arrayContaining(['better-sqlite3', 'esbuild']),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('publishes without cloning a sibling IdentityDB repository first', () => {
|
||||||
|
const workflow = readFileSync(
|
||||||
|
join(process.cwd(), '.gitea/workflows/npm-release.yml'),
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(workflow).not.toContain('Clone IdentityDB dependency');
|
||||||
|
expect(workflow).not.toContain('IDENTITYDB_URL');
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user