@ectplsm/relic 0.3.2 → 0.5.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.
- package/LICENCE.md +1 -1
- package/README.md +20 -13
- package/dist/adapters/mikoshi/client.d.ts +21 -0
- package/dist/adapters/mikoshi/client.js +84 -0
- package/dist/core/ports/index.d.ts +1 -0
- package/dist/core/ports/mikoshi.d.ts +937 -0
- package/dist/core/ports/mikoshi.js +209 -0
- package/dist/core/sync/crypto.d.ts +55 -0
- package/dist/core/sync/crypto.js +189 -0
- package/dist/core/sync/memory-hash.d.ts +35 -0
- package/dist/core/sync/memory-hash.js +61 -0
- package/dist/core/sync/memory-merge.d.ts +13 -0
- package/dist/core/sync/memory-merge.js +84 -0
- package/dist/core/sync/normalize.d.ts +10 -0
- package/dist/core/sync/normalize.js +21 -0
- package/dist/core/sync/persona-hash.d.ts +15 -0
- package/dist/core/sync/persona-hash.js +36 -0
- package/dist/core/usecases/archive-cursor-update.d.ts +6 -0
- package/dist/core/usecases/archive-cursor-update.js +18 -0
- package/dist/core/usecases/archive-pending.d.ts +18 -1
- package/dist/core/usecases/archive-pending.js +37 -2
- package/dist/core/usecases/claw-pull.d.ts +44 -0
- package/dist/core/usecases/claw-pull.js +117 -0
- package/dist/core/usecases/extract.d.ts +0 -1
- package/dist/core/usecases/extract.js +13 -27
- package/dist/core/usecases/index.d.ts +11 -3
- package/dist/core/usecases/index.js +10 -2
- package/dist/core/usecases/inject.d.ts +1 -0
- package/dist/core/usecases/inject.js +2 -0
- package/dist/core/usecases/memory-write.d.ts +5 -0
- package/dist/core/usecases/memory-write.js +26 -16
- package/dist/core/usecases/mikoshi-clone.d.ts +29 -0
- package/dist/core/usecases/mikoshi-clone.js +94 -0
- package/dist/core/usecases/mikoshi-download.d.ts +29 -0
- package/dist/core/usecases/mikoshi-download.js +94 -0
- package/dist/core/usecases/mikoshi-memory-pull.d.ts +38 -0
- package/dist/core/usecases/mikoshi-memory-pull.js +142 -0
- package/dist/core/usecases/mikoshi-memory-push.d.ts +34 -0
- package/dist/core/usecases/mikoshi-memory-push.js +105 -0
- package/dist/core/usecases/mikoshi-memory-sync.d.ts +36 -0
- package/dist/core/usecases/mikoshi-memory-sync.js +175 -0
- package/dist/core/usecases/mikoshi-pull.d.ts +44 -0
- package/dist/core/usecases/mikoshi-pull.js +124 -0
- package/dist/core/usecases/mikoshi-push.d.ts +39 -0
- package/dist/core/usecases/mikoshi-push.js +127 -0
- package/dist/core/usecases/mikoshi-status.d.ts +37 -0
- package/dist/core/usecases/mikoshi-status.js +92 -0
- package/dist/core/usecases/sync.d.ts +0 -5
- package/dist/core/usecases/sync.js +23 -61
- package/dist/interfaces/cli/commands/claw.js +134 -85
- package/dist/interfaces/cli/commands/config.js +60 -1
- package/dist/interfaces/cli/commands/create.js +17 -16
- package/dist/interfaces/cli/commands/init.js +11 -10
- package/dist/interfaces/cli/commands/list.js +9 -7
- package/dist/interfaces/cli/commands/migrate.js +8 -7
- package/dist/interfaces/cli/commands/mikoshi.d.ts +2 -0
- package/dist/interfaces/cli/commands/mikoshi.js +565 -0
- package/dist/interfaces/cli/commands/refresh-samples.js +10 -9
- package/dist/interfaces/cli/index.js +2 -0
- package/dist/interfaces/cli/output.d.ts +5 -0
- package/dist/interfaces/cli/output.js +16 -0
- package/dist/interfaces/cli/spinner.d.ts +13 -0
- package/dist/interfaces/cli/spinner.js +47 -0
- package/dist/interfaces/mcp/index.js +109 -38
- package/dist/shared/config.d.ts +25 -5
- package/dist/shared/config.js +33 -5
- package/dist/shared/engram-composer.d.ts +1 -1
- package/dist/shared/engram-composer.js +9 -5
- package/package.json +1 -1
|
@@ -0,0 +1,565 @@
|
|
|
1
|
+
import { LocalEngramRepository } from "../../../adapters/local/index.js";
|
|
2
|
+
import { MikoshiApiClient } from "../../../adapters/mikoshi/client.js";
|
|
3
|
+
import { printBlank, printDetail, printErrorDetail, printErrorLine, printLine } from "../output.js";
|
|
4
|
+
import { startSpinner } from "../spinner.js";
|
|
5
|
+
import { MikoshiStatus, MikoshiStatusEngramNotFoundError, MikoshiStatusCloudNotFoundError, } from "../../../core/usecases/mikoshi-status.js";
|
|
6
|
+
import { MikoshiPush, MikoshiPushEngramNotFoundError, MikoshiPushPersonaConflictError, MikoshiPushPersonaHashError, } from "../../../core/usecases/mikoshi-push.js";
|
|
7
|
+
import { MikoshiDownload, MikoshiDownloadAlreadyExistsError, MikoshiDownloadCloudNotFoundError, MikoshiDownloadPersonaMissingError, } from "../../../core/usecases/mikoshi-download.js";
|
|
8
|
+
import { MikoshiPull, MikoshiPullEngramNotFoundError, MikoshiPullCloudNotFoundError, MikoshiPullPersonaMissingError, } from "../../../core/usecases/mikoshi-pull.js";
|
|
9
|
+
import { MikoshiMemorySync, MikoshiMemorySyncEngramNotFoundError, MikoshiMemorySyncCloudNotFoundError, MikoshiMemorySyncDecryptError, } from "../../../core/usecases/mikoshi-memory-sync.js";
|
|
10
|
+
import { MikoshiApiError } from "../../../core/ports/mikoshi.js";
|
|
11
|
+
import { readPassphrase } from "../../../core/sync/crypto.js";
|
|
12
|
+
import { createInterface } from "node:readline";
|
|
13
|
+
import { ensureInitialized, resolveEngramsPath, resolveMikoshiUrl, resolveMikoshiApiKey, resolveMikoshiPassphrase, } from "../../../shared/config.js";
|
|
14
|
+
export function registerMikoshiCommand(program) {
|
|
15
|
+
const mikoshi = program
|
|
16
|
+
.command("mikoshi")
|
|
17
|
+
.description("Manage cloud sync with Mikoshi");
|
|
18
|
+
// relic mikoshi list
|
|
19
|
+
mikoshi
|
|
20
|
+
.command("list")
|
|
21
|
+
.description("List all Engrams registered on Mikoshi")
|
|
22
|
+
.action(async () => {
|
|
23
|
+
await ensureInitialized();
|
|
24
|
+
const apiKey = await resolveMikoshiApiKey();
|
|
25
|
+
if (!apiKey) {
|
|
26
|
+
printError("Error: Mikoshi API key is not configured.");
|
|
27
|
+
printErrorDetail("Set one with: relic config mikoshi-api-key <key>");
|
|
28
|
+
process.exit(1);
|
|
29
|
+
}
|
|
30
|
+
const mikoshiUrl = await resolveMikoshiUrl();
|
|
31
|
+
const client = new MikoshiApiClient(mikoshiUrl, apiKey);
|
|
32
|
+
try {
|
|
33
|
+
const engrams = await client.getEngrams();
|
|
34
|
+
if (engrams.length === 0) {
|
|
35
|
+
printBlank();
|
|
36
|
+
printLine("No Engrams on Mikoshi.");
|
|
37
|
+
printBlank();
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
printBlank();
|
|
41
|
+
for (const e of engrams) {
|
|
42
|
+
const mem = e.memory?.hasMemory ? "memory" : "";
|
|
43
|
+
const vis = e.visibility === "PRIVATE" ? dim("private") : e.visibility.toLowerCase();
|
|
44
|
+
printLine(`${e.name} ${dim(`(${e.sourceEngramId})`)} ${vis} ${mem}`);
|
|
45
|
+
}
|
|
46
|
+
printBlank();
|
|
47
|
+
printLine(`${engrams.length} engram(s)`);
|
|
48
|
+
printBlank();
|
|
49
|
+
}
|
|
50
|
+
catch (err) {
|
|
51
|
+
if (err instanceof MikoshiApiError) {
|
|
52
|
+
handleMikoshiApiError(err);
|
|
53
|
+
}
|
|
54
|
+
throw err;
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
// relic mikoshi status -e <id>
|
|
58
|
+
mikoshi
|
|
59
|
+
.command("status")
|
|
60
|
+
.description("Show sync status between local Engram and Mikoshi cloud")
|
|
61
|
+
.requiredOption("-e, --engram <id>", "Engram ID to check")
|
|
62
|
+
.option("-p, --path <dir>", "Override engrams directory path")
|
|
63
|
+
.action(async (opts) => {
|
|
64
|
+
await ensureInitialized();
|
|
65
|
+
const engramId = opts.engram.trim();
|
|
66
|
+
// API key 必須チェック
|
|
67
|
+
const apiKey = await resolveMikoshiApiKey();
|
|
68
|
+
if (!apiKey) {
|
|
69
|
+
printError("Error: Mikoshi API key is not configured.");
|
|
70
|
+
printErrorDetail("Set one with: relic config mikoshi-api-key <key>");
|
|
71
|
+
process.exit(1);
|
|
72
|
+
}
|
|
73
|
+
const engramsPath = await resolveEngramsPath(opts.path);
|
|
74
|
+
const mikoshiUrl = await resolveMikoshiUrl();
|
|
75
|
+
const repo = new LocalEngramRepository(engramsPath);
|
|
76
|
+
const client = new MikoshiApiClient(mikoshiUrl, apiKey);
|
|
77
|
+
const usecase = new MikoshiStatus(repo, client);
|
|
78
|
+
try {
|
|
79
|
+
const spinner = startSpinner("Checking status…");
|
|
80
|
+
const result = await usecase.execute(engramId);
|
|
81
|
+
spinner.stop();
|
|
82
|
+
printBlank();
|
|
83
|
+
printLine(`Engram: ${result.engramName} (${result.engramId})`);
|
|
84
|
+
printLine(`Cloud: ${result.cloudEngramId}`);
|
|
85
|
+
printBlank();
|
|
86
|
+
// Persona
|
|
87
|
+
const personaIcon = statusIcon(result.persona.status);
|
|
88
|
+
const personaLabel = statusLabel(result.persona.status);
|
|
89
|
+
printLine(`Persona: ${personaIcon} ${personaLabel}`);
|
|
90
|
+
if (result.persona.status === "local_differs") {
|
|
91
|
+
printDetail(`local: ${result.persona.localHash}`);
|
|
92
|
+
printDetail(`remote: ${result.persona.remoteHash}`);
|
|
93
|
+
}
|
|
94
|
+
// Memory
|
|
95
|
+
const memoryIcon = statusIcon(result.memory.status);
|
|
96
|
+
const memoryLabel = statusLabel(result.memory.status);
|
|
97
|
+
printLine(`Memory: ${memoryIcon} ${memoryLabel}`);
|
|
98
|
+
if (result.memory.status === "local_differs") {
|
|
99
|
+
printDetail(`local: ${result.memory.localHash}`);
|
|
100
|
+
printDetail(`remote: ${result.memory.remoteHash}`);
|
|
101
|
+
}
|
|
102
|
+
if (result.memory.remoteExists && result.memory.remoteSummary) {
|
|
103
|
+
const s = result.memory.remoteSummary;
|
|
104
|
+
printDetail(`entries: ${s.memoryEntryCount}, latest: ${s.latestMemoryDate ?? "—"}`);
|
|
105
|
+
}
|
|
106
|
+
printBlank();
|
|
107
|
+
}
|
|
108
|
+
catch (err) {
|
|
109
|
+
if (err instanceof MikoshiStatusEngramNotFoundError) {
|
|
110
|
+
printError(`Error: ${err.message}`);
|
|
111
|
+
process.exit(1);
|
|
112
|
+
}
|
|
113
|
+
if (err instanceof MikoshiStatusCloudNotFoundError) {
|
|
114
|
+
printBlank();
|
|
115
|
+
printLine(`Engram "${engramId}" is not registered on Mikoshi.`);
|
|
116
|
+
printDetail("Upload it with: relic mikoshi push");
|
|
117
|
+
printBlank();
|
|
118
|
+
process.exit(0);
|
|
119
|
+
}
|
|
120
|
+
if (err instanceof MikoshiApiError) {
|
|
121
|
+
handleMikoshiApiError(err);
|
|
122
|
+
}
|
|
123
|
+
throw err;
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
// relic mikoshi push -e <id>
|
|
127
|
+
mikoshi
|
|
128
|
+
.command("push")
|
|
129
|
+
.description("Push local Engram persona to Mikoshi cloud")
|
|
130
|
+
.requiredOption("-e, --engram <id>", "Engram ID to push")
|
|
131
|
+
.option("-y, --yes", "Skip create/overwrite confirmation")
|
|
132
|
+
.option("--no-sync", "Skip automatic memory sync after push")
|
|
133
|
+
.option("-p, --path <dir>", "Override engrams directory path")
|
|
134
|
+
.action(async (opts) => {
|
|
135
|
+
await ensureInitialized();
|
|
136
|
+
const engramId = opts.engram.trim();
|
|
137
|
+
const apiKey = await resolveMikoshiApiKey();
|
|
138
|
+
if (!apiKey) {
|
|
139
|
+
printError("Error: Mikoshi API key is not configured.");
|
|
140
|
+
printErrorDetail("Set one with: relic config mikoshi-api-key <key>");
|
|
141
|
+
process.exit(1);
|
|
142
|
+
}
|
|
143
|
+
const engramsPath = await resolveEngramsPath(opts.path);
|
|
144
|
+
const mikoshiUrl = await resolveMikoshiUrl();
|
|
145
|
+
const repo = new LocalEngramRepository(engramsPath);
|
|
146
|
+
const client = new MikoshiApiClient(mikoshiUrl, apiKey);
|
|
147
|
+
const usecase = new MikoshiPush(repo, client);
|
|
148
|
+
let checkSpinner;
|
|
149
|
+
let applySpinner;
|
|
150
|
+
try {
|
|
151
|
+
checkSpinner = startSpinner("Checking remote persona...");
|
|
152
|
+
const { result, apply } = await usecase.check(engramId);
|
|
153
|
+
checkSpinner.stop();
|
|
154
|
+
checkSpinner = undefined;
|
|
155
|
+
if (result.outcome === "already_synced") {
|
|
156
|
+
printLine(`✅ Persona already in sync (${result.engramName})`);
|
|
157
|
+
}
|
|
158
|
+
else {
|
|
159
|
+
if (result.outcome === "create_required") {
|
|
160
|
+
if (!opts.yes &&
|
|
161
|
+
!(await confirm(`Engram "${engramId}" does not exist on Mikoshi. Create it? [y/N] `))) {
|
|
162
|
+
printLine("Skipped.");
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
else if (!opts.yes &&
|
|
167
|
+
!(await confirm(`Overwrite Mikoshi persona with the local Relic version for "${engramId}"? [y/N] `))) {
|
|
168
|
+
printLine("Skipped.");
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
applySpinner = startSpinner("Pushing persona to Mikoshi...");
|
|
172
|
+
const applied = await apply();
|
|
173
|
+
if (applied.action === "created") {
|
|
174
|
+
applySpinner.stop(`✅ Created "${result.engramName}" on Mikoshi.`);
|
|
175
|
+
applySpinner = undefined;
|
|
176
|
+
printLine(`Cloud ID: ${applied.cloudEngramId}`);
|
|
177
|
+
}
|
|
178
|
+
else {
|
|
179
|
+
applySpinner.stop(`✅ Persona updated for "${result.engramName}".`);
|
|
180
|
+
applySpinner = undefined;
|
|
181
|
+
printLine(`Hash: ${applied.newPersonaHash}`);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
catch (err) {
|
|
186
|
+
checkSpinner?.stop();
|
|
187
|
+
applySpinner?.stop();
|
|
188
|
+
if (err instanceof MikoshiPushEngramNotFoundError) {
|
|
189
|
+
printError(`Error: ${err.message}`);
|
|
190
|
+
process.exit(1);
|
|
191
|
+
}
|
|
192
|
+
if (err instanceof MikoshiPushPersonaHashError) {
|
|
193
|
+
printError(`Error: ${err.message}`);
|
|
194
|
+
process.exit(1);
|
|
195
|
+
}
|
|
196
|
+
if (err instanceof MikoshiPushPersonaConflictError) {
|
|
197
|
+
printErrorLine(`⚠ Persona conflict for "${engramId}".`);
|
|
198
|
+
printErrorLine("The remote persona was updated since you last checked.");
|
|
199
|
+
if (err.conflictRemoteHash) {
|
|
200
|
+
printErrorDetail(`Remote hash: ${err.conflictRemoteHash}`);
|
|
201
|
+
}
|
|
202
|
+
printErrorLine("Re-run 'relic mikoshi status' to review the current state.");
|
|
203
|
+
process.exit(1);
|
|
204
|
+
}
|
|
205
|
+
if (err instanceof MikoshiApiError) {
|
|
206
|
+
handleMikoshiApiError(err);
|
|
207
|
+
}
|
|
208
|
+
throw err;
|
|
209
|
+
}
|
|
210
|
+
if (!opts.sync)
|
|
211
|
+
return;
|
|
212
|
+
const passphrase = await resolvePassphraseForSync();
|
|
213
|
+
const syncUsecase = new MikoshiMemorySync(repo, client);
|
|
214
|
+
await runSingleMikoshiSync(syncUsecase, engramId, passphrase);
|
|
215
|
+
});
|
|
216
|
+
// relic mikoshi pull -e <id>
|
|
217
|
+
mikoshi
|
|
218
|
+
.command("pull")
|
|
219
|
+
.description("Pull persona from Mikoshi into local Relic")
|
|
220
|
+
.requiredOption("-e, --engram <id>", "Engram ID to pull")
|
|
221
|
+
.option("-y, --yes", "Skip create/overwrite confirmation")
|
|
222
|
+
.option("--no-sync", "Skip automatic memory sync after pull")
|
|
223
|
+
.option("-p, --path <dir>", "Override engrams directory path")
|
|
224
|
+
.action(async (opts) => {
|
|
225
|
+
await ensureInitialized();
|
|
226
|
+
const engramId = opts.engram.trim();
|
|
227
|
+
const apiKey = await resolveMikoshiApiKey();
|
|
228
|
+
if (!apiKey) {
|
|
229
|
+
printError("Error: Mikoshi API key is not configured.");
|
|
230
|
+
printErrorDetail("Set one with: relic config mikoshi-api-key <key>");
|
|
231
|
+
process.exit(1);
|
|
232
|
+
}
|
|
233
|
+
const engramsPath = await resolveEngramsPath(opts.path);
|
|
234
|
+
const mikoshiUrl = await resolveMikoshiUrl();
|
|
235
|
+
const repo = new LocalEngramRepository(engramsPath);
|
|
236
|
+
const client = new MikoshiApiClient(mikoshiUrl, apiKey);
|
|
237
|
+
const download = new MikoshiDownload(repo, client);
|
|
238
|
+
const usecase = new MikoshiPull(repo, client);
|
|
239
|
+
let checkSpinner;
|
|
240
|
+
let applySpinner;
|
|
241
|
+
try {
|
|
242
|
+
const local = await repo.get(engramId);
|
|
243
|
+
if (!local) {
|
|
244
|
+
if (!opts.yes &&
|
|
245
|
+
!(await confirm(`Local Engram "${engramId}" does not exist. Create it from Mikoshi? [y/N] `))) {
|
|
246
|
+
printLine("Skipped.");
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
applySpinner = startSpinner("Pulling persona from Mikoshi...");
|
|
250
|
+
const result = await download.execute(engramId);
|
|
251
|
+
applySpinner.stop(`✅ Pulled "${result.engramName}" from Mikoshi.`);
|
|
252
|
+
applySpinner = undefined;
|
|
253
|
+
if (opts.sync) {
|
|
254
|
+
const passphrase = await resolvePassphraseForSync();
|
|
255
|
+
const syncUsecase = new MikoshiMemorySync(repo, client);
|
|
256
|
+
await runSingleMikoshiSync(syncUsecase, engramId, passphrase);
|
|
257
|
+
}
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
checkSpinner = startSpinner("Checking remote persona...");
|
|
261
|
+
const { result, apply } = await usecase.check(engramId);
|
|
262
|
+
checkSpinner.stop();
|
|
263
|
+
checkSpinner = undefined;
|
|
264
|
+
if (result.outcome === "already_synced") {
|
|
265
|
+
printLine(`✅ Persona already in sync (${result.engramName})`);
|
|
266
|
+
if (opts.sync) {
|
|
267
|
+
const passphrase = await resolvePassphraseForSync();
|
|
268
|
+
const syncUsecase = new MikoshiMemorySync(repo, client);
|
|
269
|
+
await runSingleMikoshiSync(syncUsecase, engramId, passphrase);
|
|
270
|
+
}
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
const diff = result.diff;
|
|
274
|
+
printLine(`Engram: ${result.engramName} (${result.engramId})`);
|
|
275
|
+
printLine(`Cloud: ${result.cloudEngramId}`);
|
|
276
|
+
printBlank();
|
|
277
|
+
if (diff.soulDiffers)
|
|
278
|
+
printDetail("SOUL.md — differs");
|
|
279
|
+
if (diff.identityDiffers)
|
|
280
|
+
printDetail("IDENTITY.md — differs");
|
|
281
|
+
printBlank();
|
|
282
|
+
// 確認プロンプト
|
|
283
|
+
if (!opts.yes) {
|
|
284
|
+
const confirmed = await confirm("Overwrite local persona files? [y/N] ");
|
|
285
|
+
if (!confirmed) {
|
|
286
|
+
printLine("Skipped.");
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
applySpinner = startSpinner("Pulling persona from Mikoshi...");
|
|
291
|
+
await apply();
|
|
292
|
+
applySpinner.stop(`✅ Local persona updated from Mikoshi.`);
|
|
293
|
+
applySpinner = undefined;
|
|
294
|
+
if (opts.sync) {
|
|
295
|
+
const passphrase = await resolvePassphraseForSync();
|
|
296
|
+
const syncUsecase = new MikoshiMemorySync(repo, client);
|
|
297
|
+
await runSingleMikoshiSync(syncUsecase, engramId, passphrase);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
catch (err) {
|
|
301
|
+
checkSpinner?.stop();
|
|
302
|
+
applySpinner?.stop();
|
|
303
|
+
if (err instanceof MikoshiPullEngramNotFoundError) {
|
|
304
|
+
printError(`Error: ${err.message}`);
|
|
305
|
+
process.exit(1);
|
|
306
|
+
}
|
|
307
|
+
if (err instanceof MikoshiDownloadAlreadyExistsError) {
|
|
308
|
+
printError(`Error: ${err.message}`);
|
|
309
|
+
process.exit(1);
|
|
310
|
+
}
|
|
311
|
+
if (err instanceof MikoshiDownloadCloudNotFoundError) {
|
|
312
|
+
printError(`Error: Engram "${engramId}" not found on Mikoshi.`);
|
|
313
|
+
process.exit(1);
|
|
314
|
+
}
|
|
315
|
+
if (err instanceof MikoshiDownloadPersonaMissingError) {
|
|
316
|
+
printError(`Error: ${err.message}`);
|
|
317
|
+
process.exit(1);
|
|
318
|
+
}
|
|
319
|
+
if (err instanceof MikoshiPullCloudNotFoundError) {
|
|
320
|
+
printError(`Error: Engram "${engramId}" not found on Mikoshi.`);
|
|
321
|
+
process.exit(1);
|
|
322
|
+
}
|
|
323
|
+
if (err instanceof MikoshiPullPersonaMissingError) {
|
|
324
|
+
printError(`Error: ${err.message}`);
|
|
325
|
+
process.exit(1);
|
|
326
|
+
}
|
|
327
|
+
if (err instanceof MikoshiApiError) {
|
|
328
|
+
handleMikoshiApiError(err);
|
|
329
|
+
}
|
|
330
|
+
throw err;
|
|
331
|
+
}
|
|
332
|
+
});
|
|
333
|
+
mikoshi
|
|
334
|
+
.command("sync")
|
|
335
|
+
.description("Bidirectional memory sync between local Engrams and Mikoshi")
|
|
336
|
+
.option("-e, --engram <id>", "Engram ID to sync")
|
|
337
|
+
.option("--all", "Sync all matching targets")
|
|
338
|
+
.option("-p, --path <dir>", "Override engrams directory path")
|
|
339
|
+
.action(async (opts) => {
|
|
340
|
+
await ensureInitialized();
|
|
341
|
+
if (opts.engram && opts.all) {
|
|
342
|
+
printError("Error: --engram and --all cannot be used together.");
|
|
343
|
+
process.exit(1);
|
|
344
|
+
}
|
|
345
|
+
if (!opts.engram && !opts.all) {
|
|
346
|
+
printError("Error: Specify --engram <id> or --all.");
|
|
347
|
+
process.exit(1);
|
|
348
|
+
}
|
|
349
|
+
const apiKey = await resolveMikoshiApiKey();
|
|
350
|
+
if (!apiKey) {
|
|
351
|
+
printError("Error: Mikoshi API key is not configured.");
|
|
352
|
+
printErrorDetail("Set one with: relic config mikoshi-api-key <key>");
|
|
353
|
+
process.exit(1);
|
|
354
|
+
}
|
|
355
|
+
const passphrase = await resolvePassphraseForSync();
|
|
356
|
+
const engramsPath = await resolveEngramsPath(opts.path);
|
|
357
|
+
const mikoshiUrl = await resolveMikoshiUrl();
|
|
358
|
+
const repo = new LocalEngramRepository(engramsPath);
|
|
359
|
+
const client = new MikoshiApiClient(mikoshiUrl, apiKey);
|
|
360
|
+
const usecase = new MikoshiMemorySync(repo, client);
|
|
361
|
+
try {
|
|
362
|
+
if (opts.engram) {
|
|
363
|
+
const engramId = opts.engram.trim();
|
|
364
|
+
if (!engramId) {
|
|
365
|
+
printError(`Error: Invalid Engram ID "${opts.engram}".`);
|
|
366
|
+
process.exit(1);
|
|
367
|
+
}
|
|
368
|
+
await runSingleMikoshiSync(usecase, engramId, passphrase);
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
371
|
+
const localEngrams = await repo.list();
|
|
372
|
+
if (localEngrams.length === 0) {
|
|
373
|
+
console.log("No local Engrams found.");
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
const cloudEngrams = await client.getEngrams();
|
|
377
|
+
const cloudIds = new Set(cloudEngrams.map((engram) => engram.sourceEngramId));
|
|
378
|
+
const syncableIds = localEngrams
|
|
379
|
+
.map((engram) => engram.id)
|
|
380
|
+
.filter((id) => cloudIds.has(id));
|
|
381
|
+
const skippedIds = localEngrams
|
|
382
|
+
.map((engram) => engram.id)
|
|
383
|
+
.filter((id) => !cloudIds.has(id));
|
|
384
|
+
if (syncableIds.length === 0) {
|
|
385
|
+
console.log("No matching Mikoshi targets found.");
|
|
386
|
+
if (skippedIds.length > 0) {
|
|
387
|
+
printLine(`Skipped (not on Mikoshi): ${skippedIds.join(", ")}`);
|
|
388
|
+
}
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
for (const engramId of syncableIds) {
|
|
392
|
+
await runSingleMikoshiSync(usecase, engramId, passphrase);
|
|
393
|
+
}
|
|
394
|
+
if (skippedIds.length > 0) {
|
|
395
|
+
printLine(`Skipped (not on Mikoshi): ${skippedIds.join(", ")}`);
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
catch (err) {
|
|
399
|
+
if (err instanceof MikoshiMemorySyncEngramNotFoundError) {
|
|
400
|
+
printError(`Error: ${err.message}`);
|
|
401
|
+
process.exit(1);
|
|
402
|
+
}
|
|
403
|
+
if (err instanceof MikoshiMemorySyncCloudNotFoundError) {
|
|
404
|
+
printError(`Error: ${err.message}`);
|
|
405
|
+
process.exit(1);
|
|
406
|
+
}
|
|
407
|
+
if (err instanceof MikoshiMemorySyncDecryptError) {
|
|
408
|
+
printError("Error: Failed to decrypt memory. Wrong passphrase or corrupted data.");
|
|
409
|
+
process.exit(1);
|
|
410
|
+
}
|
|
411
|
+
if (err instanceof MikoshiApiError) {
|
|
412
|
+
handleMikoshiApiError(err);
|
|
413
|
+
}
|
|
414
|
+
throw err;
|
|
415
|
+
}
|
|
416
|
+
});
|
|
417
|
+
}
|
|
418
|
+
// ---------------------------------------------------------------------------
|
|
419
|
+
// Shared error handler
|
|
420
|
+
// ---------------------------------------------------------------------------
|
|
421
|
+
function handleMikoshiApiError(err) {
|
|
422
|
+
if (err.isUnauthorized) {
|
|
423
|
+
printError("Error: Mikoshi API key is invalid or expired.");
|
|
424
|
+
printErrorDetail("Update with: relic config mikoshi-api-key <key>");
|
|
425
|
+
}
|
|
426
|
+
else if (err.isRateLimited) {
|
|
427
|
+
printError("Error: Mikoshi rate limit exceeded. Try again later.");
|
|
428
|
+
}
|
|
429
|
+
else {
|
|
430
|
+
printError(`Error: Mikoshi API returned ${err.status}: ${err.message}`);
|
|
431
|
+
}
|
|
432
|
+
process.exit(1);
|
|
433
|
+
}
|
|
434
|
+
function printError(msg) {
|
|
435
|
+
console.error(red(msg));
|
|
436
|
+
}
|
|
437
|
+
// ---------------------------------------------------------------------------
|
|
438
|
+
// Display helpers
|
|
439
|
+
// ---------------------------------------------------------------------------
|
|
440
|
+
function statusIcon(status) {
|
|
441
|
+
switch (status) {
|
|
442
|
+
case "synced": return "✅";
|
|
443
|
+
case "local_differs": return "⚠️";
|
|
444
|
+
case "remote_only": return "⚠️";
|
|
445
|
+
case "not_uploaded": return "—";
|
|
446
|
+
case "local_empty": return "—";
|
|
447
|
+
default: return "?";
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
function statusLabel(status) {
|
|
451
|
+
switch (status) {
|
|
452
|
+
case "synced": return "synced";
|
|
453
|
+
case "local_differs": return "local differs";
|
|
454
|
+
case "remote_only": return "remote only (no local persona hash)";
|
|
455
|
+
case "not_uploaded": return "not uploaded";
|
|
456
|
+
case "local_empty": return "local empty";
|
|
457
|
+
default: return status;
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
function confirm(prompt) {
|
|
461
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
462
|
+
return new Promise((resolve) => {
|
|
463
|
+
rl.question(prompt, (answer) => {
|
|
464
|
+
rl.close();
|
|
465
|
+
resolve(answer.trim().toLowerCase() === "y");
|
|
466
|
+
});
|
|
467
|
+
});
|
|
468
|
+
}
|
|
469
|
+
// ---------------------------------------------------------------------------
|
|
470
|
+
// ANSI colors
|
|
471
|
+
// ---------------------------------------------------------------------------
|
|
472
|
+
const red = (s) => `\x1b[31m${s}\x1b[0m`;
|
|
473
|
+
const dim = (s) => `\x1b[2m${s}\x1b[0m`;
|
|
474
|
+
// ---------------------------------------------------------------------------
|
|
475
|
+
// Passphrase helpers
|
|
476
|
+
// ---------------------------------------------------------------------------
|
|
477
|
+
async function resolvePassphraseForSync() {
|
|
478
|
+
const saved = await resolveMikoshiPassphrase();
|
|
479
|
+
if (saved) {
|
|
480
|
+
console.log(dim("Using saved passphrase from config."));
|
|
481
|
+
return saved;
|
|
482
|
+
}
|
|
483
|
+
let passphrase;
|
|
484
|
+
try {
|
|
485
|
+
passphrase = await readPassphrase("Passphrase for memory sync: ");
|
|
486
|
+
}
|
|
487
|
+
catch {
|
|
488
|
+
console.error("Cancelled.");
|
|
489
|
+
process.exit(1);
|
|
490
|
+
}
|
|
491
|
+
if (!passphrase) {
|
|
492
|
+
printError("Error: Passphrase cannot be empty.");
|
|
493
|
+
process.exit(1);
|
|
494
|
+
}
|
|
495
|
+
let confirm2;
|
|
496
|
+
try {
|
|
497
|
+
confirm2 = await readPassphrase("Confirm passphrase: ");
|
|
498
|
+
}
|
|
499
|
+
catch {
|
|
500
|
+
console.error("Cancelled.");
|
|
501
|
+
process.exit(1);
|
|
502
|
+
}
|
|
503
|
+
if (passphrase !== confirm2) {
|
|
504
|
+
printError("Error: Passphrases do not match.");
|
|
505
|
+
process.exit(1);
|
|
506
|
+
}
|
|
507
|
+
return passphrase;
|
|
508
|
+
}
|
|
509
|
+
async function runSingleMikoshiSync(usecase, engramId, passphrase, options) {
|
|
510
|
+
const prefix = options?.prefix ?? "";
|
|
511
|
+
const spinner = startSpinner(`Syncing memory (${engramId})...`);
|
|
512
|
+
let stopped = false;
|
|
513
|
+
try {
|
|
514
|
+
const result = await usecase.execute(engramId, passphrase);
|
|
515
|
+
switch (result.outcome) {
|
|
516
|
+
case "already_synced":
|
|
517
|
+
spinner.stop(`${prefix}✅ Memory already in sync (${engramId})`);
|
|
518
|
+
stopped = true;
|
|
519
|
+
return;
|
|
520
|
+
case "synced": {
|
|
521
|
+
const details = summarizeMergedMemory(result.mergedPaths ?? []);
|
|
522
|
+
if (details.length > 0) {
|
|
523
|
+
spinner.stop(`${prefix}✅ Memory synced: ${details.join(", ")} (${engramId})`);
|
|
524
|
+
}
|
|
525
|
+
else {
|
|
526
|
+
spinner.stop(`${prefix}✅ Memory synced (${engramId})`);
|
|
527
|
+
}
|
|
528
|
+
stopped = true;
|
|
529
|
+
return;
|
|
530
|
+
}
|
|
531
|
+
case "conflict":
|
|
532
|
+
spinner.stop();
|
|
533
|
+
stopped = true;
|
|
534
|
+
printErrorLine(`⚠ Memory sync conflict for "${result.engramName}".`);
|
|
535
|
+
printErrorLine("The remote memory changed during sync.");
|
|
536
|
+
if (result.conflictRemoteHash) {
|
|
537
|
+
printErrorDetail(`Remote hash: ${result.conflictRemoteHash}`);
|
|
538
|
+
}
|
|
539
|
+
if (result.conflictRemoteVersion) {
|
|
540
|
+
printErrorDetail(`Remote version: ${result.conflictRemoteVersion}`);
|
|
541
|
+
}
|
|
542
|
+
printErrorLine("Re-run 'relic mikoshi sync' to merge again.");
|
|
543
|
+
process.exit(1);
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
finally {
|
|
547
|
+
if (!stopped) {
|
|
548
|
+
spinner.stop();
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
function summarizeMergedMemory(paths) {
|
|
553
|
+
const details = [];
|
|
554
|
+
const memoryEntries = paths.filter((path) => path.startsWith("memory/") && path.endsWith(".md"));
|
|
555
|
+
if (memoryEntries.length > 0) {
|
|
556
|
+
details.push(`${memoryEntries.length} memory file(s)`);
|
|
557
|
+
}
|
|
558
|
+
if (paths.includes("MEMORY.md")) {
|
|
559
|
+
details.push("MEMORY.md");
|
|
560
|
+
}
|
|
561
|
+
if (paths.includes("USER.md")) {
|
|
562
|
+
details.push("USER.md");
|
|
563
|
+
}
|
|
564
|
+
return details;
|
|
565
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { LocalEngramRepository } from "../../../adapters/local/index.js";
|
|
2
2
|
import { RefreshSamples } from "../../../core/usecases/index.js";
|
|
3
|
+
import { printDetail, printLine } from "../output.js";
|
|
3
4
|
import { resolveEngramsPath, TEMPLATES_DIR } from "../../../shared/config.js";
|
|
4
5
|
export function registerRefreshSamplesCommand(program) {
|
|
5
6
|
program
|
|
@@ -13,27 +14,27 @@ export function registerRefreshSamplesCommand(program) {
|
|
|
13
14
|
const refreshSamples = new RefreshSamples(repo, TEMPLATES_DIR);
|
|
14
15
|
const result = await refreshSamples.execute(opts.engram ? [opts.engram] : undefined);
|
|
15
16
|
if (result.refreshed.length > 0) {
|
|
16
|
-
|
|
17
|
-
|
|
17
|
+
printLine(`Refreshed: ${result.refreshed.length}`);
|
|
18
|
+
printDetail(`IDs: ${result.refreshed.join(", ")}`);
|
|
18
19
|
}
|
|
19
20
|
if (result.seeded.length > 0) {
|
|
20
|
-
|
|
21
|
-
|
|
21
|
+
printLine(`Seeded: ${result.seeded.length}`);
|
|
22
|
+
printDetail(`IDs: ${result.seeded.join(", ")}`);
|
|
22
23
|
}
|
|
23
24
|
if (result.migratedMemory.length > 0) {
|
|
24
|
-
|
|
25
|
+
printLine("Memory migrated:");
|
|
25
26
|
for (const m of result.migratedMemory) {
|
|
26
|
-
|
|
27
|
+
printDetail(`${m.from} → ${m.to}`);
|
|
27
28
|
}
|
|
28
29
|
}
|
|
29
30
|
if (result.skipped.length > 0) {
|
|
30
|
-
|
|
31
|
+
printLine(`Skipped: ${result.skipped.length}`);
|
|
31
32
|
for (const skipped of result.skipped) {
|
|
32
|
-
|
|
33
|
+
printDetail(`${skipped.id}: ${skipped.reason}`);
|
|
33
34
|
}
|
|
34
35
|
}
|
|
35
36
|
if (result.refreshed.length === 0 && result.seeded.length === 0 && result.skipped.length === 0) {
|
|
36
|
-
|
|
37
|
+
printLine("No sample templates found.");
|
|
37
38
|
}
|
|
38
39
|
});
|
|
39
40
|
}
|
|
@@ -13,6 +13,7 @@ import { registerMigrateCommand } from "./commands/migrate.js";
|
|
|
13
13
|
import { registerRefreshSamplesCommand } from "./commands/refresh-samples.js";
|
|
14
14
|
import { registerCreateCommand } from "./commands/create.js";
|
|
15
15
|
import { registerDeleteCommand } from "./commands/delete.js";
|
|
16
|
+
import { registerMikoshiCommand } from "./commands/mikoshi.js";
|
|
16
17
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
17
18
|
const pkg = JSON.parse(readFileSync(resolve(__dirname, "../../../package.json"), "utf-8"));
|
|
18
19
|
const program = new Command();
|
|
@@ -30,4 +31,5 @@ registerClawCommand(program);
|
|
|
30
31
|
registerConfigCommand(program);
|
|
31
32
|
registerMigrateCommand(program);
|
|
32
33
|
registerRefreshSamplesCommand(program);
|
|
34
|
+
registerMikoshiCommand(program);
|
|
33
35
|
program.parse();
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export declare function printLine(message?: string): void;
|
|
2
|
+
export declare function printBlank(): void;
|
|
3
|
+
export declare function printDetail(message: string): void;
|
|
4
|
+
export declare function printErrorLine(message: string): void;
|
|
5
|
+
export declare function printErrorDetail(message: string): void;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
const DETAIL_PREFIX = " ";
|
|
2
|
+
export function printLine(message = "") {
|
|
3
|
+
console.log(message);
|
|
4
|
+
}
|
|
5
|
+
export function printBlank() {
|
|
6
|
+
printLine();
|
|
7
|
+
}
|
|
8
|
+
export function printDetail(message) {
|
|
9
|
+
console.log(`${DETAIL_PREFIX}${message}`);
|
|
10
|
+
}
|
|
11
|
+
export function printErrorLine(message) {
|
|
12
|
+
console.error(message);
|
|
13
|
+
}
|
|
14
|
+
export function printErrorDetail(message) {
|
|
15
|
+
console.error(`${DETAIL_PREFIX}${message}`);
|
|
16
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export interface Spinner {
|
|
2
|
+
/** Update the message while spinning. */
|
|
3
|
+
update(message: string): void;
|
|
4
|
+
/** Stop with a final message (replaces the spinner line). */
|
|
5
|
+
stop(finalMessage?: string): void;
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Start a CLI spinner that overwrites the current line.
|
|
9
|
+
*
|
|
10
|
+
* Falls back to a single static line when stdout is not a TTY
|
|
11
|
+
* (e.g. CI, piped output).
|
|
12
|
+
*/
|
|
13
|
+
export declare function startSpinner(message: string): Spinner;
|