@illli-studio/dsh-mory 0.0.0-stage → 0.1.2

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/service.mjs ADDED
@@ -0,0 +1,165 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { homedir } from 'node:os';
3
+ import { join } from 'node:path';
4
+
5
+ export class MoryError extends Error {
6
+ constructor(status, code, message) { super(message); this.status = status; this.code = code; }
7
+ }
8
+ const fail = message => { throw new MoryError(400, 'INVALID_INPUT', message); };
9
+ const text = (value, name, max = 200_000) => {
10
+ if (typeof value !== 'string' || value.length > max) fail(`${name} must be text (at most ${max} characters).`);
11
+ return value;
12
+ };
13
+ const integer = (value, fallback, min, max, name) => {
14
+ const result = value ?? fallback;
15
+ if (!Number.isSafeInteger(result) || result < min || result > max) fail(`${name} must be an integer from ${min} to ${max}.`);
16
+ return result;
17
+ };
18
+ const kinds = ['note', 'chat', 'bill', 'finance', 'task', 'waiting', 'bookmark', 'file', 'decision', 'log'];
19
+ export const compact = memory => ({
20
+ id: memory.id, title: (memory.payload?.title ?? memory.title ?? '').slice(0, 160),
21
+ preview: (memory.payload?.content ?? memory.text ?? '').slice(0, 320),
22
+ kind: memory.kind, tags: (memory.tags || []).slice(0, 20).map(tag => tag.slice(0, 128)),
23
+ scope: memory.scope?.projectId ? { projectId: memory.scope.projectId.slice(0, 256) } : {}, isPrivate: memory.isPrivate,
24
+ revision: memory.revision, updatedAt: memory.updatedAt, capturedAt: memory.capturedAt,
25
+ source: memory.source, status: memory.status,
26
+ ...(memory.origin?.deviceName ? { origin: { deviceName: memory.origin.deviceName.slice(0, 128) } } : {}),
27
+ });
28
+ const full = memory => ({ ...compact(memory), tags: memory.tags || [], payload: memory.payload ?? { title: memory.title, content: memory.text }, source: memory.source, status: memory.status, capturedAt: memory.capturedAt });
29
+
30
+ // One application service for both the authenticated panel and the agent tool.
31
+ export class MoryService {
32
+ constructor(config = {}, dependencies = {}) {
33
+ this.config = config;
34
+ this.env = dependencies.env ?? process.env;
35
+ this.readFile = dependencies.readFile ?? readFile;
36
+ this.fetch = dependencies.fetch ?? globalThis.fetch;
37
+ this.home = dependencies.home ?? homedir();
38
+ this.settings = dependencies.settings;
39
+ }
40
+ async connection(options = {}) {
41
+ const configPath = this.config.configPath || join(this.env.MORY_HOME || join(this.home, '.mory'), 'config.json');
42
+ let local = {};
43
+ try { local = JSON.parse(await this.readFile(configPath, 'utf8')); }
44
+ catch (error) {
45
+ if (error.code !== 'ENOENT') throw new MoryError(503, 'CONFIG_INVALID', 'Cannot read Mory configuration. Check config.json on the DSH host.');
46
+ }
47
+ const saved = options.saved ?? await this.settings?.connectionOverrides() ?? {};
48
+ const explicitUrl = this.config.apiUrl || this.env.MORY_API_URL || saved.apiUrl;
49
+ const localOrigin = `http://127.0.0.1:${local.port || 8787}`;
50
+ let url;
51
+ try { url = new URL(explicitUrl || localOrigin); }
52
+ catch { throw new MoryError(503, 'CONFIG_INVALID', 'Mory API URL is invalid.'); }
53
+ const loopback = ['127.0.0.1', 'localhost', '[::1]'].includes(url.hostname);
54
+ if (!['http:', 'https:'].includes(url.protocol) || (!loopback && url.protocol !== 'https:') || url.username || url.password || url.search || url.hash || url.pathname !== '/') {
55
+ throw new MoryError(503, 'CONFIG_INVALID', 'Mory API URL must be an HTTPS origin, or a local HTTP origin, without credentials.');
56
+ }
57
+ // Never forward the local token to a remote service implicitly.
58
+ const token = this.env[this.config.tokenEnv || 'MORY_API_TOKEN'] || (saved.tokenOrigin === url.origin ? saved.token : undefined) || (loopback && url.origin === localOrigin ? local.token : undefined) || '';
59
+ if (options.requireToken !== false && (typeof token !== 'string' || !token.trim())) throw new MoryError(503, 'SETUP_REQUIRED', 'Run mory init and mory start --background on the DSH host, or set MORY_API_URL and MORY_API_TOKEN.');
60
+ return { baseUrl: url.origin, token };
61
+ }
62
+ async request(path, method = 'GET', body, signal, connection) {
63
+ const { baseUrl, token } = connection || await this.connection();
64
+ const requestSignal = AbortSignal.any([AbortSignal.timeout(40_000), ...(signal ? [signal] : [])]);
65
+ let response;
66
+ try {
67
+ response = await this.fetch(baseUrl + path, {
68
+ method, headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' },
69
+ body: body === undefined ? undefined : JSON.stringify(body), signal: requestSignal,
70
+ redirect: 'error',
71
+ });
72
+ } catch (error) {
73
+ if (signal?.aborted) throw new MoryError(499, 'ABORTED', 'Request cancelled.');
74
+ throw new MoryError(503, 'UNREACHABLE', 'Cannot reach Mory. Check mory status on the DSH host.');
75
+ }
76
+ let result;
77
+ try {
78
+ const reader = response.body?.getReader();
79
+ if (!reader) throw new Error('Empty response');
80
+ const chunks = []; let size = 0;
81
+ try {
82
+ while (true) {
83
+ const { value, done } = await reader.read();
84
+ if (done) break;
85
+ size += value.byteLength;
86
+ if (size > 8 * 1024 * 1024) throw new MoryError(502, 'RESPONSE_TOO_LARGE', 'Mory response is too large. Use a smaller page or query.');
87
+ chunks.push(Buffer.from(value));
88
+ }
89
+ } finally { await reader.cancel().catch(() => {}); reader.releaseLock(); }
90
+ result = JSON.parse(Buffer.concat(chunks).toString('utf8'));
91
+ } catch (error) {
92
+ if (error instanceof MoryError) throw error;
93
+ if (signal?.aborted) throw new MoryError(499, 'ABORTED', 'Request cancelled.');
94
+ throw new MoryError(502, 'INVALID_RESPONSE', 'Mory returned an invalid response.');
95
+ }
96
+ if (!response.ok) {
97
+ const code = response.status === 409 ? 'REVISION_CONFLICT' : response.status === 401 ? 'UNAUTHORIZED' : 'MORY_API_ERROR';
98
+ throw new MoryError(response.status, code, response.status === 401 ? 'Mory token is invalid. Check the host configuration.' : String(result.error || `Mory API error (${response.status}).`));
99
+ }
100
+ return result;
101
+ }
102
+ async execute(action, input = {}, signal, actor = { type: 'plugin', id: 'dsh-mory' }) {
103
+ if (!input || typeof input !== 'object' || Array.isArray(input)) fail('Input must be an object.');
104
+ const scope = input.projectId ? { projectId: text(input.projectId, 'projectId', 256) } : {};
105
+ const memoryId = () => {
106
+ const id = text(input.memoryId, 'memoryId', 256);
107
+ if (!id.trim() || id === '.' || id === '..') fail('memoryId must be a nonempty memory identifier.');
108
+ return encodeURIComponent(id);
109
+ };
110
+ if (action === 'status') {
111
+ // /health is public in Mory; verify the token against a protected endpoint.
112
+ await this.request('/v1/memories?limit=1&offset=0', 'GET', undefined, signal);
113
+ return { connected: true };
114
+ }
115
+ if (action === 'list') {
116
+ const limit = integer(input.limit, 30, 1, 50, 'limit');
117
+ const offset = integer(input.offset, 0, 0, Number.MAX_SAFE_INTEGER, 'offset');
118
+ const params = new URLSearchParams({ limit: String(limit), offset: String(offset), ...scope });
119
+ const result = await this.request(`/v1/memories?${params}`, 'GET', undefined, signal);
120
+ return { ...result, memories: result.memories.map(compact) };
121
+ }
122
+ if (action === 'search' || action === 'context') {
123
+ const query = text(input.query, 'query', 4096);
124
+ const limit = integer(input.limit, 10, 1, 50, 'limit');
125
+ const result = await this.request(action === 'context' ? '/v1/context' : '/v1/memories/search', 'POST', { query, scope, limit }, signal);
126
+ if (action === 'search') return { results: result.results.map(item => ({ score: item.score, memory: compact(item.memory) })) };
127
+ const memories = result.memories.map(compact);
128
+ const context = String(result.context || '');
129
+ return { memories, context: context.slice(0, 24_000), truncated: context.length > 24_000 };
130
+ }
131
+ if (action === 'get') {
132
+ const result = await this.request(`/v1/memories/${memoryId()}`, 'GET', undefined, signal);
133
+ return { memory: full(result.memory) };
134
+ }
135
+ if (action === 'create' || action === 'update') {
136
+ const body = {};
137
+ if (input.text !== undefined) body.text = text(input.text, 'text');
138
+ if (input.title !== undefined) body.title = text(input.title, 'title', 4096);
139
+ if (input.kind !== undefined) { if (!kinds.includes(input.kind)) fail('Invalid memory kind.'); body.kind = input.kind; }
140
+ if (input.tags !== undefined) {
141
+ if (!Array.isArray(input.tags) || input.tags.length > 50 || input.tags.some(tag => typeof tag !== 'string' || tag.length > 128)) fail('tags must contain at most 50 short strings.');
142
+ body.tags = input.tags;
143
+ }
144
+ if (input.isPrivate !== undefined) { if (typeof input.isPrivate !== 'boolean') fail('isPrivate must be boolean.'); body.isPrivate = input.isPrivate; }
145
+ if (action === 'create') {
146
+ if (!(body.text || '').trim() && !(body.title || '').trim()) fail('A title or text is required.');
147
+ Object.assign(body, { source: 'dsh', actor, scope });
148
+ if (this.settings) body.origin = await this.settings.deviceOrigin();
149
+ } else {
150
+ body.expectedRevision = integer(input.expectedRevision, undefined, 1, Number.MAX_SAFE_INTEGER, 'expectedRevision');
151
+ if (Object.keys(body).length === 1) fail('Provide a field to update.');
152
+ }
153
+ const result = await this.request(action === 'create' ? '/v1/memories' : `/v1/memories/${memoryId()}`, action === 'create' ? 'POST' : 'PATCH', body, signal);
154
+ if ((action === 'create' && !result.duplicate) || action === 'update') await this.settings?.afterMutation();
155
+ return { ...result, memory: full(result.memory) };
156
+ }
157
+ if (action === 'forget') {
158
+ const revision = integer(input.expectedRevision, undefined, 1, Number.MAX_SAFE_INTEGER, 'expectedRevision');
159
+ const result = await this.request(`/v1/memories/${memoryId()}`, 'DELETE', { expectedRevision: revision }, signal);
160
+ await this.settings?.afterMutation();
161
+ return { ok: result.ok, memoryId: result.memory.id, revision: result.memory.revision };
162
+ }
163
+ fail('Unknown action.');
164
+ }
165
+ }
package/settings.mjs ADDED
@@ -0,0 +1,294 @@
1
+ import * as nativeFs from 'node:fs/promises';
2
+ import { homedir, hostname } from 'node:os';
3
+ import { dirname, join } from 'node:path';
4
+ import { randomUUID, createHash } from 'node:crypto';
5
+ import { MoryError } from './service.mjs';
6
+ import { mergeSnapshots, normalizeMemory } from './protocol.mjs';
7
+
8
+ export const appearanceIds = ['default', 'midnight', 'ocean', 'sunset', 'forest', 'lavender', 'graphite', 'rose', 'terminal', 'sand', 'cobalt', 'plum', 'mint', 'copper', 'mono', 'abyss', 'ember', 'aurora', 'violet', 'navy', 'crimson', 'sapphire', 'moss', 'solar', 'ink'];
9
+ const maxBytes = 8 * 1024 * 1024;
10
+ const invalid = message => { throw new MoryError(400, 'INVALID_INPUT', message); };
11
+ const object = (value, label) => { if (!value || typeof value !== 'object' || Array.isArray(value)) invalid(`${label} must be an object.`); return value; };
12
+ const string = (value, label, max = 256) => { if (typeof value !== 'string' || value.length > max || /[\u0000-\u001f]/u.test(value)) invalid(`${label} must be text without control characters, at most ${max} characters.`); return value.trim(); };
13
+ function keys(value, allowed, label) { object(value, label); if (Object.keys(value).some(key => !allowed.includes(key))) invalid(`Unknown ${label} field.`); }
14
+ export function validateApiUrl(value) {
15
+ const input = string(value, 'apiUrl', 2048); if (!input) return '';
16
+ let url; try { url = new URL(input); } catch { invalid('Invalid Mory API origin.'); }
17
+ const local = ['127.0.0.1', 'localhost', '[::1]'].includes(url.hostname);
18
+ if (!['http:', 'https:'].includes(url.protocol) || (!local && url.protocol !== 'https:') || url.username || url.password || url.search || url.hash || url.pathname !== '/') invalid('Mory API must be an HTTPS origin or a local HTTP origin, without credentials or a path.');
19
+ return url.origin;
20
+ }
21
+ export function parseRepository(value, provider) {
22
+ const input = string(value, 'repositoryUrl', 2048); if (!input) return null;
23
+ let url; try { url = new URL(input); } catch { invalid('Use an HTTPS GitHub or Gitee repository URL.'); }
24
+ const host = provider === 'gitee' ? 'gitee.com' : 'github.com';
25
+ if (url.protocol !== 'https:' || url.hostname !== host || url.port || url.username || url.password || url.search || url.hash) invalid('Repository URL does not match the selected provider.');
26
+ const parts = url.pathname.replace(/\/$/, '').replace(/\.git$/, '').split('/').filter(Boolean);
27
+ if (parts.length !== 2 || parts.some(part => !/^[a-zA-Z0-9_.-]+$/.test(part) || part === '.' || part === '..')) invalid('Repository URL must identify one owner and repository.');
28
+ return { owner: parts[0], repo: parts[1], url: `https://${host}/${parts[0]}/${parts[1]}` };
29
+ }
30
+ function syncPath(value) {
31
+ const path = string(value, 'sync.path', 1024);
32
+ if (!path || path.startsWith('/') || path.endsWith('/') || path.includes('\\') || path.split('/').some(part => !part || part === '.' || part === '..')) invalid('Sync path must be a relative file path without traversal.');
33
+ return path;
34
+ }
35
+ function secret(current, incoming) {
36
+ if (incoming === undefined || incoming === '') return current;
37
+ if (incoming === null) return '';
38
+ const value = string(incoming, 'token', 8192); return value || current;
39
+ }
40
+ const defaults = () => ({ schemaVersion: 1, revision: 0, appearance: 'default', language: 'auto', connection: { apiUrl: '', token: '', tokenOrigin: '' }, sync: { provider: 'github', repositoryUrl: '', branch: '', path: 'mory/memories.json', deviceName: '', token: '', tokenProvider: '', autoSync: false }, lastSync: null });
41
+
42
+ export async function parseSyncSnapshot(value) {
43
+ const parsed = Array.isArray(value) ? { memories: value, tombstones: [] } : value;
44
+ try {
45
+ object(parsed, 'snapshot');
46
+ if (parsed.schemaVersion !== undefined && ![1, 2, 3].includes(parsed.schemaVersion)) throw new Error('version');
47
+ if (!Array.isArray(parsed.memories) || parsed.memories.length > 10000 || (parsed.tombstones !== undefined && (!Array.isArray(parsed.tombstones) || parsed.tombstones.length > 100000 || parsed.tombstones.some(id => typeof id !== 'string' || !id || id.length > 256)))) throw new Error('shape');
48
+ if (parsed.memories.some(memory => !memory || typeof memory.id !== 'string' || !memory.id)) throw new Error('identifier');
49
+ const memories = await Promise.all(parsed.memories.map(normalizeMemory));
50
+ const tombstones = [...new Set([...(parsed.tombstones || []), ...memories.filter(memory => memory.status !== 'active').map(memory => memory.id)])];
51
+ return { schemaVersion: 2, exportedAt: new Date().toISOString(), memories: memories.filter(memory => memory.status === 'active' && !tombstones.includes(memory.id)), tombstones };
52
+ } catch { throw new MoryError(400, 'INVALID_SNAPSHOT', 'Invalid or unsupported memory snapshot. Local data was preserved.'); }
53
+ }
54
+
55
+ export class MorySettingsService {
56
+ constructor(config = {}, dependencies = {}) {
57
+ this.config = config;
58
+ this.env = dependencies.env ?? process.env;
59
+ this.home = dependencies.home ?? homedir();
60
+ this.hostname = dependencies.hostname ?? hostname;
61
+ this.fs = { ...nativeFs, ...dependencies.fs };
62
+ this.fetch = dependencies.fetch ?? globalThis.fetch;
63
+ this.setTimeout = dependencies.setTimeout ?? globalThis.setTimeout;
64
+ this.clearTimeout = dependencies.clearTimeout ?? globalThis.clearTimeout;
65
+ this.path = config.settingsPath || join(this.env.MORY_HOME || join(this.home, '.mory'), 'dsh-plugin.json');
66
+ this.timer = null; this.autoController = null; this.syncing = false; this.dirty = false; this.disposed = false; this.lastSync = null;
67
+ }
68
+ attach(service) { this.service = service; }
69
+ async read() {
70
+ let raw;
71
+ try { raw = await this.fs.readFile(this.path, 'utf8'); }
72
+ catch (error) { if (error.code === 'ENOENT') return defaults(); throw new MoryError(503, 'SETTINGS_IO', 'Cannot read Mory plugin settings.'); }
73
+ try {
74
+ if (Buffer.byteLength(raw) > 64 * 1024) throw new Error('size');
75
+ const value = JSON.parse(raw); object(value, 'settings');
76
+ if (value.schemaVersion !== 1 || !Number.isSafeInteger(value.revision) || value.revision < 0 || !appearanceIds.includes(value.appearance) || !['auto', 'en', 'zh'].includes(value.language)) throw new Error('schema');
77
+ object(value.connection, 'connection'); object(value.sync, 'sync');
78
+ const state = defaults(); Object.assign(state, value);
79
+ state.connection = { ...state.connection, ...value.connection };
80
+ state.sync = { ...defaults().sync, ...value.sync };
81
+ validateApiUrl(state.connection.apiUrl);
82
+ if (typeof state.connection.token !== 'string' || typeof state.connection.tokenOrigin !== 'string' || typeof state.sync.token !== 'string' || !['github', 'gitee'].includes(state.sync.provider) || typeof state.sync.autoSync !== 'boolean') throw new Error('fields');
83
+ parseRepository(state.sync.repositoryUrl, state.sync.provider); syncPath(state.sync.path);
84
+ return state;
85
+ } catch { throw new MoryError(503, 'SETTINGS_INVALID', 'Mory plugin settings are invalid. Repair the plugin settings file before continuing.'); }
86
+ }
87
+ async connectionOverrides() { return (await this.read()).connection; }
88
+ async deviceOrigin() {
89
+ const state = await this.read(); const machine = this.hostname();
90
+ return { deviceId: `dsh-${createHash('sha256').update(`${machine}\u0000${this.path}`).digest('hex').slice(0, 24)}`, deviceName: state.sync.deviceName || machine };
91
+ }
92
+ async safe(state) {
93
+ let effective = { baseUrl: state.connection.apiUrl || 'http://127.0.0.1:8787', token: '' };
94
+ if (this.service) { try { effective = await this.service.connection({ requireToken: false, saved: state.connection }); } catch {} }
95
+ return {
96
+ revision: state.revision, appearance: state.appearance, language: state.language,
97
+ connection: { apiUrl: state.connection.apiUrl, effectiveApiUrl: effective.baseUrl, tokenConfigured: !!effective.token, tokenStored: !!state.connection.token, managedByEnvironment: !!(this.service?.config?.apiUrl || this.service?.env?.MORY_API_URL || this.service?.env?.[this.service?.config?.tokenEnv || 'MORY_API_TOKEN'] || this.config.apiUrl || this.env.MORY_API_URL || this.env[this.config.tokenEnv || 'MORY_API_TOKEN']) },
98
+ sync: { provider: state.sync.provider, repositoryUrl: state.sync.repositoryUrl, branch: state.sync.branch, path: state.sync.path, deviceName: state.sync.deviceName, autoSync: state.sync.autoSync, tokenConfigured: !!state.sync.token && state.sync.tokenProvider === state.sync.provider, tokenStored: !!state.sync.token },
99
+ lastSync: this.lastSync || state.lastSync || null,
100
+ };
101
+ }
102
+ async locked(operation) {
103
+ let lock, temporary;
104
+ try {
105
+ await this.fs.mkdir(dirname(this.path), { recursive: true, mode: 0o700 });
106
+ try { lock = await this.fs.open(`${this.path}.lock`, 'wx', 0o600); }
107
+ catch (error) { if (error.code === 'EEXIST') throw new MoryError(409, 'SETTINGS_BUSY', 'Another settings update is in progress. Retry.'); throw error; }
108
+ const state = await this.read(); const next = await operation(state);
109
+ temporary = `${this.path}.${randomUUID()}.tmp`;
110
+ await this.fs.writeFile(temporary, JSON.stringify(next, null, 2) + '\n', { mode: 0o600, flag: 'wx' });
111
+ await this.fs.rename(temporary, this.path); temporary = undefined;
112
+ return next;
113
+ } catch (error) { if (error instanceof MoryError) throw error; throw new MoryError(503, 'SETTINGS_IO', 'Cannot save Mory plugin settings.'); }
114
+ finally { if (temporary) await this.fs.unlink(temporary).catch(() => {}); if (lock) { await lock.close().catch(() => {}); await this.fs.unlink(`${this.path}.lock`).catch(() => {}); } }
115
+ }
116
+ async update(input) {
117
+ keys(input, ['expectedRevision', 'appearance', 'language', 'connection', 'sync'], 'settings');
118
+ if (!Number.isSafeInteger(input.expectedRevision) || input.expectedRevision < 0) invalid('expectedRevision must be the revision returned by settings.get.');
119
+ if (Object.keys(input).length < 2) invalid('Provide a setting to update.');
120
+ const state = await this.locked(async current => {
121
+ if (input.expectedRevision !== current.revision) throw new MoryError(409, 'SETTINGS_CONFLICT', 'Settings changed elsewhere. Reload before saving.');
122
+ const next = structuredClone(current);
123
+ if (input.appearance !== undefined) { if (!appearanceIds.includes(input.appearance)) invalid('Unknown Mory theme.'); next.appearance = input.appearance; }
124
+ if (input.language !== undefined) { if (!['auto', 'en', 'zh'].includes(input.language)) invalid('Language must be auto, en, or zh.'); next.language = input.language; }
125
+ if (input.connection !== undefined) {
126
+ keys(input.connection, ['apiUrl', 'token'], 'connection');
127
+ if (input.connection.apiUrl !== undefined) next.connection.apiUrl = validateApiUrl(input.connection.apiUrl);
128
+ const changedToken = secret(current.connection.token, input.connection.token);
129
+ if (input.connection.token !== undefined && input.connection.token !== '') {
130
+ next.connection.token = changedToken;
131
+ let origin = next.connection.apiUrl;
132
+ if (!origin && changedToken && this.service) origin = (await this.service.connection({ requireToken: false, saved: { ...next.connection, token: '' } })).baseUrl;
133
+ if (!origin && changedToken) invalid('Set apiUrl when saving a connection token.');
134
+ next.connection.tokenOrigin = changedToken ? origin : '';
135
+ }
136
+ }
137
+ if (input.sync !== undefined) {
138
+ keys(input.sync, ['provider', 'repositoryUrl', 'branch', 'path', 'deviceName', 'autoSync', 'token'], 'sync');
139
+ if (input.sync.provider !== undefined) { if (!['github', 'gitee'].includes(input.sync.provider)) invalid('Sync provider must be github or gitee.'); next.sync.provider = input.sync.provider; }
140
+ if (input.sync.repositoryUrl !== undefined) next.sync.repositoryUrl = string(input.sync.repositoryUrl, 'repositoryUrl', 2048);
141
+ const repository = parseRepository(next.sync.repositoryUrl, next.sync.provider); if (repository) next.sync.repositoryUrl = repository.url;
142
+ for (const field of ['branch', 'deviceName']) if (input.sync[field] !== undefined) next.sync[field] = string(input.sync[field], `sync.${field}`);
143
+ if (input.sync.path !== undefined) next.sync.path = syncPath(input.sync.path);
144
+ if (input.sync.autoSync !== undefined) { if (typeof input.sync.autoSync !== 'boolean') invalid('autoSync must be boolean.'); next.sync.autoSync = input.sync.autoSync; }
145
+ // Preserve blank-token semantics, while keeping a saved credential on its original provider.
146
+ next.sync.token = secret(current.sync.token, input.sync.token);
147
+ if (input.sync.token !== undefined && input.sync.token !== '') next.sync.tokenProvider = next.sync.token ? next.sync.provider : '';
148
+ }
149
+ next.revision++;
150
+ return next;
151
+ });
152
+ // Saving settings never starts synchronization.
153
+ if (!state.sync.autoSync && this.timer !== null) { this.clearTimeout(this.timer); this.timer = null; }
154
+ return this.safe(state);
155
+ }
156
+ async remote(url, sync, method = 'GET', body, signal, allow404 = false) {
157
+ const endpoint = new URL(url);
158
+ if (sync.provider === 'gitee' && method === 'GET') endpoint.searchParams.set('access_token', sync.token);
159
+ const headers = { Accept: sync.provider === 'gitee' ? 'application/json' : 'application/vnd.github+json', Authorization: `${sync.provider === 'gitee' ? 'token' : 'Bearer'} ${sync.token}`, 'content-type': 'application/json', ...(sync.provider === 'github' ? { 'X-GitHub-Api-Version': '2022-11-28' } : {}) };
160
+ const serialized = body === undefined ? undefined : JSON.stringify(body);
161
+ if (serialized && Buffer.byteLength(serialized) > maxBytes) throw new MoryError(413, 'SNAPSHOT_TOO_LARGE', 'The sync snapshot exceeds the 8 MiB request limit.');
162
+ let response;
163
+ try { response = await this.fetch(endpoint.href, { method, headers, body: serialized, redirect: 'error', signal: AbortSignal.any([AbortSignal.timeout(40_000), ...(signal ? [signal] : [])]) }); }
164
+ catch { throw new MoryError(signal?.aborted ? 499 : 503, signal?.aborted ? 'ABORTED' : 'REMOTE_UNREACHABLE', signal?.aborted ? 'Sync cancelled.' : 'Cannot reach the repository provider. A failed upload may have completed remotely; check before retrying.'); }
165
+ if (response.status === 404 && allow404) { await response.body?.cancel().catch(() => {}); return null; }
166
+ if (!response.ok) {
167
+ await response.body?.cancel().catch(() => {});
168
+ const error = new MoryError(response.status, response.status === 409 || response.status === 422 ? 'REMOTE_CONFLICT' : 'REMOTE_ERROR', `Repository provider request failed (${response.status}). Check its token, permissions, branch, and repository.`);
169
+ throw error;
170
+ }
171
+ // A successful mutation is committed even if its unused response body is empty or malformed.
172
+ if (method !== 'GET') { await response.body?.cancel().catch(() => {}); return { committed: true }; }
173
+ const reader = response.body?.getReader(); if (!reader) throw new MoryError(502, 'INVALID_RESPONSE', 'Repository provider returned an empty response.');
174
+ const chunks = []; let size = 0;
175
+ try {
176
+ while (true) { const { value, done } = await reader.read(); if (done) break; size += value.byteLength; if (size > maxBytes) throw new MoryError(502, 'RESPONSE_TOO_LARGE', 'Repository provider response exceeds 8 MiB.'); chunks.push(Buffer.from(value)); }
177
+ return JSON.parse(Buffer.concat(chunks).toString('utf8'));
178
+ } catch (error) {
179
+ if (error instanceof MoryError) throw error;
180
+ if (signal?.aborted) throw new MoryError(499, 'ABORTED', 'Sync cancelled.');
181
+ throw new MoryError(502, 'INVALID_RESPONSE', 'Repository provider returned an invalid response.');
182
+ } finally { await reader.cancel().catch(() => {}); reader.releaseLock(); }
183
+ }
184
+ async repository(sync, signal) {
185
+ const parsed = parseRepository(sync.repositoryUrl, sync.provider);
186
+ if (!parsed || !sync.token || sync.tokenProvider !== sync.provider) throw new MoryError(400, 'SYNC_SETUP_REQUIRED', 'Save a repository URL and a token for the selected provider before checking or syncing.');
187
+ const base = sync.provider === 'gitee' ? 'https://gitee.com/api/v5' : 'https://api.github.com';
188
+ const endpoint = `${base}/repos/${encodeURIComponent(parsed.owner)}/${encodeURIComponent(parsed.repo)}`;
189
+ const info = await this.remote(endpoint, sync, 'GET', undefined, signal);
190
+ if (info.private !== true) throw new MoryError(400, 'PRIVATE_REPOSITORY_REQUIRED', 'Use a private repository: synchronization includes private memories and deletion records.');
191
+ const branch = string(sync.branch || info.default_branch || 'main', 'branch');
192
+ return { endpoint, branch, parsed };
193
+ }
194
+ async remoteSnapshot(sync, repository, signal) {
195
+ const endpoint = `${repository.endpoint}/contents/${sync.path.split('/').map(encodeURIComponent).join('/')}`;
196
+ const readUrl = new URL(endpoint); readUrl.searchParams.set('ref', repository.branch);
197
+ const file = await this.remote(readUrl.href, sync, 'GET', undefined, signal, true);
198
+ if (file === null) return { endpoint, sha: undefined, exists: false, snapshot: { schemaVersion: 2, memories: [], tombstones: [] } };
199
+ if (file.encoding !== 'base64' || typeof file.content !== 'string' || typeof file.sha !== 'string' || !file.sha || !/^[A-Za-z0-9+/=\s]*$/.test(file.content)) throw new MoryError(502, 'INVALID_SNAPSHOT', 'Repository file is not a supported base64 memory snapshot.');
200
+ let decoded; try { decoded = JSON.parse(Buffer.from(file.content.replace(/\s/g, ''), 'base64').toString('utf8')); } catch { throw new MoryError(400, 'INVALID_SNAPSHOT', 'Repository file does not contain valid memory JSON.'); }
201
+ return { endpoint, sha: file.sha, exists: true, snapshot: await parseSyncSnapshot(decoded) };
202
+ }
203
+ async health(signal) {
204
+ if (!this.service) throw new MoryError(503, 'UNAVAILABLE', 'Mory service is unavailable.');
205
+ try {
206
+ const connection = await this.service.connection();
207
+ const response = await this.service.request('/v1/memories?limit=1&offset=0', 'GET', undefined, signal, connection);
208
+ return { connected: true, apiUrl: connection.baseUrl, total: response.total, storage: 'mory-api', settingsRevision: (await this.read()).revision };
209
+ } catch (error) { throw new MoryError(error.status || 503, error.code || 'UNREACHABLE', 'Unable to connect to the configured Mory service. Check the API origin, token and running service.'); }
210
+ }
211
+ async saveStatus(status) {
212
+ this.lastSync = status;
213
+ try { await this.locked(state => ({ ...state, lastSync: status })); }
214
+ catch { this.lastSync = { ...status, statusPersisted: false }; }
215
+ }
216
+ async synchronize(mode, signal) {
217
+ if (!['pull', 'push', 'merge'].includes(mode)) invalid('Sync mode must be pull, push, or merge.');
218
+ if (this.syncing) throw new MoryError(409, 'SYNC_BUSY', 'Another synchronization is in progress.');
219
+ if (!this.service) throw new MoryError(503, 'UNAVAILABLE', 'Mory service is unavailable.');
220
+ signal = AbortSignal.any([AbortSignal.timeout(180_000), ...(signal ? [signal] : [])]);
221
+ this.syncing = true;
222
+ if (this.timer !== null) { this.clearTimeout(this.timer); this.timer = null; }
223
+ const startedAt = new Date().toISOString(); let remoteCommitted = false; let sync;
224
+ try {
225
+ const state = await this.read(); sync = state.sync;
226
+ this.lastSync = { status: 'running', mode, startedAt };
227
+ const repository = await this.repository(sync, signal);
228
+ const connection = await this.service.connection();
229
+ const base = await this.service.request('/v1/snapshot', 'GET', undefined, signal, connection);
230
+ if (!Number.isSafeInteger(base.generation) || base.generation < 0) throw new MoryError(502, 'INVALID_RESPONSE', 'Mory returned an invalid repository generation.');
231
+ const local = await parseSyncSnapshot(base);
232
+ let snapshot, exists, retries = 0;
233
+ for (let attempt = 0; attempt < 4; attempt++) {
234
+ const remote = await this.remoteSnapshot(sync, repository, signal); exists = remote.exists;
235
+ snapshot = mergeSnapshots(local, remote.snapshot);
236
+ if (mode === 'pull') break;
237
+ const content = Buffer.from(JSON.stringify(snapshot, null, 2), 'utf8').toString('base64');
238
+ const body = { message: `sync: update Mory memories (${snapshot.memories.length})`, branch: repository.branch, content, ...(remote.sha ? { sha: remote.sha } : {}), ...(sync.provider === 'gitee' ? { access_token: sync.token } : {}) };
239
+ try { await this.remote(remote.endpoint, sync, sync.provider === 'gitee' && !remote.sha ? 'POST' : 'PUT', body, signal); remoteCommitted = true; break; }
240
+ catch (error) { if (error.code !== 'REMOTE_CONFLICT' || attempt === 3) throw error; retries++; }
241
+ }
242
+ let localApplied = false;
243
+ try {
244
+ await this.service.request('/v1/sync', 'POST', { memories: snapshot.memories, tombstones: snapshot.tombstones, expectedGeneration: base.generation }, signal, connection);
245
+ localApplied = true;
246
+ } catch (error) {
247
+ if (!remoteCommitted) throw error;
248
+ const result = { ok: false, status: 'partial', mode, remoteCommitted: true, localApplied: false, code: error.code || 'LOCAL_SYNC_FAILED', message: 'The remote snapshot was committed, but local data changed or could not be updated. Local data was preserved; pull or merge again.', startedAt, finishedAt: new Date().toISOString() };
249
+ await this.saveStatus(result); return result;
250
+ }
251
+ const result = { ok: true, status: 'success', mode, provider: sync.provider, branch: repository.branch, remoteCommitted, localApplied, remoteExists: exists, memories: snapshot.memories.length, tombstones: snapshot.tombstones.length, retries, startedAt, finishedAt: new Date().toISOString() };
252
+ await this.saveStatus(result); return result;
253
+ } catch (error) {
254
+ const known = error instanceof MoryError;
255
+ const failure = new MoryError(known ? error.status : 503, known ? error.code : 'SYNC_FAILED', known && !['MORY_API_ERROR', 'REVISION_CONFLICT'].includes(error.code) ? error.message : 'Synchronization failed. Local data was preserved.');
256
+ await this.saveStatus({ ok: false, status: 'error', mode, remoteCommitted, localApplied: false, code: failure.code, message: failure.message, startedAt, finishedAt: new Date().toISOString() });
257
+ throw failure;
258
+ } finally {
259
+ this.syncing = false;
260
+ if (this.dirty && !this.disposed) { this.dirty = false; await this.afterMutation(); }
261
+ }
262
+ }
263
+ async afterMutation() {
264
+ if (this.disposed) return;
265
+ let state;
266
+ try { state = await this.read(); } catch { return; }
267
+ if (!state.sync.autoSync) return;
268
+ if (this.syncing) { this.dirty = true; return; }
269
+ if (this.timer !== null) this.clearTimeout(this.timer);
270
+ this.timer = this.setTimeout(async () => {
271
+ this.timer = null;
272
+ if (this.disposed) return;
273
+ try { if (!(await this.read()).sync.autoSync) return; this.autoController = new AbortController(); await this.synchronize('merge', this.autoController.signal); }
274
+ catch { /* synchronize records a sanitized status for the Settings page. */ }
275
+ finally { this.autoController = null; }
276
+ }, 5000);
277
+ this.timer?.unref?.();
278
+ }
279
+ dispose() { this.disposed = true; this.dirty = false; if (this.timer !== null) this.clearTimeout(this.timer); this.timer = null; this.autoController?.abort(); }
280
+ async execute(action, input = {}, signal) {
281
+ object(input, 'settings input');
282
+ if (action === 'get') return this.safe(await this.read());
283
+ if (action === 'update') return this.update(input);
284
+ if (action === 'health') return this.health(signal);
285
+ if (action === 'check') {
286
+ if (input.target === 'mory') return this.health(signal);
287
+ if (input.target !== 'sync') invalid('Check target must be mory or sync.');
288
+ const state = await this.read(); const repository = await this.repository(state.sync, signal);
289
+ return { connected: true, provider: state.sync.provider, repositoryUrl: repository.parsed.url, branch: repository.branch, private: true };
290
+ }
291
+ if (action === 'sync') return this.synchronize(input.mode, signal);
292
+ invalid('Unknown settings action.');
293
+ }
294
+ }