@coffer-org/server 7.2.0 → 7.3.0

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 (57) hide show
  1. package/dist/auth-api.d.ts +4 -0
  2. package/dist/auth-api.js +53 -0
  3. package/dist/auth-store.d.ts +2 -0
  4. package/dist/auth-store.js +1 -0
  5. package/dist/entity-schema.d.ts +2 -0
  6. package/dist/entity-schema.js +26 -0
  7. package/dist/identity-link.d.ts +15 -0
  8. package/dist/identity-link.js +76 -0
  9. package/dist/index.js +8 -1
  10. package/dist/mcp-http.js +7 -3
  11. package/dist/mcp-tools.d.ts +4 -3
  12. package/dist/mcp-tools.js +84 -84
  13. package/dist/media/image.d.ts +23 -0
  14. package/dist/media/image.js +103 -0
  15. package/dist/media/index.d.ts +1 -0
  16. package/dist/media/index.js +1 -0
  17. package/dist/migrations.js +1 -1
  18. package/dist/orchestrator/agent-capabilities.d.ts +2 -2
  19. package/dist/orchestrator/agent-capabilities.js +3 -3
  20. package/dist/orchestrator/allow.d.ts +1 -16
  21. package/dist/orchestrator/allow.js +3 -53
  22. package/dist/orchestrator/config.js +0 -1
  23. package/dist/orchestrator/context-facts.d.ts +27 -0
  24. package/dist/orchestrator/context-facts.js +89 -0
  25. package/dist/orchestrator/conversation-access.d.ts +9 -0
  26. package/dist/orchestrator/conversation-access.js +12 -0
  27. package/dist/orchestrator/environment.d.ts +1 -0
  28. package/dist/orchestrator/environment.js +10 -0
  29. package/dist/orchestrator/file-inspection.d.ts +2 -2
  30. package/dist/orchestrator/file-inspection.js +41 -19
  31. package/dist/orchestrator/index.d.ts +15 -9
  32. package/dist/orchestrator/index.js +13 -7
  33. package/dist/orchestrator/live-message.d.ts +7 -4
  34. package/dist/orchestrator/live-message.js +48 -31
  35. package/dist/orchestrator/pipeline.d.ts +25 -4
  36. package/dist/orchestrator/pipeline.js +214 -94
  37. package/dist/orchestrator/registry.d.ts +4 -2
  38. package/dist/orchestrator/registry.js +10 -1
  39. package/dist/orchestrator/system-areas.d.ts +12 -0
  40. package/dist/orchestrator/system-areas.js +63 -0
  41. package/dist/orchestrator/system-capabilities.js +1 -1
  42. package/dist/orchestrator/turn-context.d.ts +18 -0
  43. package/dist/orchestrator/turn-context.js +39 -0
  44. package/dist/orchestrator/types.d.ts +101 -44
  45. package/dist/plugin-hooks.d.ts +26 -0
  46. package/dist/plugin-http-mounts.d.ts +18 -0
  47. package/dist/plugin-http-mounts.js +94 -0
  48. package/dist/plugin-runtime.js +2 -2
  49. package/dist/records-api.js +15 -3
  50. package/dist/system-settings.js +0 -1
  51. package/dist/thread-state.d.ts +14 -0
  52. package/dist/thread-state.js +71 -11
  53. package/dist/thread-store.d.ts +5 -3
  54. package/dist/thread-store.js +12 -9
  55. package/dist/turn-gate.d.ts +8 -0
  56. package/dist/turn-gate.js +39 -0
  57. package/package.json +7 -2
