@hyperspell/openclaw-hyperspell 0.12.0 → 0.13.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/README.md +37 -1
  2. package/dist/client.js +341 -0
  3. package/dist/commands/setup.js +568 -0
  4. package/dist/commands/slash.js +159 -0
  5. package/dist/config.js +349 -0
  6. package/{graph/cron.ts → dist/graph/cron.js} +11 -15
  7. package/dist/graph/index.js +4 -0
  8. package/dist/graph/ops.js +221 -0
  9. package/dist/graph/state.js +68 -0
  10. package/dist/graph/tools.js +87 -0
  11. package/dist/hooks/auto-context.js +199 -0
  12. package/dist/hooks/auto-trace.js +143 -0
  13. package/dist/hooks/emotional-state.js +155 -0
  14. package/dist/hooks/memory-sync.js +54 -0
  15. package/dist/hooks/startup-orientation.js +236 -0
  16. package/dist/index.js +132 -0
  17. package/dist/lib/browser.js +29 -0
  18. package/dist/lib/sender.js +173 -0
  19. package/dist/lib/voice-id.js +22 -0
  20. package/dist/logger.js +32 -0
  21. package/dist/sync/markdown.js +151 -0
  22. package/dist/tools/remember.js +97 -0
  23. package/dist/tools/search.js +87 -0
  24. package/openclaw.plugin.json +25 -1
  25. package/package.json +7 -12
  26. package/client.ts +0 -566
  27. package/commands/setup.ts +0 -673
  28. package/commands/slash.ts +0 -198
  29. package/config.test.ts +0 -202
  30. package/config.ts +0 -497
  31. package/graph/index.ts +0 -5
  32. package/graph/ops.ts +0 -259
  33. package/graph/state.ts +0 -79
  34. package/graph/tools.ts +0 -117
  35. package/hooks/auto-context.ts +0 -272
  36. package/hooks/auto-trace.test.ts +0 -81
  37. package/hooks/auto-trace.ts +0 -197
  38. package/hooks/emotional-state.test.ts +0 -160
  39. package/hooks/emotional-state.ts +0 -179
  40. package/hooks/memory-sync.ts +0 -65
  41. package/index.ts +0 -152
  42. package/lib/browser.ts +0 -31
  43. package/lib/sender.test.ts +0 -234
  44. package/lib/sender.ts +0 -234
  45. package/lib/voice-id.ts +0 -39
  46. package/logger.ts +0 -41
  47. package/sync/markdown.ts +0 -186
  48. package/tools/remember.ts +0 -132
  49. package/tools/search.ts +0 -131
  50. package/types/openclaw.d.ts +0 -76
