@yeaft/webchat-agent 1.0.606 → 1.0.608
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.
- package/local-runtime/server/handlers/client-person.js +1 -1
- package/local-runtime/version.json +1 -1
- package/local-runtime/web/app.bundle.js +124 -105
- package/local-runtime/web/app.bundle.js.gz +0 -0
- package/local-runtime/web/index.html +2 -2
- package/local-runtime/web/style.bundle.css +1 -1
- package/local-runtime/web/style.bundle.css.gz +0 -0
- package/package.json +1 -2
- package/yeaft/person/bridge.js +3 -7
- package/yeaft/person/capabilities.js +1 -1
- package/yeaft/person/contracts.js +1 -1
- package/yeaft/person/inspection.js +1 -1
- package/yeaft/person/provider.js +29 -13
- package/yeaft/person/runtime.js +4 -4
- package/yeaft/person/service.js +51 -38
- package/yeaft/person/sqlite-repository.js +1 -1
- package/yeaft/person/sqlite-storage.js +24 -0
- package/yeaft/person/sqlite-store.js +8 -7
- package/yeaft/person/task-host.js +1 -1
- package/yeaft/person/repository.js +0 -515
- package/yeaft/person/storage.js +0 -36
|
Binary file
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yeaft/webchat-agent",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.608",
|
|
4
4
|
"description": "Remote worker agent for Yeaft Web Code Agent — connects the native Yeaft engine, CLI providers, and workbench tools",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"type": "module",
|
|
@@ -72,7 +72,6 @@
|
|
|
72
72
|
"dotenv": "^16.3.1",
|
|
73
73
|
"express": "^4.22.2",
|
|
74
74
|
"jsonwebtoken": "^9.0.2",
|
|
75
|
-
"mongodb": "^6.21.0",
|
|
76
75
|
"multer": "^2.2.0",
|
|
77
76
|
"nodemailer": "^9.0.3",
|
|
78
77
|
"puppeteer-core": "24.41.0",
|
package/yeaft/person/bridge.js
CHANGED
|
@@ -2,8 +2,8 @@ import { createHash } from 'node:crypto';
|
|
|
2
2
|
import ctx from '../../context.js';
|
|
3
3
|
import { sendToServer } from '../../connection/buffer.js';
|
|
4
4
|
|
|
5
|
-
/** Lazy Person transport.
|
|
6
|
-
*
|
|
5
|
+
/** Lazy Person transport. SQLite storage belongs to this Agent instance;
|
|
6
|
+
* browser messages cannot choose a storage authority or instance directory.
|
|
7
7
|
*/
|
|
8
8
|
export function createPersonBridge({
|
|
9
9
|
context = ctx, send = sendToServer, env = process.env,
|
|
@@ -32,7 +32,6 @@ export function createPersonBridge({
|
|
|
32
32
|
await send({ ...response, ok: false, errorCode: 'invalid_request', error: 'Digital person request requires authenticated ownership' });
|
|
33
33
|
return true;
|
|
34
34
|
}
|
|
35
|
-
const uri = env.YEAFT_PERSON_MONGODB_URI;
|
|
36
35
|
const agentId = context.agentId || context.AGENT_ID;
|
|
37
36
|
if (!agentId || !context.CONFIG?.yeaftDir) throw new Error('identity');
|
|
38
37
|
// Server+Agent is a storage boundary; a re-registration must not reuse the old runtime.
|
|
@@ -45,10 +44,7 @@ export function createPersonBridge({
|
|
|
45
44
|
identity = namespace;
|
|
46
45
|
if (!servicePromise) {
|
|
47
46
|
servicePromise = Promise.resolve().then(() => createService({
|
|
48
|
-
uri,
|
|
49
|
-
storage: env.YEAFT_PERSON_STORAGE,
|
|
50
47
|
embedding: { enabled: env.YEAFT_PERSON_EMBEDDING !== 'off', allowDownload: env.YEAFT_PERSON_EMBEDDING_DOWNLOAD !== '0' },
|
|
51
|
-
dbName: env.YEAFT_PERSON_MONGODB_DB || 'yeaft_person',
|
|
52
48
|
namespace,
|
|
53
49
|
yeaftDir: context.CONFIG.yeaftDir,
|
|
54
50
|
workDir: context.CONFIG.workDir,
|
|
@@ -78,7 +74,7 @@ export function createPersonBridge({
|
|
|
78
74
|
TASK_CONTROL_UNAVAILABLE: 'Task control could not be confirmed; refresh before retrying',
|
|
79
75
|
STALE: 'Digital person state changed; refresh and try again',
|
|
80
76
|
IDEMPOTENCY_CONFLICT: 'This message identifier already belongs to a different request',
|
|
81
|
-
STORAGE_MISMATCH: '
|
|
77
|
+
STORAGE_MISMATCH: 'Existing digital person storage is not a valid SQLite binding; check instance storage before retrying',
|
|
82
78
|
UNSUPPORTED: 'This capability is not available in this digital person version',
|
|
83
79
|
};
|
|
84
80
|
const known = Object.hasOwn(safeErrors, error?.code);
|
|
@@ -83,7 +83,7 @@ export function inspectCapabilities(created, { cursor, limit }, nativeToolIds =
|
|
|
83
83
|
contract.evidence = item.evidence;
|
|
84
84
|
entries.push(item);
|
|
85
85
|
}
|
|
86
|
-
// IDs are ASCII identifiers. Binary ordering matches SQLite
|
|
86
|
+
// IDs are ASCII identifiers. Binary ordering matches SQLite.
|
|
87
87
|
const matches = entries.filter(entry => cursor === null || entry.id > cursor)
|
|
88
88
|
.sort((a, b) => a.id < b.id ? -1 : a.id > b.id ? 1 : 0);
|
|
89
89
|
return inspectionPage(matches, limit, 'id');
|
|
@@ -5,7 +5,7 @@ export const LIMITS = Object.freeze({ inputBytes: 8192, outputBytes: 65536, cont
|
|
|
5
5
|
const ERRORS = {
|
|
6
6
|
INVALID_REQUEST: 'Invalid digital person request.', NOT_CONFIGURED: 'Digital person storage configuration is missing.',
|
|
7
7
|
STORAGE_UNAVAILABLE: 'Digital person storage is unavailable. Check instance storage and database configuration.',
|
|
8
|
-
STORAGE_MISMATCH: '
|
|
8
|
+
STORAGE_MISMATCH: 'Existing digital person storage is not a valid SQLite binding.',
|
|
9
9
|
NOT_OPEN: 'Open your digital person first.', BUSY: 'The digital person is already active.',
|
|
10
10
|
IDEMPOTENCY_CONFLICT: 'This clientMessageId was already used for a different request.',
|
|
11
11
|
STALE: 'The activity no longer owns the current person revision.', INVALID_PROPOSAL: 'The model returned an invalid cognitive proposal.',
|
|
@@ -18,7 +18,7 @@ export function personName(value) {
|
|
|
18
18
|
return text(value.trim(), 160);
|
|
19
19
|
}
|
|
20
20
|
const pick = (value, keys) => value == null ? null : Object.fromEntries(keys.filter(key => Object.hasOwn(value, key)).map(key => [key, value[key]]));
|
|
21
|
-
export const settingsView = value => pick(value, ['autonomyEnabled', 'modelCandidates']);
|
|
21
|
+
export const settingsView = value => pick(value, ['autonomyEnabled', 'modelCandidates', 'defaultModel']);
|
|
22
22
|
export function messageView(value) {
|
|
23
23
|
const result = pick(value, ['id', 'schemaVersion', 'revision', 'seq', 'episodeId', 'clientMessageId', 'role', 'text', 'createdAt']);
|
|
24
24
|
if (result && Array.isArray(value.attachments)) result.attachments = value.attachments.slice(0, 4)
|
package/yeaft/person/provider.js
CHANGED
|
@@ -14,6 +14,29 @@ export function validateModelCandidates(refs) {
|
|
|
14
14
|
new Set(refs).size !== refs.length) fail('MODEL_SELECTION');
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
+
export function validateDefaultModel(ref) {
|
|
18
|
+
if (ref !== null) validateModelCandidates([ref]);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Resolve router ownership before allowlisting or bounding the Person catalog. */
|
|
22
|
+
export function resolveAgentDefaultModel(config) {
|
|
23
|
+
const models = config.availableModels || [], requested = config.primaryModel || config.model;
|
|
24
|
+
const model = models.find(m => (m.ref || m.id) === requested) || models.find(m => m.id === requested);
|
|
25
|
+
return model?.ref || (model?.id?.includes('/') ? model.id : null);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Shared validation for settings, status and runtime; never broaden an explicit subset. */
|
|
29
|
+
export function selectPersonModels({ availableModels, modelCandidates = [], defaultModel = null }) {
|
|
30
|
+
validateModelCandidates(modelCandidates);
|
|
31
|
+
validateDefaultModel(defaultModel);
|
|
32
|
+
if (modelCandidates.some(ref => !availableModels.some(model => model.id === ref))) fail('MODEL_SELECTION');
|
|
33
|
+
const catalog = modelCandidates.length ? availableModels.filter(model => modelCandidates.includes(model.id))
|
|
34
|
+
: availableModels.slice(0, MODEL_LIMITS.candidates);
|
|
35
|
+
if (defaultModel !== null && !catalog.some(model => model.id === defaultModel)) fail('MODEL_SELECTION');
|
|
36
|
+
if (!catalog.length) fail('MODEL_UNAVAILABLE');
|
|
37
|
+
return { catalog, defaultSelection: { model: defaultModel ?? catalog[0].id, effort: null } };
|
|
38
|
+
}
|
|
39
|
+
|
|
17
40
|
// Bounds apply to the actual wire protocol, not a vision flag or compressed file size.
|
|
18
41
|
// OpenAI tile models (including 2833 base tokens for gpt-4o-mini) have a fixed
|
|
19
42
|
// cost only at explicit low detail. Known patch models below fit <=6144 patches
|
|
@@ -38,17 +61,13 @@ function imageInputBudget(modelId, protocol) {
|
|
|
38
61
|
}
|
|
39
62
|
|
|
40
63
|
/** Only configured native API models; no Session initialization or implicit credential fallback. */
|
|
41
|
-
export async function createPersonProvider({ yeaftDir, config: suppliedConfig, adapter: suppliedAdapter, allowedModels, effortEnabled = process.env.YEAFT_THINKING_V1 === '1', modelCandidates = [] }) {
|
|
64
|
+
export async function createPersonProvider({ yeaftDir, config: suppliedConfig, adapter: suppliedAdapter, allowedModels, effortEnabled = process.env.YEAFT_THINKING_V1 === '1', modelCandidates = [], defaultModel = null }) {
|
|
42
65
|
const config = structuredClone(suppliedConfig || loadConfig({ dir: yeaftDir }));
|
|
43
66
|
if (!config.providers?.length && !suppliedAdapter) fail('MODEL_UNAVAILABLE');
|
|
44
67
|
const models = config.availableModels || [];
|
|
45
|
-
const
|
|
46
|
-
// Match router ownership: exact qualified ref first; bare IDs use the first
|
|
47
|
-
// configured provider. Resolve before allowlisting, sorting or truncating.
|
|
48
|
-
const defaultModel = models.find(m => (m.ref || m.id) === requestedDefault)
|
|
49
|
-
|| models.find(m => m.id === requestedDefault);
|
|
50
|
-
const defaultRef = defaultModel?.ref || defaultModel?.id;
|
|
68
|
+
const agentDefaultModel = resolveAgentDefaultModel(config);
|
|
51
69
|
validateModelCandidates(modelCandidates);
|
|
70
|
+
validateDefaultModel(defaultModel);
|
|
52
71
|
const available = models.filter(m => !allowedModels || allowedModels.includes(m.ref || m.id));
|
|
53
72
|
const routingProviders = (config.providers || []).map(normalizeKnownProviderForRuntime);
|
|
54
73
|
const safeModels = available.map(m => {
|
|
@@ -76,19 +95,16 @@ export async function createPersonProvider({ yeaftDir, config: suppliedConfig, a
|
|
|
76
95
|
return { id: m.ref || m.id, efforts, maxOutput, supportsImages: imageBudget !== null, imageBudget,
|
|
77
96
|
contextWindow: Math.floor(resolveContextWindow(m.id, { ...config, modelInfo: m })) };
|
|
78
97
|
}).filter(m => typeof m.id === 'string' && m.id.length <= 256 && m.contextWindow > m.maxOutput + 1024 && m.maxOutput >= 256);
|
|
79
|
-
safeModels.sort((a, b) => Number(b.id ===
|
|
98
|
+
safeModels.sort((a, b) => Number(b.id === agentDefaultModel) - Number(a.id === agentDefaultModel));
|
|
80
99
|
const seen = new Set();
|
|
81
100
|
const uniqueModels = safeModels.filter(model => !seen.has(model.id) && seen.add(model.id));
|
|
82
101
|
const availableModels = uniqueModels.slice(0, MODEL_LIMITS.available);
|
|
83
102
|
// A UI catalog must not be the bounded episode choice set: model 9+ remains selectable.
|
|
84
103
|
// Explicit lists are validated, never silently truncated or broadened on stale config.
|
|
85
|
-
|
|
86
|
-
const catalog = modelCandidates.length ? availableModels.filter(model => modelCandidates.includes(model.id))
|
|
87
|
-
: availableModels.slice(0, MODEL_LIMITS.candidates);
|
|
88
|
-
if (!catalog.length) fail('MODEL_UNAVAILABLE');
|
|
104
|
+
const { catalog, defaultSelection } = selectPersonModels({ availableModels, modelCandidates, defaultModel });
|
|
89
105
|
const adapter = suppliedAdapter || await createLLMAdapter(config);
|
|
90
106
|
return { adapter, catalog, availableModels, availableModelsTruncated: uniqueModels.length > MODEL_LIMITS.available,
|
|
91
|
-
catalogRevision: digest(catalog), defaultSelection
|
|
107
|
+
catalogRevision: digest(catalog), defaultSelection, agentDefaultModel, effortEnabled };
|
|
92
108
|
}
|
|
93
109
|
|
|
94
110
|
export function abortable(promise, signal) {
|
package/yeaft/person/runtime.js
CHANGED
|
@@ -147,8 +147,8 @@ export class PersonRuntime {
|
|
|
147
147
|
this.running = new Map(); this.pendingCancellations = new Map(); this.workerId = randomUUID(); this.closed = false;
|
|
148
148
|
}
|
|
149
149
|
tasks() {
|
|
150
|
-
// Construction/status/open do not touch task storage.
|
|
151
|
-
//
|
|
150
|
+
// Construction/status/open do not touch task storage. An instance root is
|
|
151
|
+
// required before creating the durable task host.
|
|
152
152
|
if (!this.toolOptions.yeaftDir) return null;
|
|
153
153
|
return this.taskHost ??= new PersonTaskHost({ ...this.toolOptions, namespace: this.namespace });
|
|
154
154
|
}
|
|
@@ -201,7 +201,7 @@ export class PersonRuntime {
|
|
|
201
201
|
}, Math.max(100, Math.floor(this.repository.leaseMs / 3)));
|
|
202
202
|
heartbeat.unref?.();
|
|
203
203
|
try {
|
|
204
|
-
const provider = await abortable(this.getProvider(episode.modelCandidates ?? []), signal);
|
|
204
|
+
const provider = await abortable(this.getProvider(episode.modelCandidates ?? [], episode.defaultModel ?? null), signal);
|
|
205
205
|
const snapshot = await abortable(this.repository.context(episode), signal);
|
|
206
206
|
const attachments = episode.attachments?.length ? await abortable(this.repository.episodeAttachments(episode), signal) : [];
|
|
207
207
|
// Bind recovered evidence using the repository's authoritative Person ID,
|
|
@@ -209,7 +209,7 @@ export class PersonRuntime {
|
|
|
209
209
|
await this.tasks()?.context({ episode, parentToolRegistry: this.parentToolRegistry });
|
|
210
210
|
signal.throwIfAborted();
|
|
211
211
|
let selection = { ...provider.defaultSelection, reason: 'configured-default', origin: 'bootstrap' };
|
|
212
|
-
if (snapshot.state.lastSelection) {
|
|
212
|
+
if (episode.defaultModel == null && snapshot.state.lastSelection) {
|
|
213
213
|
try { validateSelection(snapshot.state.lastSelection, provider.catalog); selection = { ...snapshot.state.lastSelection, reason: 'last-accepted-choice', origin: 'persisted' }; }
|
|
214
214
|
catch { await this.repository.append(episode, 'selection_rejected', { requested: snapshot.state.lastSelection, code: 'MODEL_SELECTION', fallback: 'configured-default' }); }
|
|
215
215
|
}
|
package/yeaft/person/service.js
CHANGED
|
@@ -1,52 +1,47 @@
|
|
|
1
|
-
import { MongoPersonRepository } from './repository.js';
|
|
2
1
|
import { SqlitePersonRepository } from './sqlite-repository.js';
|
|
3
2
|
import { LocalPersonMemory } from './local-memory.js';
|
|
4
|
-
import {
|
|
3
|
+
import { ensurePersonStorage } from './sqlite-storage.js';
|
|
5
4
|
import { PersonRuntime } from './runtime.js';
|
|
6
5
|
import { allowedNativeToolIds } from './native-tools.js';
|
|
7
6
|
import { loadConfig } from '../config.js';
|
|
8
7
|
import { validateFiles } from './attachments.js';
|
|
9
|
-
import { createPersonProvider, validateModelCandidates } from './provider.js';
|
|
8
|
+
import { createPersonProvider, resolveAgentDefaultModel, selectPersonModels, validateDefaultModel, validateModelCandidates } from './provider.js';
|
|
10
9
|
import { fail, identifier, LIMITS, object, page, personTaskRequest, safeError, text } from './contracts.js';
|
|
11
10
|
import { inspectRequest, personName, searchRequest } from './inspection.js';
|
|
12
11
|
|
|
13
12
|
/**
|
|
14
13
|
* One durable Person per (namespace, authenticated ownerId). The transport MUST
|
|
15
|
-
* supply authenticated ownerId, never accept it from a browser payload.
|
|
14
|
+
* supply authenticated ownerId, never accept it from a browser payload. Provider
|
|
16
15
|
* secrets stay inside this Agent. Constructor and status never initiate model calls.
|
|
17
|
-
*
|
|
18
|
-
*
|
|
16
|
+
* SQLite storage and native provider configuration belong to yeaftDir only
|
|
17
|
+
* (no Session initialization or alternate storage authority).
|
|
19
18
|
*/
|
|
20
19
|
export function createPersonService(options = {}) {
|
|
21
|
-
const {
|
|
20
|
+
const { namespace = 'default', yeaftDir, workDir, config, adapter, allowedModels } = options;
|
|
22
21
|
identifier(namespace);
|
|
23
|
-
|
|
24
|
-
if (uri != null && typeof uri !== 'string') fail('INVALID_REQUEST');
|
|
25
|
-
const { storage, configured } = selectPersonStorage({ ...options, yeaftDir });
|
|
22
|
+
const configured = typeof yeaftDir === 'string' && Boolean(yeaftDir.trim());
|
|
26
23
|
const calls = options.maxCalls ?? LIMITS.calls;
|
|
27
24
|
const timeoutMs = options.timeoutMs ?? LIMITS.timeoutMs;
|
|
28
25
|
const leaseMs = options.leaseMs ?? LIMITS.leaseMs;
|
|
29
26
|
if (!Number.isInteger(calls) || calls < 1 || calls > 32 || !Number.isInteger(timeoutMs) || timeoutMs < 100 || timeoutMs > 300000 ||
|
|
30
27
|
!Number.isInteger(leaseMs) || leaseMs < 300 || leaseMs > 60000) fail('INVALID_REQUEST');
|
|
31
28
|
if (allowedModels != null && (!Array.isArray(allowedModels) || !allowedModels.length || allowedModels.some(m => typeof m !== 'string'))) fail('INVALID_REQUEST');
|
|
32
|
-
const repository =
|
|
33
|
-
? new MongoPersonRepository({ uri, dbName, namespace, MongoClient, leaseMs })
|
|
34
|
-
: new SqlitePersonRepository({ yeaftDir, namespace, leaseMs });
|
|
29
|
+
const repository = configured ? new SqlitePersonRepository({ yeaftDir, namespace, leaseMs }) : null;
|
|
35
30
|
const initialize = repository?.init.bind(repository);
|
|
36
31
|
let binding;
|
|
37
32
|
if (repository) repository.init = async () => {
|
|
38
|
-
if (!binding) binding =
|
|
33
|
+
if (!binding) binding = ensurePersonStorage(yeaftDir, namespace).catch(error => { binding = null; throw error; });
|
|
39
34
|
await binding;
|
|
40
35
|
return initialize();
|
|
41
36
|
};
|
|
42
37
|
const literalRecall = repository?.recall.bind(repository);
|
|
43
|
-
const memory = repository
|
|
38
|
+
const memory = repository ? new LocalPersonMemory({
|
|
44
39
|
repository, literalRecall, yeaftDir, namespace, embedding: options.embedding,
|
|
45
40
|
}) : null;
|
|
46
41
|
if (memory) repository.recall = (ownerId, args = {}, { signal } = {}) => args.query?.trim()
|
|
47
42
|
? memory.recall(ownerId, args, { signal }) : literalRecall(ownerId, args);
|
|
48
43
|
// Config/adapter are loaded per explicit episode, not a permanent stale cache.
|
|
49
|
-
const getProvider = (modelCandidates = []) => createPersonProvider({ yeaftDir, config, adapter, allowedModels, modelCandidates, effortEnabled: options.effortEnabled });
|
|
44
|
+
const getProvider = (modelCandidates = [], defaultModel = null) => createPersonProvider({ yeaftDir, config, adapter, allowedModels, modelCandidates, defaultModel, effortEnabled: options.effortEnabled });
|
|
50
45
|
const runtime = new PersonRuntime({ repository, getProvider, budget: { calls, timeoutMs }, workDir, yeaftDir, config, namespace });
|
|
51
46
|
let closed = false;
|
|
52
47
|
const requests = new Set();
|
|
@@ -56,23 +51,32 @@ export function createPersonService(options = {}) {
|
|
|
56
51
|
if (typeof op !== 'string') fail('INVALID_REQUEST');
|
|
57
52
|
if (op === 'status') {
|
|
58
53
|
object(payload, []);
|
|
59
|
-
|
|
54
|
+
let agentDefaultModel = null;
|
|
55
|
+
try { agentDefaultModel = resolveAgentDefaultModel(config ?? loadConfig({ dir: yeaftDir })); } catch { /* Invalid/missing native config. */ }
|
|
56
|
+
const status = { renameSupported: true, defaultModelSupported: true, configured, storage: 'sqlite', modelReady: false,
|
|
57
|
+
defaultModel: null, agentDefaultModel, modelCandidates: [], effectiveModelCandidates: [], effectiveDefaultModel: null };
|
|
58
|
+
if (!configured) return { ...status, reason: 'Digital person storage configuration is missing.', storageReady: false };
|
|
60
59
|
try { await repository.init(); }
|
|
61
|
-
catch (error) { return {
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
60
|
+
catch (error) { return { ...status, reason: safeError(error).message, storageReady: false }; }
|
|
61
|
+
try {
|
|
62
|
+
const settings = (await repository.getPerson(ownerId)).settings;
|
|
63
|
+
status.modelCandidates = settings.modelCandidates ?? [];
|
|
64
|
+
status.defaultModel = settings.defaultModel ?? null;
|
|
65
|
+
} catch (error) { if (error.code !== 'NOT_OPEN') throw error; }
|
|
65
66
|
try {
|
|
66
67
|
const provider = await getProvider();
|
|
67
|
-
let modelReady = true;
|
|
68
68
|
try {
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
69
|
+
const selected = selectPersonModels({ availableModels: provider.availableModels,
|
|
70
|
+
modelCandidates: status.modelCandidates, defaultModel: status.defaultModel });
|
|
71
|
+
status.modelReady = true;
|
|
72
|
+
status.effectiveModelCandidates = selected.catalog.map(model => model.id);
|
|
73
|
+
status.effectiveDefaultModel = selected.defaultSelection.model;
|
|
74
|
+
} catch { /* Stale settings remain visible, but do not grant fallback candidates. */ }
|
|
75
|
+
return { ...status, agentDefaultModel: provider.agentDefaultModel,
|
|
76
|
+
reason: status.modelReady ? null : 'Saved model settings are unavailable; choose models or reset to Agent defaults.',
|
|
77
|
+
storageReady: true, models: provider.availableModels, availableModels: provider.availableModels,
|
|
78
|
+
availableModelsTruncated: provider.availableModelsTruncated, autonomySupported: false };
|
|
79
|
+
} catch { return { ...status, reason: 'No permitted native model is configured for digital person.', storageReady: true, models: [], availableModels: [] }; }
|
|
76
80
|
}
|
|
77
81
|
if (!configured) fail('NOT_CONFIGURED');
|
|
78
82
|
switch (op) {
|
|
@@ -143,21 +147,30 @@ export function createPersonService(options = {}) {
|
|
|
143
147
|
} finally { release(); }
|
|
144
148
|
}
|
|
145
149
|
case 'settings': {
|
|
146
|
-
object(payload, ['name', 'autonomyEnabled', 'modelCandidates'], []);
|
|
150
|
+
object(payload, ['name', 'autonomyEnabled', 'modelCandidates', 'defaultModel'], []);
|
|
147
151
|
const patch = { ...payload };
|
|
148
152
|
if (Object.hasOwn(patch, 'name')) patch.name = personName(patch.name);
|
|
149
153
|
if (Object.hasOwn(payload, 'autonomyEnabled') && typeof payload.autonomyEnabled !== 'boolean') fail('INVALID_REQUEST');
|
|
150
|
-
if (Object.hasOwn(payload, 'modelCandidates'))
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
+
if (Object.hasOwn(payload, 'modelCandidates')) validateModelCandidates(payload.modelCandidates);
|
|
155
|
+
if (Object.hasOwn(payload, 'defaultModel')) validateDefaultModel(payload.defaultModel);
|
|
156
|
+
// Timer-driven autonomy is deliberately not claimed or silently enabled.
|
|
157
|
+
if (payload.autonomyEnabled === true) fail('UNSUPPORTED');
|
|
158
|
+
let expectedControlVersion;
|
|
159
|
+
if (Object.hasOwn(payload, 'modelCandidates') || Object.hasOwn(payload, 'defaultModel')) {
|
|
160
|
+
const prior = await repository.getPerson(ownerId);
|
|
161
|
+
if (prior.activeEpisodeId) fail('BUSY');
|
|
162
|
+
const merged = { modelCandidates: [], defaultModel: null, ...prior.settings, ...patch };
|
|
163
|
+
validateModelCandidates(merged.modelCandidates);
|
|
164
|
+
validateDefaultModel(merged.defaultModel);
|
|
165
|
+
// An automatic reset must also work when no model is currently available.
|
|
166
|
+
if (merged.modelCandidates.length || merged.defaultModel !== null) {
|
|
154
167
|
const provider = await getProvider();
|
|
155
|
-
|
|
168
|
+
selectPersonModels({ availableModels: provider.availableModels, modelCandidates: merged.modelCandidates, defaultModel: merged.defaultModel });
|
|
156
169
|
}
|
|
170
|
+
// Fence the merge base across concurrent services; retry from fresh settings.
|
|
171
|
+
expectedControlVersion = prior.controlVersion;
|
|
157
172
|
}
|
|
158
|
-
|
|
159
|
-
if (payload.autonomyEnabled === true) fail('UNSUPPORTED');
|
|
160
|
-
return repository.settings(ownerId, patch);
|
|
173
|
+
return repository.settings(ownerId, patch, expectedControlVersion);
|
|
161
174
|
}
|
|
162
175
|
default: fail('INVALID_REQUEST');
|
|
163
176
|
}
|
|
@@ -103,7 +103,7 @@ export class SqlitePersonRepository {
|
|
|
103
103
|
async commit(episode, proposal, selection, callId, reportedSources = new Map()) { return this.request('commit', [episode, proposal, selection, callId, reportedSources]); }
|
|
104
104
|
async finish(episode, status, code) { return this.request('finish', [episode, status, code]); }
|
|
105
105
|
async cancel(ownerId, episodeId) { return this.request('cancel', [ownerId, episodeId]); }
|
|
106
|
-
async settings(ownerId, settings) { return this.request('settings', [ownerId, settings]); }
|
|
106
|
+
async settings(ownerId, settings, expectedControlVersion) { return this.request('settings', [ownerId, settings, expectedControlVersion]); }
|
|
107
107
|
async list(ownerId, collection, options, filter) { return this.request('list', [ownerId, collection, options, filter]); }
|
|
108
108
|
async snapshot(ownerId) { return this.request('snapshot', [ownerId]); }
|
|
109
109
|
async inspect(ownerId, options, nativeToolIds) { return this.request('inspect', [ownerId, options, nativeToolIds]); }
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { mkdir, readFile, writeFile, link, unlink } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { randomUUID } from 'node:crypto';
|
|
4
|
+
import { digest, fail } from './contracts.js';
|
|
5
|
+
|
|
6
|
+
/** Preserve existing instance bindings without silently creating a new authority.
|
|
7
|
+
* SQLite is the only supported storage; this marker is not a backend selector.
|
|
8
|
+
*/
|
|
9
|
+
export async function ensurePersonStorage(yeaftDir, namespace) {
|
|
10
|
+
const directory = join(yeaftDir, 'person');
|
|
11
|
+
await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
12
|
+
const path = join(directory, `storage-${digest(namespace)}.json`);
|
|
13
|
+
const temporary = `${path}.${randomUUID()}.tmp`;
|
|
14
|
+
await writeFile(temporary, JSON.stringify({ version: 1, storage: 'sqlite' }), { flag: 'wx', mode: 0o600 });
|
|
15
|
+
try {
|
|
16
|
+
// A fully written inode is linked atomically; competing openers never read partial JSON.
|
|
17
|
+
await link(temporary, path);
|
|
18
|
+
} catch (error) {
|
|
19
|
+
if (error.code !== 'EEXIST') throw error;
|
|
20
|
+
let binding;
|
|
21
|
+
try { binding = JSON.parse(await readFile(path, 'utf8')); } catch { fail('STORAGE_MISMATCH'); }
|
|
22
|
+
if (!binding || binding.version !== 1 || binding.storage !== 'sqlite') fail('STORAGE_MISMATCH');
|
|
23
|
+
} finally { await unlink(temporary).catch(() => {}); }
|
|
24
|
+
}
|
|
@@ -151,7 +151,7 @@ export class SqlitePersonStore {
|
|
|
151
151
|
if (!p) {
|
|
152
152
|
p = this.doc(scope, {
|
|
153
153
|
name, soul: 'A continuous, curious and honest digital person. Preserve uncertainty, reconsider your judgments, distinguish imagination from experience, and respect permissions. You may disagree without acting without authority.',
|
|
154
|
-
soulRevision: 1, createdAt: this.now, settings: { autonomyEnabled: false },
|
|
154
|
+
soulRevision: 1, createdAt: this.now, settings: { autonomyEnabled: false, defaultModel: null },
|
|
155
155
|
epoch: 0, writeSerial: 0, inputWatermark: 0, controlVersion: 0, stateVersion: 0,
|
|
156
156
|
traceSeq: 0, messageSeq: 0, activeEpisodeId: null, leaseOwner: null, leaseUntil: new Date(0),
|
|
157
157
|
});
|
|
@@ -223,7 +223,7 @@ export class SqlitePersonStore {
|
|
|
223
223
|
const message = this.doc(scope, { id: messageId, revision: 1, seq: p.messageSeq, episodeId: id, role: 'user', text: input, attachments, createdAt: this.now, clientMessageId });
|
|
224
224
|
this.put('messages', message, true); this.journal(scope, 'messages', message);
|
|
225
225
|
}
|
|
226
|
-
const episode = this.doc(scope, { id, clientMessageId, requestHash, kind, text: input, messageId, attachments, modelCandidates: [...(p.settings.modelCandidates ?? [])], status: 'running', workerId, epoch: p.epoch,
|
|
226
|
+
const episode = this.doc(scope, { id, clientMessageId, requestHash, kind, text: input, messageId, attachments, modelCandidates: [...(p.settings.modelCandidates ?? [])], defaultModel: p.settings.defaultModel ?? null, status: 'running', workerId, epoch: p.epoch,
|
|
227
227
|
baseStateVersion: p.stateVersion, inputWatermark: p.inputWatermark, controlVersion: p.controlVersion, budget, createdAt: this.now });
|
|
228
228
|
this.put('episodes', episode, true);
|
|
229
229
|
this.trace(ownerId, id, 'accepted', { trigger: { kind, text: input, messageId, attachments }, baseStateVersion: p.stateVersion, budget });
|
|
@@ -419,9 +419,10 @@ export class SqlitePersonStore {
|
|
|
419
419
|
this.trace(ownerId, id, 'cancelled', { code: 'CANCELLED' });
|
|
420
420
|
return { cancelled: true, episodeId: id };
|
|
421
421
|
}
|
|
422
|
-
settings(ownerId, settings) {
|
|
422
|
+
settings(ownerId, settings, expectedControlVersion) {
|
|
423
423
|
const p = this.getPerson(ownerId);
|
|
424
424
|
if (p.activeEpisodeId) fail('BUSY');
|
|
425
|
+
if (expectedControlVersion !== undefined && p.controlVersion !== expectedControlVersion) fail('STALE');
|
|
425
426
|
const { name, ...patch } = settings;
|
|
426
427
|
if (Object.hasOwn(settings, 'name')) p.name = personName(name);
|
|
427
428
|
// A partial update must preserve durable fields omitted from the public view.
|
|
@@ -447,9 +448,9 @@ export class SqlitePersonStore {
|
|
|
447
448
|
list(ownerId, collection, { cursor = null, limit = 20 } = {}, filter = {}) {
|
|
448
449
|
if (!['messages', 'traces'].includes(collection)) fail('INVALID_REQUEST');
|
|
449
450
|
this.getPerson(ownerId); boundedLimit(limit);
|
|
450
|
-
if (Object.keys(filter).some(k => k !== '
|
|
451
|
-
|
|
452
|
-
|
|
451
|
+
if (Object.keys(filter).some(k => k !== 'query')) fail('INVALID_REQUEST');
|
|
452
|
+
const query = filter.query ?? '';
|
|
453
|
+
text(query, LIMITS.inputBytes, true);
|
|
453
454
|
if (query && collection !== 'messages') fail('INVALID_REQUEST');
|
|
454
455
|
let clause = '', params = [];
|
|
455
456
|
if (cursor != null) { clause += ' AND seq < ?'; params.push(sequence(cursor)); }
|
|
@@ -459,7 +460,7 @@ export class SqlitePersonStore {
|
|
|
459
460
|
}
|
|
460
461
|
recall(ownerId, { kind = 'messages', query = '', cursor = null, limit = 5 } = {}) {
|
|
461
462
|
memoryKind(kind); text(query, LIMITS.inputBytes, true); boundedLimit(limit);
|
|
462
|
-
if (kind === 'messages') return this.list(ownerId, kind, { cursor, limit },
|
|
463
|
+
if (kind === 'messages') return this.list(ownerId, kind, { cursor, limit }, { query });
|
|
463
464
|
this.getPerson(ownerId);
|
|
464
465
|
if (cursor != null) text(cursor, 128);
|
|
465
466
|
const docs = this.rows('concepts', this.scope(ownerId), ' AND (? IS NULL OR id > ?) AND instr(statement, ?) > 0 ORDER BY id ASC LIMIT ?', [cursor, cursor, query.toLowerCase(), limit + 1]);
|
|
@@ -61,7 +61,7 @@ function privatePath(root, path) {
|
|
|
61
61
|
// expose native command fallbacks; redact common sensitive forms before capping.
|
|
62
62
|
function summary(value, maxBytes = 512) {
|
|
63
63
|
const clean = String(value ?? '')
|
|
64
|
-
.replace(
|
|
64
|
+
.replace(/\b[a-z][a-z\d+.-]*:\/\/\S+/gi, '[url]')
|
|
65
65
|
.replace(/(?:bearer\s+|(?:api[_-]?key|password|secret|token)\s*[:=]\s*)\S+/gi, '[redacted]')
|
|
66
66
|
.replace(/\b(?:sk-[\w-]+|gh[pousr]_[\w]+|github_pat_[\w]+)\b/g, '[redacted]')
|
|
67
67
|
.replace(/(?:\/[\w.@~+-]+)+(?:\/[^\s]*)?|(?:~|\.\.?|[A-Za-z]:)[\\/][^\s]+/g, '[path]')
|