@@ -0,0 +1,18 @@
1
+ import type { AuthRole } from '../plugin-hooks.ts';
2
+ export declare const PAUSE_THRESHOLD_MS: number;
3
+ export interface TurnSpeaker {
4
+ name: string;
5
+ role: AuthRole;
6
+ }
7
+ export interface TurnContextInput {
8
+ connectorLines: readonly string[];
9
+ speaker: TurnSpeaker;
10
+ previousSpeakerName: string | null;
11
+ now: Date;
12
+ timeZone: string;
13
+ previousMessageTs: number | null;
14
+ }
15
+ export declare const MAX_SPEAKER_NAME = 80;
16
+ export declare function oneLine(value: string, maxLen?: number): string;
17
+ export declare function humanizePause(ms: number): string;
18
+ export declare function buildTurnContext(input: TurnContextInput): string;
@@ -0,0 +1,39 @@
1
+ import { currentMoment } from "./environment.js";
2
+ export const PAUSE_THRESHOLD_MS = 10 * 60_000;
3
+ export const MAX_SPEAKER_NAME = 80;
4
+ const FORGERY_CHARS = /[\p{Cc}\p{Zl}\p{Zp}\p{Bidi_Control}]+/gu;
5
+ export function oneLine(value, maxLen) {
6
+ const flat = value.replace(FORGERY_CHARS, ' ').replace(/\s+/g, ' ').trim();
7
+ return maxLen !== undefined && flat.length > maxLen ? `${flat.slice(0, maxLen - 1).trimEnd()}…` : flat;
8
+ }
9
+ export function humanizePause(ms) {
10
+ const minutes = Math.round(ms / 60_000);
11
+ if (minutes < 90)
12
+ return `about ${minutes} minutes`;
13
+ const hours = Math.round(ms / 3_600_000);
14
+ if (hours < 48)
15
+ return `about ${hours} hours`;
16
+ return `about ${Math.round(ms / 86_400_000)} days`;
17
+ }
18
+ export function buildTurnContext(input) {
19
+ const name = oneLine(input.speaker.name, MAX_SPEAKER_NAME) || 'unknown';
20
+ const previousName = input.previousSpeakerName
21
+ ? oneLine(input.previousSpeakerName, MAX_SPEAKER_NAME) || 'unknown'
22
+ : null;
23
+ const who = `${name} (${input.speaker.role})`;
24
+ const lines = [
25
+ previousName ? `Speaking now: ${who} — the previous message was from ${previousName}.` : `Speaking now: ${who}.`,
26
+ `Current time: ${currentMoment(input.now, input.timeZone)} (${input.timeZone}).`,
27
+ ];
28
+ if (input.previousMessageTs !== null) {
29
+ const gap = input.now.getTime() - input.previousMessageTs * 1000;
30
+ if (gap >= PAUSE_THRESHOLD_MS)
31
+ lines.push(`Time since the previous message: ${humanizePause(gap)}.`);
32
+ }
33
+ for (const raw of input.connectorLines) {
34
+ const line = oneLine(raw);
35
+ if (line)
36
+ lines.push(line);
37
+ }
38
+ return lines.join('\n');
39
+ }
@@ -1,3 +1,10 @@
1
+ import type { ImageTarget } from '../media/index.ts';
2
+ import type { AuthRole } from '../plugin-hooks.ts';
3
+ import type { ContextFact } from './context-facts.ts';
4
+ import type { SystemAreas } from './system-areas.ts';
5
+ export type { ImageTarget };
6
+ export type { ContextFact };
7
+ export type { SystemAreas };
1
8
  export interface AttachmentRef {
2
9
  name: string;
3
10
  mime?: string;
@@ -9,7 +16,6 @@ export interface AgentToolDefinition {
9
16
  description: string;
10
17
  inputSchema: Record<string, unknown>;
11
18
  handler: (args: Record<string, unknown>, signal?: AbortSignal) => Promise<unknown>;
12
- forcedAfterAnswer?: boolean;
13
19
  }
14
20
  export interface AgentToolProvider {
15
21
  tools: AgentToolDefinition[];
@@ -34,18 +40,25 @@ export interface AgentToolContentResult {
34
40
  export interface ConvMessage {
35
41
  role: 'user' | 'assistant';
36
42
  content: string;
37
- context?: string;
43
+ context?: ContextFact[];
38
44
  attachments?: AttachmentRef[];
39
45
  sender?: string | null;
40
46
  msgId: string;
41
47
  ts: number;
42
48
  }
43
49
  export interface AgentCapabilities {
44
- vision?: boolean;
45
- documents?: boolean;
46
50
  tools?: boolean;
47
51
  reasoning?: boolean;
48
52
  }
53
+ export interface AgentMediaKind {
54
+ maxBytes: number;
55
+ accepts: readonly string[];
56
+ }
57
+ export interface AgentMediaLimits {
58
+ image: AgentMediaKind & ImageTarget;
59
+ document: AgentMediaKind;
60
+ text: AgentMediaKind;
61
+ }
49
62
  export interface AgentPreset {
50
63
  id: string;
51
64
  title: string;
@@ -60,38 +73,46 @@ export interface AgentDescriptor {
60
73
  title: string;
61
74
  presets: AgentPreset[];
62
75
  }
76
+ export interface AgentCatalogEntry extends AgentDescriptor {
77
+ media: AgentMediaLimits;
78
+ }
63
79
  export interface AgentBudget {
64
80
  responseTimeout?: number;
65
81
  toolRounds?: number;
66
82
  }
67
- export interface AgentRequest {
68
- system: string;
69
- messages: ConvMessage[];
83
+ export interface AgentTurn {
84
+ envelope: TurnEnvelope;
85
+ body: {
86
+ system: string[];
87
+ messages: ConvMessage[];
88
+ userTurns: number;
89
+ };
70
90
  toolProvider?: AgentToolProvider;
71
91
  presetId?: string;
72
92
  budget?: AgentBudget;
73
- onDelta?: (accumulated: string) => void;
74
- onReasoning?: (accumulated: string) => void;
75
- onSegment?: () => void;
76
- }
77
- export interface AgentResult {
78
- text: string | null;
79
- reasoning: string | null;
80
- tokensIn: number | null;
81
- tokensOut: number | null;
82
- stopReason: string | null;
83
- presetId: string | null;
84
- suggestions?: string[] | null;
93
+ senderRole?: AuthRole;
94
+ signal?: AbortSignal;
85
95
  }
86
96
  export interface AgentRuntime {
87
97
  id: string;
88
- run(request: AgentRequest): Promise<AgentResult>;
98
+ media: AgentMediaLimits;
99
+ run(turn: AgentTurn, emit: (e: TurnEvent) => void): Promise<void>;
89
100
  systemBase(): Promise<string>;
90
101
  describe(): Promise<AgentDescriptor>;
91
102
  starters?(hint: string): Promise<string[]>;
103
+ afterword?(turn: AgentTurn, answer: string, want: {
104
+ suggestions: boolean;
105
+ title: boolean;
106
+ }): Promise<{
107
+ suggestions?: string[];
108
+ title?: string;
109
+ tokensIn?: number;
110
+ tokensOut?: number;
111
+ }>;
92
112
  }
93
113
  export interface ConnectorRegistration {
94
114
  id: string;
115
+ linkUrl?(code: string): string | undefined;
95
116
  }
96
117
  export interface AttachmentMaterializer {
97
118
  store(bytes: Uint8Array, opts?: {
@@ -99,48 +120,84 @@ export interface AttachmentMaterializer {
99
120
  mime?: string;
100
121
  }): Promise<AttachmentRef>;
101
122
  }
102
- export interface IncomingConversation {
123
+ export type SenderIdKind = 'coffer-user' | 'transport';
124
+ export interface TurnEnvelope {
103
125
  connectorId: string;
126
+ chatId: string;
127
+ turnId: string;
128
+ }
129
+ export interface ConnectorCapabilities {
130
+ events: readonly ('delta' | 'reasoning' | 'segment' | 'suggestions' | 'title')[];
131
+ privateChats: boolean;
132
+ }
133
+ export interface TurnBody {
134
+ systemPrompt: SystemAreas;
135
+ messages: ConvMessage[];
136
+ capabilities: ConnectorCapabilities;
137
+ userTurns: number;
138
+ }
139
+ export interface TurnRequest {
140
+ envelope: TurnEnvelope;
141
+ body: TurnBody;
104
142
  agentId?: string;
105
143
  presetId?: string;
106
- chatId: string;
107
- channelSystem?: string;
108
- turnContext?: string;
109
144
  sender: {
110
145
  id: string;
111
146
  displayName?: string;
147
+ idKind?: SenderIdKind;
112
148
  };
113
- messages: ConvMessage[];
149
+ turnContext?: ContextFact[];
114
150
  prepareAttachments?: (materializer: AttachmentMaterializer) => Promise<ConvMessage[]>;
115
- supportsSuggestions?: boolean;
151
+ signal?: AbortSignal;
116
152
  }
117
- export interface ReplyContext {
118
- parentMsgId: string | null;
119
- }
120
- export interface ReplyPayload {
153
+ export type TurnEvent = {
154
+ kind: 'delta';
155
+ text: string;
156
+ } | {
157
+ kind: 'reasoning';
158
+ text: string;
159
+ } | {
160
+ kind: 'segment';
161
+ } | {
162
+ kind: 'answer';
121
163
  text: string | null;
122
164
  reasoning: string | null;
123
- suggestions: string[] | null;
124
- }
125
- export interface ReplyChannel {
126
- update(text: string): void;
127
- updateReasoning?(acc: string): void;
128
- segment(): void;
129
- finish(r: ReplyPayload): Promise<string | null>;
165
+ } | {
166
+ kind: 'usage';
167
+ tokensIn: number | null;
168
+ tokensOut: number | null;
169
+ presetId: string | null;
170
+ stopReason: string | null;
171
+ } | {
172
+ kind: 'error';
173
+ message: string;
174
+ } | {
175
+ kind: 'suggestions';
176
+ items: string[];
177
+ } | {
178
+ kind: 'title';
179
+ text: string;
180
+ } | {
181
+ kind: 'notice';
182
+ text: string;
183
+ };
184
+ export interface TurnSink {
185
+ emit(event: TurnEvent): void;
186
+ done(): Promise<void>;
130
187
  }
131
188
  export interface Connector {
132
189
  id: string;
133
- reply(chatId: string, ctx: ReplyContext): ReplyChannel;
134
- recordAssistant(m: {
135
- parentMsgId: string | null;
136
- botMsgId: string | null;
137
- text: string;
138
- reasoning: string | null;
190
+ enrolmentNotice: string;
191
+ open(envelope: TurnEnvelope): TurnSink;
192
+ recordContext(m: {
193
+ chatId: string;
194
+ userMsgId: string;
195
+ facts: ContextFact[];
196
+ ts: number;
139
197
  }): Promise<void>;
140
198
  }
141
199
  export interface GatePolicy {
142
200
  agentId?: string;
143
- accessPassword: string;
144
201
  triggerPrefix: string;
145
202
  replyWindow: number;
146
203
  }
@@ -70,5 +70,31 @@ export interface PluginHooks {
70
70
  actions?: Record<string, PluginAction>;
71
71
  userActions?: Record<string, PluginUserAction>;
72
72
  streamActions?: Record<string, PluginStreamAction>;
73
+ http?: PluginHttpMount[];
73
74
  }
74
75
  export declare const pluginHooks: Record<string, PluginHooks>;
76
+ export interface PluginHttpRequest {
77
+ method: string;
78
+ path: string;
79
+ url: string;
80
+ headers: Record<string, string>;
81
+ body: string;
82
+ user: {
83
+ id: number;
84
+ login: string;
85
+ role: AuthRole;
86
+ };
87
+ }
88
+ export interface PluginHttpResponse {
89
+ status: number;
90
+ headers?: Record<string, string>;
91
+ body?: string;
92
+ }
93
+ export interface PluginHttpMount {
94
+ prefix: string;
95
+ methods: string[];
96
+ auth: 'member' | 'admin' | 'token';
97
+ body?: 'text' | 'none';
98
+ wellKnown?: string;
99
+ handle(req: PluginHttpRequest): Promise<PluginHttpResponse>;
100
+ }
@@ -0,0 +1,18 @@
1
+ import type { FastifyInstance, FastifyRequest } from 'fastify';
2
+ import type { AuthRole, PluginHooks, PluginHttpMount } from './plugin-hooks.ts';
3
+ export interface MountEntry {
4
+ pluginId: string;
5
+ mount: PluginHttpMount;
6
+ }
7
+ export declare function collectHttpMounts(hooks: Record<string, PluginHooks>): MountEntry[];
8
+ export declare function customMethodsOf(entries: MountEntry[]): string[];
9
+ export declare function registerTextBodyParsers(app: FastifyInstance): void;
10
+ export interface MountDeps {
11
+ disabledSet: () => Promise<Set<string>>;
12
+ resolveUser: (req: FastifyRequest) => Promise<{
13
+ id: number;
14
+ login: string;
15
+ role: AuthRole;
16
+ } | null>;
17
+ }
18
+ export declare function registerPluginHttpMounts(app: FastifyInstance, entries: MountEntry[], deps: MountDeps): void;
@@ -0,0 +1,94 @@
1
+ const RESERVED = ['/api', '/mcp', '/health', '/uploads', '/.well-known', '/assets'];
2
+ const DEFAULT_METHODS = new Set(['GET', 'HEAD', 'POST', 'PUT', 'DELETE', 'OPTIONS', 'PATCH']);
3
+ export function collectHttpMounts(hooks) {
4
+ const out = [];
5
+ const owner = new Map();
6
+ for (const [pluginId, h] of Object.entries(hooks)) {
7
+ for (const mount of h.http ?? []) {
8
+ const prefix = mount.prefix.replace(/\/+$/, '');
9
+ if (!prefix.startsWith('/') || prefix === '') {
10
+ throw new Error(`[plugin-http] ${pluginId}: prefix must be absolute, got '${mount.prefix}'`);
11
+ }
12
+ if (RESERVED.some((r) => prefix === r || prefix.startsWith(r + '/'))) {
13
+ throw new Error(`[plugin-http] ${pluginId}: prefix '${prefix}' is reserved for the core`);
14
+ }
15
+ const other = owner.get(prefix);
16
+ if (other)
17
+ throw new Error(`[plugin-http] prefix '${prefix}' is declared by both ${other} and ${pluginId}`);
18
+ owner.set(prefix, pluginId);
19
+ out.push({ pluginId, mount: { ...mount, prefix, methods: mount.methods.map((m) => m.toUpperCase()) } });
20
+ }
21
+ }
22
+ return out;
23
+ }
24
+ export function customMethodsOf(entries) {
25
+ const seen = new Set();
26
+ for (const e of entries)
27
+ for (const m of e.mount.methods)
28
+ if (!DEFAULT_METHODS.has(m))
29
+ seen.add(m);
30
+ return [...seen];
31
+ }
32
+ export function registerTextBodyParsers(app) {
33
+ const asText = (_req, body, done) => done(null, body);
34
+ for (const type of ['text/calendar', 'text/xml', 'application/xml', 'text/plain', 'application/octet-stream']) {
35
+ if (!app.hasContentTypeParser(type))
36
+ app.addContentTypeParser(type, { parseAs: 'string' }, asText);
37
+ }
38
+ }
39
+ function flatHeaders(raw) {
40
+ const out = {};
41
+ for (const [k, v] of Object.entries(raw)) {
42
+ if (v === undefined)
43
+ continue;
44
+ out[k.toLowerCase()] = Array.isArray(v) ? v.join(', ') : String(v);
45
+ }
46
+ return out;
47
+ }
48
+ export function registerPluginHttpMounts(app, entries, deps) {
49
+ for (const method of customMethodsOf(entries))
50
+ app.addHttpMethod(method, { hasBody: true });
51
+ for (const { pluginId, mount } of entries) {
52
+ const allow = mount.methods.join(', ');
53
+ const handler = async (req, reply) => {
54
+ if ((await deps.disabledSet()).has(pluginId))
55
+ return reply.code(404).send({ error: 'not_found' });
56
+ if (!mount.methods.includes(req.method.toUpperCase())) {
57
+ return reply.code(405).header('allow', allow).send();
58
+ }
59
+ const user = req.user ?? (await deps.resolveUser(req));
60
+ if (!user) {
61
+ if (mount.auth === 'token')
62
+ reply.header('www-authenticate', 'Basic realm="Coffer"');
63
+ return reply.code(401).send({ error: 'unauthorized' });
64
+ }
65
+ if (mount.auth === 'admin' && user.role !== 'admin')
66
+ return reply.code(403).send({ error: 'forbidden' });
67
+ const full = req.url.split('?')[0];
68
+ const below = full.slice(mount.prefix.length);
69
+ const body = mount.body === 'none' ? '' : typeof req.body === 'string' ? req.body : req.body ? JSON.stringify(req.body) : '';
70
+ const res = await mount.handle({
71
+ method: req.method.toUpperCase(),
72
+ path: below === '' ? '/' : below,
73
+ url: req.url,
74
+ headers: flatHeaders(req.headers),
75
+ body,
76
+ user: { id: user.id, login: user.login, role: user.role },
77
+ });
78
+ reply.code(res.status);
79
+ for (const [k, v] of Object.entries(res.headers ?? {}))
80
+ reply.header(k, v);
81
+ return reply.send(res.body ?? '');
82
+ };
83
+ const verbs = [...new Set([...mount.methods, ...DEFAULT_METHODS])];
84
+ app.route({ method: verbs, url: mount.prefix, handler });
85
+ app.route({ method: verbs, url: `${mount.prefix}/*`, handler });
86
+ if (mount.wellKnown) {
87
+ app.route({
88
+ method: verbs,
89
+ url: `/.well-known/${mount.wellKnown}`,
90
+ handler: async (_req, reply) => reply.code(301).header('location', `${mount.prefix}/`).send(),
91
+ });
92
+ }
93
+ }
94
+ }
@@ -13,7 +13,7 @@ import { migrateEmbeddingVectorsToBlob } from "./embeddings.js";
13
13
  import { ensureSearchTable } from "./search-index.js";
14
14
  import { startScheduler, stopScheduler } from "./background-scheduler.js";
15
15
  import { startSearchIndexer, indexSearchOnce } from "./search-indexer.js";
16
- import { startOrchestrator, stopOrchestrator, orchestratorStartersTask } from "./orchestrator/index.js";
16
+ import { startOrchestrator, stopOrchestrator, orchestratorStartersTask, linkCodePruneTask, } from "./orchestrator/index.js";
17
17
  import { SYSTEM_SETTINGS_ID, SYSTEM_SETTINGS } from "./system-settings.js";
18
18
  const log = getLogger('plugins');
19
19
  let stopSearchIndexer;
@@ -110,7 +110,7 @@ export async function initPlugins() {
110
110
  Object.assign(pluginHooks, await loadServerHooks());
111
111
  await runSeeds({ em: getEm().fork(), plugins: reg.order, hooks: pluginHooks });
112
112
  startOrchestrator();
113
- const bgTasks = [orchestratorStartersTask];
113
+ const bgTasks = [orchestratorStartersTask, linkCodePruneTask];
114
114
  for (const p of reg.order) {
115
115
  const h = pluginHooks[p.id];
116
116
  try {
@@ -1,3 +1,5 @@
1
+ import { isJsonArrayStored } from '@coffer-org/sdk/fields';
2
+ import { raw } from '@mikro-orm/core';
1
3
  import { fieldMap, textSearchKeys, titleKey, recordTitle, listKeys, storageColumnsFor, magnitudeSub, resolveColumnKey, } from '@coffer-org/sdk/shelf';
2
4
  import { tokenize, matchScoreFolded, foldText } from '@coffer-org/sdk/search';
3
5
  import { getActiveRegistry, getShelf, getExtendsFor } from "./registry-context.js";
@@ -97,7 +99,7 @@ function resolveFilterKey(m, fm, k) {
97
99
  const direct = fm[k];
98
100
  if (direct) {
99
101
  if (!direct.columns)
100
- return { column: k, type: direct.column };
102
+ return { column: k, type: direct.column, json: isJsonArrayStored(direct) };
101
103
  const sub = magnitudeSub(direct);
102
104
  if (sub === undefined)
103
105
  throw new FilterError(`${m.library}/${m.shelf}: '${k}' is a composite with no stored part — it owns no column to filter on`);
@@ -110,14 +112,24 @@ function resolveFilterKey(m, fm, k) {
110
112
  throw new FilterError(`${m.library}/${m.shelf}: '${k}' names no part of composite '${owner.key}' (parts: ${Object.keys(owner.field.columns).join(', ')})`);
111
113
  return { column: k, type: owner.field.columns[owner.sub] };
112
114
  }
115
+ function jsonMemberClause(column, value) {
116
+ const col = `"${column.replace(/"/g, '""')}"`;
117
+ return {
118
+ [raw(`(json_valid(${col}) AND EXISTS (SELECT 1 FROM json_each(${col}) WHERE CAST(value AS TEXT) = ?))`, [value])]: 1,
119
+ };
120
+ }
113
121
  function buildWhere(m, filterParams) {
114
- const where = {};
122
+ let where = {};
115
123
  const fm = fieldMap(m.fields);
116
124
  for (const [k, v] of Object.entries(filterParams)) {
117
125
  if (k === 'id' || v === undefined)
118
126
  continue;
119
127
  const target = resolveFilterKey(m, fm, k);
120
- if (target)
128
+ if (!target)
129
+ continue;
130
+ if (target.json)
131
+ where = { ...where, ...jsonMemberClause(target.column, String(v)) };
132
+ else
121
133
  where[target.column] = coerceFilter(String(v), target.type);
122
134
  }
123
135
  if (filterParams['id']) {
@@ -20,7 +20,6 @@ export const SYSTEM_SETTINGS = defineSettings({
20
20
  ],
21
21
  }),
22
22
  agent_id: field.string({ label: 'core.settings.agent_id', strict: true, noSearch: true }),
23
- access_password: field.password({ label: 'core.settings.access_password' }),
24
23
  trigger_prefix: field.string({ label: 'core.settings.trigger_prefix' }),
25
24
  reply_window: field.int({ label: 'core.settings.reply_window', default: 1800 }),
26
25
  },
@@ -1,8 +1,22 @@
1
1
  export interface ThreadSelection {
2
2
  agentId: string | null;
3
3
  presetId: string | null;
4
+ title: string | null;
5
+ owner: string | null;
6
+ visibility: 'private' | null;
4
7
  }
8
+ export declare const TITLE_MAX = 60;
5
9
  export declare function getThreadState(connector: string, chatId: string): Promise<ThreadSelection>;
10
+ export declare function getThreadStates(connector: string, chatIds: string[]): Promise<Map<string, ThreadSelection>>;
6
11
  export declare function setThreadState(connector: string, chatId: string, patch: Partial<ThreadSelection>): Promise<void>;
7
12
  export declare function readAndTouchThreadState(connector: string, chatId: string): Promise<ThreadSelection>;
13
+ export declare function findChatByConvId(connector: string, convId: string): Promise<ChatLookup>;
14
+ export type ChatLookup = {
15
+ found: 'one';
16
+ chatId: string;
17
+ } | {
18
+ found: 'none';
19
+ } | {
20
+ found: 'ambiguous';
21
+ };
8
22
  export declare function pruneThreadState(connector: string, cutoffIso: string): Promise<void>;
@@ -1,26 +1,63 @@
1
1
  import { getEm } from "./db.js";
2
- const EMPTY = { agentId: null, presetId: null };
2
+ import { getLogger } from '@coffer-org/sdk/logger';
3
+ const log = getLogger('thread-state');
4
+ export const TITLE_MAX = 60;
5
+ function capTitle(title) {
6
+ const t = title.trim();
7
+ return t.length <= TITLE_MAX ? t : `${t.slice(0, TITLE_MAX - 1)}…`;
8
+ }
9
+ const EMPTY = { agentId: null, presetId: null, title: null, owner: null, visibility: null };
10
+ function toSelection(row) {
11
+ return {
12
+ agentId: row.agent_id,
13
+ presetId: row.preset_id,
14
+ title: row.title,
15
+ owner: row.owner,
16
+ visibility: row.visibility === 'private' ? 'private' : null,
17
+ };
18
+ }
3
19
  export async function getThreadState(connector, chatId) {
4
20
  const em = getEm().fork();
5
21
  const row = (await em.findOne('_ThreadState', { connector, chat_id: chatId }));
6
22
  if (!row)
7
23
  return { ...EMPTY };
8
- return { agentId: row.agent_id, presetId: row.preset_id };
24
+ return toSelection(row);
25
+ }
26
+ export async function getThreadStates(connector, chatIds) {
27
+ if (chatIds.length === 0)
28
+ return new Map();
29
+ const em = getEm().fork();
30
+ const rows = (await em.find('_ThreadState', { connector, chat_id: { $in: chatIds } }));
31
+ return new Map(rows.map((row) => [row.chat_id, toSelection(row)]));
9
32
  }
10
33
  export async function setThreadState(connector, chatId, patch) {
11
34
  const em = getEm().fork();
12
- const current = await getThreadState(connector, chatId);
13
- const next = {
14
- agentId: 'agentId' in patch ? (patch.agentId ?? null) : current.agentId,
15
- presetId: 'presetId' in patch ? (patch.presetId ?? null) : current.presetId,
16
- };
35
+ const data = { updated_at: new Date().toISOString() };
36
+ if ('agentId' in patch)
37
+ data['agent_id'] = patch.agentId ?? null;
38
+ if ('presetId' in patch)
39
+ data['preset_id'] = patch.presetId ?? null;
40
+ if ('title' in patch)
41
+ data['title'] = patch.title ? capTitle(patch.title) : null;
42
+ if ('owner' in patch)
43
+ data['owner'] = patch.owner ?? null;
44
+ if ('visibility' in patch)
45
+ data['visibility'] = patch.visibility ?? null;
17
46
  const existing = await em.findOne('_ThreadState', { connector, chat_id: chatId });
18
- const data = { agent_id: next.agentId, preset_id: next.presetId, updated_at: new Date().toISOString() };
19
47
  if (existing) {
20
48
  em.assign(existing, data);
21
49
  }
22
50
  else {
23
- em.persist(em.create('_ThreadState', { connector, chat_id: chatId, ...data }));
51
+ em.persist(em.create('_ThreadState', {
52
+ connector,
53
+ chat_id: chatId,
54
+ agent_id: data['agent_id'] ?? null,
55
+ preset_id: data['preset_id'] ?? null,
56
+ title: data['title'] ?? null,
57
+ owner: data['owner'] ?? null,
58
+ visibility: data['visibility'] ?? null,
59
+ updated_at: data['updated_at'],
60
+ }));
24
61
  }
25
62
  await em.flush();
26
63
  }
@@ -31,9 +68,32 @@ export async function readAndTouchThreadState(connector, chatId) {
31
68
  return { ...EMPTY };
32
69
  em.assign(row, { updated_at: new Date().toISOString() });
33
70
  await em.flush();
34
- return { agentId: row.agent_id, presetId: row.preset_id };
71
+ return toSelection(row);
72
+ }
73
+ export async function findChatByConvId(connector, convId) {
74
+ const suffix = `:${convId}`;
75
+ const em = getEm().fork();
76
+ const like = { $like: `%${suffix}` };
77
+ const stateRows = (await em.find('_ThreadState', { connector, chat_id: like }));
78
+ const stateMatches = matchingChatIds(stateRows, suffix);
79
+ if (stateMatches.length > 0)
80
+ return pickUnambiguous(stateMatches, connector, convId);
81
+ const messageRows = (await em.find('_ThreadMessage', { connector, chat_id: like }));
82
+ const messageMatches = matchingChatIds(messageRows, suffix);
83
+ return pickUnambiguous(messageMatches, connector, convId);
84
+ }
85
+ function matchingChatIds(rows, suffix) {
86
+ return [...new Set(rows.map((r) => r.chat_id).filter((id) => id.endsWith(suffix)))];
87
+ }
88
+ function pickUnambiguous(matches, connector, convId) {
89
+ if (matches.length === 0)
90
+ return { found: 'none' };
91
+ if (matches.length === 1)
92
+ return { found: 'one', chatId: matches[0] };
93
+ log.warn(`ambiguous convId "${convId}" for connector "${connector}": ${matches.length} chats match`);
94
+ return { found: 'ambiguous' };
35
95
  }
36
96
  export async function pruneThreadState(connector, cutoffIso) {
37
97
  const em = getEm().fork();
38
- await em.nativeDelete('_ThreadState', { connector, updated_at: { $lt: cutoffIso } });
98
+ await em.nativeDelete('_ThreadState', { connector, updated_at: { $lt: cutoffIso }, owner: null, visibility: null });
39
99
  }
@@ -1,7 +1,8 @@
1
+ export declare const HIDDEN_ROLES: readonly ["reasoning", "suggestions", "context"];
1
2
  export declare const SIDECAR_ROLES: readonly ["reasoning", "suggestions"];
2
3
  export interface StoredMsg {
3
4
  msgId: string;
4
- role: 'user' | 'assistant' | 'reasoning' | 'suggestions';
5
+ role: 'user' | 'assistant' | 'reasoning' | 'suggestions' | 'context';
5
6
  sender: string | null;
6
7
  text: string;
7
8
  attachments?: StoredAttachment[];
@@ -25,7 +26,7 @@ export declare function putThreadMessage(m: {
25
26
  connector: string;
26
27
  chatId: string;
27
28
  msgId: string;
28
- role: 'user' | 'assistant' | 'reasoning' | 'suggestions';
29
+ role: 'user' | 'assistant' | 'reasoning' | 'suggestions' | 'context';
29
30
  sender?: string | null;
30
31
  attachments?: StoredAttachment[];
31
32
  text: string;
@@ -34,4 +35,5 @@ export declare function putThreadMessage(m: {
34
35
  }): Promise<void>;
35
36
  export declare function pruneThreadMessages(connector: string, cutoffTs: number): Promise<void>;
36
37
  export declare function listThreadMessages(connector: string, chatId: string, limit?: number): Promise<StoredMsg[]>;
37
- export declare function listThreadChats(connector: string, chatIdPrefix: string): Promise<ThreadChat[]>;
38
+ export declare function countUserTurns(connector: string, chatId: string): Promise<number>;
39
+ export declare function listAllThreadChats(connector: string): Promise<ThreadChat[]>;