@@ -1,179 +0,0 @@
1
- import type { HyperspellClient } from "../client.ts";
2
- import type { HyperspellConfig } from "../config.ts";
3
- import { log } from "../logger.ts";
4
- import { sanitizeTraceText } from "./auto-trace.ts";
5
-
6
- type Message = { role?: string; content?: string | unknown };
7
- type AgentContext = { sessionKey?: string };
8
-
9
- const MIN_MESSAGES = 3;
10
- const MIN_CONVERSATION_LENGTH = 100;
11
-
12
- /**
13
- * Sessions where emotional context has already been injected this run.
14
- * Emotional state doesn't change within a session (it's extracted at
15
- * agent_end and surfaces on the *next* session), so re-fetching and
16
- * re-injecting on every turn is pure cost — one API call and a few
17
- * hundred tokens of repeated wrapper per turn.
18
- *
19
- * Lifecycle:
20
- * - first before_agent_start in a session: fetch, inject, mark.
21
- * - subsequent turns in same session: skip (return undefined).
22
- * - after_compaction: clear the mark so the next turn re-injects (the
23
- * initial injection may have been compacted out of history).
24
- * - session_end: clean up to prevent unbounded Set growth.
25
- */
26
- const injectedSessions = new Set<string>();
27
-
28
- /**
29
- * Extract readable text from a message content, unwrapping the common
30
- * `[{ type: "text", text: "..." }]` array shape so sanitizeTraceText can
31
- * operate on real text (not JSON-stringified content where newlines would
32
- * be escaped and regex line-anchors wouldn't match).
33
- */
34
- function contentToText(content: unknown): string {
35
- if (typeof content === "string") return content;
36
- if (Array.isArray(content)) {
37
- const texts: string[] = [];
38
- for (const item of content) {
39
- if (
40
- item &&
41
- typeof item === "object" &&
42
- (item as { type?: unknown }).type === "text" &&
43
- typeof (item as { text?: unknown }).text === "string"
44
- ) {
45
- texts.push((item as { text: string }).text);
46
- }
47
- }
48
- if (texts.length > 0) return texts.join("\n");
49
- }
50
- return "";
51
- }
52
-
53
- function messagesToTranscript(messages: unknown[]): string {
54
- const lines: string[] = [];
55
- for (const m of messages as Message[]) {
56
- if (!m.role || !m.content) continue;
57
- if (m.role === "system") continue;
58
- const raw = contentToText(m.content);
59
- if (!raw) continue;
60
- const cleaned = sanitizeTraceText(raw);
61
- if (cleaned.length === 0) continue;
62
- lines.push(`${m.role}: ${cleaned}`);
63
- }
64
- return lines.join("\n");
65
- }
66
-
67
- /**
68
- * Fetch emotional state on the first agent turn of a session and inject into
69
- * context. On later turns of the same session, return undefined — the
70
- * injection from the first turn is already in the conversation history.
71
- *
72
- * Runs on `before_agent_start` (which fires every turn).
73
- */
74
- export function buildEmotionalStateFetchHandler(
75
- client: HyperspellClient,
76
- cfg: HyperspellConfig,
77
- ) {
78
- return async (_event: Record<string, unknown>, ctx?: AgentContext) => {
79
- const sessionKey = ctx?.sessionKey;
80
- if (sessionKey && injectedSessions.has(sessionKey)) {
81
- return;
82
- }
83
-
84
- try {
85
- const state = await client.getEmotionalState(cfg.relationshipId);
86
-
87
- if (!state) {
88
- log.debug("emotional-context: no prior emotional state found");
89
- if (sessionKey) injectedSessions.add(sessionKey);
90
- return;
91
- }
92
-
93
- log.debug(`emotional-context: injecting state from ${state.extractedAt}`);
94
-
95
- const context = [
96
- "<hyperspell-emotional-context>",
97
- "The following captures the emotional register of your relationship with this user from your last interaction. Let it inform your tone — don't reference it explicitly.",
98
- "",
99
- state.summary,
100
- "</hyperspell-emotional-context>",
101
- ].join("\n");
102
-
103
- if (sessionKey) injectedSessions.add(sessionKey);
104
- return { prependContext: context };
105
- } catch (err) {
106
- log.error("emotional-context fetch failed", err);
107
- return;
108
- }
109
- };
110
- }
111
-
112
- /**
113
- * After compaction, the emotional-context block from the first turn may have
114
- * been trimmed out of history. Clear the cache so the next turn re-injects.
115
- */
116
- export function buildEmotionalStateCompactionHandler() {
117
- return async (_event: Record<string, unknown>, ctx?: AgentContext) => {
118
- const sessionKey = ctx?.sessionKey;
119
- if (sessionKey && injectedSessions.delete(sessionKey)) {
120
- log.debug(
121
- `emotional-context: cache cleared after compaction (session=${sessionKey})`,
122
- );
123
- }
124
- };
125
- }
126
-
127
- /**
128
- * Remove session from the inject-once cache when the session ends, to keep the
129
- * Set from growing unbounded over process lifetime.
130
- */
131
- export function buildEmotionalStateSessionCleanupHandler() {
132
- return async (_event: Record<string, unknown>, ctx?: AgentContext) => {
133
- const sessionKey = ctx?.sessionKey;
134
- if (sessionKey) injectedSessions.delete(sessionKey);
135
- };
136
- }
137
-
138
- /**
139
- * Extract and store emotional state at session end.
140
- * Runs on `agent_end` — fire-and-forget.
141
- */
142
- export function buildEmotionalStateStoreHandler(
143
- client: HyperspellClient,
144
- cfg: HyperspellConfig,
145
- ) {
146
- return async (event: Record<string, unknown>) => {
147
- if (event.success === false) {
148
- log.debug("emotional-state: skipping — agent ended with error");
149
- return;
150
- }
151
-
152
- const messages = event.messages as unknown[] | undefined;
153
- if (!messages || messages.length < MIN_MESSAGES) {
154
- log.debug(
155
- `emotional-state: skipping — too few messages (${messages?.length ?? 0})`,
156
- );
157
- return;
158
- }
159
-
160
- const transcript = messagesToTranscript(messages);
161
- if (transcript.length < MIN_CONVERSATION_LENGTH) {
162
- log.debug(
163
- `emotional-state: skipping — conversation too short (${transcript.length} chars)`,
164
- );
165
- return;
166
- }
167
-
168
- try {
169
- const result = await client.storeEmotionalState(transcript, {
170
- relationshipId: cfg.relationshipId,
171
- metadata: { source: "openclaw_agent_end" },
172
- });
173
- log.info(`emotional-state: stored ${result.resourceId}`);
174
- } catch (err) {
175
- // Fire-and-forget — never let this break the session
176
- log.error("emotional-state store failed", err);
177
- }
178
- };
179
- }
@@ -1,65 +0,0 @@
1
- import * as path from "node:path"
2
- import type { HyperspellClient } from "../client.ts"
3
- import type { HyperspellConfig } from "../config.ts"
4
- import { getWorkspaceDir } from "../config.ts"
5
- import { log } from "../logger.ts"
6
- import { syncMarkdownFile, syncAllMemoryFiles } from "../sync/markdown.ts"
7
-
8
- /**
9
- * Build a handler for file change events that syncs markdown files to Hyperspell
10
- */
11
- export function buildFileSyncHandler(client: HyperspellClient, cfg: HyperspellConfig) {
12
- const workspaceDir = getWorkspaceDir()
13
- const memoryDir = path.join(workspaceDir, "memory")
14
- const syncUserId = cfg.multiUser?.sharedUserId
15
-
16
- return async (event: Record<string, unknown>) => {
17
- const filePath = event.file_path as string | undefined
18
- if (!filePath) return
19
-
20
- // Only process markdown files in the workspace's memory directory
21
- if (!filePath.startsWith(memoryDir) || !filePath.endsWith(".md")) {
22
- return
23
- }
24
-
25
- const fileName = path.basename(filePath)
26
- log.info(`Memory file changed: ${fileName}`)
27
-
28
- try {
29
- const result = await syncMarkdownFile(client, filePath, { userId: syncUserId })
30
- if (result.success) {
31
- log.info(`Synced ${fileName} -> ${result.resourceId}`)
32
- } else {
33
- log.error(`Failed to sync ${fileName}: ${result.error}`)
34
- }
35
- } catch (err) {
36
- log.error(`Error syncing ${fileName}`, err)
37
- }
38
- }
39
- }
40
-
41
- /**
42
- * Sync all existing memory files on startup
43
- */
44
- export async function syncMemoriesOnStartup(
45
- client: HyperspellClient,
46
- workspaceDir: string,
47
- options?: { userId?: string },
48
- ): Promise<void> {
49
- log.info("Syncing existing memory files...")
50
-
51
- const result = await syncAllMemoryFiles(client, workspaceDir, { userId: options?.userId })
52
-
53
- if (result.synced > 0) {
54
- log.info(`Synced ${result.synced} memory files`)
55
- }
56
- if (result.failed > 0) {
57
- log.error(`Failed to sync ${result.failed} files:`)
58
- for (const error of result.errors) {
59
- log.error(` - ${error}`)
60
- }
61
- }
62
- if (result.synced === 0 && result.failed === 0) {
63
- log.info("No memory files found in memory/ directory")
64
- }
65
- }
package/index.ts DELETED
@@ -1,152 +0,0 @@
1
- import type { OpenClawPluginApi } from "openclaw/plugin-sdk"
2
- import { HyperspellClient } from "./client.ts"
3
- import { registerCommands } from "./commands/slash.ts"
4
- import { registerCliCommands } from "./commands/setup.ts"
5
- import { parseConfig, hyperspellConfigSchema, getWorkspaceDir } from "./config.ts"
6
- import { buildAutoContextHandler } from "./hooks/auto-context.ts"
7
- import { buildAutoTraceHandler } from "./hooks/auto-trace.ts"
8
- import {
9
- buildEmotionalStateCompactionHandler,
10
- buildEmotionalStateFetchHandler,
11
- buildEmotionalStateSessionCleanupHandler,
12
- buildEmotionalStateStoreHandler,
13
- } from "./hooks/emotional-state.ts"
14
- import { buildFileSyncHandler, syncMemoriesOnStartup } from "./hooks/memory-sync.ts"
15
- import { initLogger } from "./logger.ts"
16
- import { createRememberToolFactory } from "./tools/remember.ts"
17
- import { createSearchToolFactory } from "./tools/search.ts"
18
- import { registerNetworkTools } from "./graph/index.ts"
19
-
20
- export default {
21
- id: "openclaw-hyperspell",
22
- name: "Hyperspell",
23
- description:
24
- "Hyperspell gives your Molty context and memory from all your existing data",
25
- kind: "memory" as const,
26
- configSchema: hyperspellConfigSchema,
27
-
28
- register(api: OpenClawPluginApi) {
29
- // Register CLI commands (openclaw openclaw-hyperspell setup|status|connect)
30
- api.registerCli(
31
- (ctx) => {
32
- registerCliCommands(ctx.program, api.pluginConfig);
33
- },
34
- { commands: ["openclaw-hyperspell"] },
35
- );
36
-
37
- // Check if configured
38
- const rawConfig = api.pluginConfig as Record<string, unknown> | undefined;
39
- const hasConfig = rawConfig?.apiKey || process.env.HYPERSPELL_API_KEY;
40
-
41
- if (!hasConfig) {
42
- api.logger.info(
43
- "hyperspell: not configured - run 'openclaw openclaw-hyperspell setup'",
44
- )
45
- // Still register slash commands so they show up, but they'll return an error
46
- api.registerCommand({
47
- name: "getcontext",
48
- description: "Search your memories for relevant context",
49
- acceptsArgs: true,
50
- requireAuth: false,
51
- handler: async () => {
52
- return {
53
- text: "Hyperspell not configured. Run 'openclaw openclaw-hyperspell setup' first.",
54
- }
55
- },
56
- })
57
- api.registerCommand({
58
- name: "remember",
59
- description: "Save something to memory",
60
- acceptsArgs: true,
61
- requireAuth: false,
62
- handler: async () => {
63
- return {
64
- text: "Hyperspell not configured. Run 'openclaw openclaw-hyperspell setup' first.",
65
- }
66
- },
67
- })
68
- api.registerCommand({
69
- name: "sync",
70
- description: "Sync memory/*.md files with Hyperspell",
71
- acceptsArgs: false,
72
- requireAuth: false,
73
- handler: async () => {
74
- return {
75
- text: "Hyperspell not configured. Run 'openclaw openclaw-hyperspell setup' first.",
76
- }
77
- },
78
- })
79
- return
80
- }
81
-
82
- const cfg = parseConfig(api.pluginConfig);
83
-
84
- initLogger(api.logger, cfg.debug);
85
-
86
- const client = new HyperspellClient(cfg);
87
-
88
- // Register AI tools (factory pattern for sender context)
89
- api.registerTool(createSearchToolFactory(client, cfg), {
90
- name: "hyperspell_search",
91
- });
92
- api.registerTool(createRememberToolFactory(client, cfg), {
93
- name: "hyperspell_remember",
94
- });
95
-
96
- // Register emotional context hooks.
97
- // - fetch: inject once per session on first turn (cached thereafter)
98
- // - compaction: clear cache so the next turn re-injects after trim
99
- // - session cleanup: drop Set entry on session end
100
- // - store: extract new emotional state from the finished session
101
- if (cfg.emotionalContext) {
102
- api.on("before_agent_start", buildEmotionalStateFetchHandler(client, cfg));
103
- api.on("after_compaction", buildEmotionalStateCompactionHandler());
104
- api.on("session_end", buildEmotionalStateSessionCleanupHandler());
105
- api.on("agent_end", buildEmotionalStateStoreHandler(client, cfg));
106
- }
107
-
108
- // Register auto-context hook
109
- if (cfg.autoContext) {
110
- const autoContextHandler = buildAutoContextHandler(client, cfg);
111
- api.on("before_agent_start", autoContextHandler);
112
- }
113
-
114
- // Register auto-trace hook (send conversations to Hyperspell on session end)
115
- if (cfg.autoTrace.enabled) {
116
- api.on("agent_end", buildAutoTraceHandler(client, cfg));
117
- }
118
-
119
- // Register memory sync hook
120
- if (cfg.syncMemories) {
121
- const fileSyncHandler = buildFileSyncHandler(client, cfg);
122
- api.on("file_changed", fileSyncHandler);
123
- }
124
-
125
- // Register memory network tools
126
- if (cfg.knowledgeGraph.enabled) {
127
- registerNetworkTools(api, client, cfg);
128
- }
129
-
130
- // Register slash commands
131
- registerCommands(api, client, cfg);
132
-
133
- // Register service for lifecycle management
134
- api.registerService({
135
- id: "openclaw-hyperspell",
136
- start: async () => {
137
- api.logger.info("hyperspell: connected");
138
-
139
- // Sync memories on startup if enabled
140
- if (cfg.syncMemories) {
141
- const workspaceDir = getWorkspaceDir();
142
- await syncMemoriesOnStartup(client, workspaceDir, {
143
- userId: cfg.multiUser?.sharedUserId,
144
- });
145
- }
146
- },
147
- stop: () => {
148
- api.logger.info("hyperspell: stopped");
149
- },
150
- });
151
- },
152
- };
package/lib/browser.ts DELETED
@@ -1,31 +0,0 @@
1
- import { execFile } from "node:child_process"
2
- import { platform } from "node:os"
3
-
4
- export function openInBrowser(url: string): Promise<void> {
5
- return new Promise((resolve, reject) => {
6
- let file: string
7
- let args: string[]
8
-
9
- switch (platform()) {
10
- case "darwin":
11
- file = "open"
12
- args = [url]
13
- break
14
- case "win32":
15
- file = "cmd"
16
- args = ["/c", "start", "", url]
17
- break
18
- default:
19
- file = "xdg-open"
20
- args = [url]
21
- }
22
-
23
- execFile(file, args, (error) => {
24
- if (error) {
25
- reject(error)
26
- } else {
27
- resolve()
28
- }
29
- })
30
- })
31
- }
@@ -1,234 +0,0 @@
1
- import assert from "node:assert/strict"
2
- import { test } from "node:test"
3
- import type { HyperspellConfig } from "../config.ts"
4
- import {
5
- buildScopeFilter,
6
- getCanReadScopes,
7
- getDefaultWriteScope,
8
- resolveRole,
9
- routeWrite,
10
- } from "./sender.ts"
11
-
12
- function cfg(overrides: Partial<HyperspellConfig["multiUser"]> = {}): HyperspellConfig {
13
- return {
14
- apiKey: "test",
15
- autoContext: false,
16
- autoTrace: { enabled: false, extract: ["procedure"] },
17
- emotionalContext: false,
18
- syncMemories: false,
19
- sources: [],
20
- maxResults: 5,
21
- relevanceThreshold: 0.6,
22
- debug: false,
23
- knowledgeGraph: { enabled: false, scanIntervalMinutes: 60, batchSize: 20 },
24
- multiUser: overrides.scoping
25
- ? {
26
- sharedUserId: "shared",
27
- includeSharedInSearch: true,
28
- senderMap: overrides.senderMap ?? {},
29
- scoping: overrides.scoping,
30
- }
31
- : undefined,
32
- }
33
- }
34
-
35
- test("buildScopeFilter — wildcard returns undefined", () => {
36
- assert.equal(buildScopeFilter(["*"], "u1"), undefined)
37
- })
38
-
39
- test("buildScopeFilter — empty returns match-nothing filter (not undefined)", () => {
40
- const f = buildScopeFilter([], "u1")
41
- assert.deepEqual(f, { openclaw_scope: "__never__" })
42
- })
43
-
44
- test("buildScopeFilter — single named scope collapses $or", () => {
45
- const f = buildScopeFilter(["family"], "u1")
46
- assert.deepEqual(f, { openclaw_scope: { $in: ["family"] } })
47
- })
48
-
49
- test("buildScopeFilter — multiple named scopes", () => {
50
- const f = buildScopeFilter(["family", "kid_shared"], "u1")
51
- assert.deepEqual(f, { openclaw_scope: { $in: ["family", "kid_shared"] } })
52
- })
53
-
54
- test("buildScopeFilter — scopes plus self produces $or", () => {
55
- const f = buildScopeFilter(["family", "self"], "u1")
56
- assert.deepEqual(f, {
57
- $or: [
58
- { openclaw_scope: { $in: ["family"] } },
59
- { openclaw_user: "u1" },
60
- ],
61
- })
62
- })
63
-
64
- test("buildScopeFilter — hyphenated scope normalized in metadata", () => {
65
- const f = buildScopeFilter(["parent-only"], "u1")
66
- assert.deepEqual(f, { openclaw_scope: { $in: ["parent_only"] } })
67
- })
68
-
69
- test("buildScopeFilter — self only (no named scopes)", () => {
70
- const f = buildScopeFilter(["self"], "u1")
71
- assert.deepEqual(f, { openclaw_user: "u1" })
72
- })
73
-
74
- test("buildScopeFilter — self only with empty userId → match-nothing", () => {
75
- const f = buildScopeFilter(["self"], "")
76
- // Empty userId cannot build a self clause; no named scopes either → match-nothing
77
- assert.deepEqual(f, { openclaw_scope: "__never__" })
78
- })
79
-
80
- test("getCanReadScopes — scoping absent returns wildcard", () => {
81
- const c = cfg()
82
- const canRead = getCanReadScopes(
83
- { userId: "u1", name: "U1", resolved: true },
84
- c,
85
- )
86
- assert.deepEqual(canRead, ["*"])
87
- })
88
-
89
- test("getCanReadScopes — role from scoping.users", () => {
90
- const c = cfg({
91
- senderMap: { "u1-phone": { userId: "u1", name: "U1" } },
92
- scoping: {
93
- enabled: true,
94
- defaultScope: "private",
95
- scopes: ["private", "family"],
96
- roles: {
97
- kid: { canRead: ["family"], defaultWriteScope: "family" },
98
- },
99
- users: { u1: { role: "kid" } },
100
- },
101
- })
102
- const canRead = getCanReadScopes(
103
- { userId: "u1", name: "U1", resolved: true },
104
- c,
105
- )
106
- assert.deepEqual(canRead, ["family"])
107
- })
108
-
109
- test("getCanReadScopes — missing role returns empty (no access)", () => {
110
- const c = cfg({
111
- senderMap: { "u1-phone": { userId: "u1", name: "U1" } },
112
- scoping: {
113
- enabled: true,
114
- defaultScope: "private",
115
- scopes: ["private", "family"],
116
- roles: {
117
- kid: { canRead: ["family"], defaultWriteScope: "family" },
118
- },
119
- users: {}, // u1 has no role assignment
120
- },
121
- })
122
- const canRead = getCanReadScopes(
123
- { userId: "u1", name: "U1", resolved: true },
124
- c,
125
- )
126
- assert.deepEqual(canRead, [])
127
- })
128
-
129
- test("getDefaultWriteScope — role default wins", () => {
130
- const c = cfg({
131
- senderMap: { "u1-phone": { userId: "u1", name: "U1" } },
132
- scoping: {
133
- enabled: true,
134
- defaultScope: "private",
135
- scopes: ["private", "family"],
136
- roles: {
137
- kid: { canRead: ["family"], defaultWriteScope: "family" },
138
- },
139
- users: { u1: { role: "kid" } },
140
- },
141
- })
142
- const scope = getDefaultWriteScope(
143
- { userId: "u1", name: "U1", resolved: true },
144
- c,
145
- )
146
- assert.equal(scope, "family")
147
- })
148
-
149
- test("getDefaultWriteScope — falls back to global default when no role", () => {
150
- const c = cfg({
151
- senderMap: { "u1-phone": { userId: "u1", name: "U1" } },
152
- scoping: {
153
- enabled: true,
154
- defaultScope: "private",
155
- scopes: ["private", "family"],
156
- roles: {},
157
- users: {},
158
- },
159
- })
160
- const scope = getDefaultWriteScope(
161
- { userId: "u1", name: "U1", resolved: true },
162
- c,
163
- )
164
- assert.equal(scope, "private")
165
- })
166
-
167
- test("getDefaultWriteScope — profile-level role override", () => {
168
- const c = cfg({
169
- senderMap: { "u1-phone": { userId: "u1", name: "U1", role: "parent" } },
170
- scoping: {
171
- enabled: true,
172
- defaultScope: "private",
173
- scopes: ["private", "family"],
174
- roles: {
175
- parent: { canRead: ["*"], defaultWriteScope: "private" },
176
- kid: { canRead: ["family"], defaultWriteScope: "family" },
177
- },
178
- users: { u1: { role: "kid" } }, // users table says kid but profile override is parent
179
- },
180
- })
181
- const scope = getDefaultWriteScope(
182
- { userId: "u1", name: "U1", resolved: true, role: "parent" },
183
- c,
184
- )
185
- assert.equal(scope, "private")
186
- })
187
-
188
- test("resolveRole — returns undefined when scoping absent", () => {
189
- const c = cfg()
190
- const role = resolveRole(
191
- { userId: "u1", name: "U1", resolved: true },
192
- c,
193
- )
194
- assert.equal(role, undefined)
195
- })
196
-
197
- test("routeWrite — private goes to user's own space", () => {
198
- const c = cfg({
199
- senderMap: { "u1-phone": { userId: "u1", name: "U1" } },
200
- scoping: {
201
- enabled: true,
202
- defaultScope: "private",
203
- scopes: ["private", "family"],
204
- roles: { kid: { canRead: ["family"], defaultWriteScope: "family" } },
205
- users: { u1: { role: "kid" } },
206
- },
207
- })
208
- const r = routeWrite(
209
- { userId: "u1", name: "U1", resolved: true },
210
- "private",
211
- c,
212
- )
213
- assert.deepEqual(r, { userId: "u1", collection: undefined })
214
- })
215
-
216
- test("routeWrite — non-private goes to sharedUserId with optional collection", () => {
217
- const c = cfg({
218
- senderMap: { "u1-phone": { userId: "u1", name: "U1" } },
219
- scoping: {
220
- enabled: true,
221
- defaultScope: "private",
222
- scopes: ["private", "family"],
223
- roles: { kid: { canRead: ["family"], defaultWriteScope: "family" } },
224
- users: { u1: { role: "kid" } },
225
- collections: { family: "household-shared" },
226
- },
227
- })
228
- const r = routeWrite(
229
- { userId: "u1", name: "U1", resolved: true },
230
- "family",
231
- c,
232
- )
233
- assert.deepEqual(r, { userId: "shared", collection: "household-shared" })
234
- })