@ectplsm/relic 0.5.0 → 0.5.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/README.md +11 -0
- package/dist/adapters/local/local-engram-repository.d.ts +3 -1
- package/dist/adapters/local/local-engram-repository.js +25 -0
- package/dist/adapters/mikoshi/client.d.ts +3 -1
- package/dist/adapters/mikoshi/client.js +33 -3
- package/dist/adapters/shells/codex-shell.js +1 -1
- package/dist/adapters/shells/gemini-shell.js +38 -38
- package/dist/core/entities/engram.d.ts +28 -0
- package/dist/core/entities/engram.js +4 -0
- package/dist/core/ports/engram-repository.d.ts +17 -1
- package/dist/core/ports/mikoshi.d.ts +24 -0
- package/dist/core/ports/mikoshi.js +17 -0
- package/dist/core/sync/avatar.d.ts +121 -0
- package/dist/core/sync/avatar.js +352 -0
- package/dist/core/usecases/mikoshi-download.d.ts +5 -0
- package/dist/core/usecases/mikoshi-download.js +17 -1
- package/dist/core/usecases/mikoshi-pull.d.ts +26 -0
- package/dist/core/usecases/mikoshi-pull.js +47 -3
- package/dist/core/usecases/mikoshi-push.d.ts +129 -2
- package/dist/core/usecases/mikoshi-push.js +316 -11
- package/dist/interfaces/cli/commands/mikoshi.js +150 -13
- package/package.json +1 -1
- package/templates/engrams/commander/IDENTITY.md +4 -0
- package/templates/engrams/commander/SOUL.md +14 -1
- package/templates/engrams/rebel/IDENTITY.md +3 -1
- package/templates/engrams/rebel/SOUL.md +12 -0
|
@@ -1,5 +1,8 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { readFile, stat } from "node:fs/promises";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { MikoshiApiError, AVATAR_MAX_BYTES } from "../ports/mikoshi.js";
|
|
2
4
|
import { computePersonaHash } from "../sync/persona-hash.js";
|
|
5
|
+
import { computeAvatarHash, computeAvatarHashFromBytes, detectAvatarMimeType, isAvatarOnlyDrift, fetchAvatarFromUrl, parseAvatarRef, resolveAvatarPath, } from "../sync/avatar.js";
|
|
3
6
|
// ---------------------------------------------------------------------------
|
|
4
7
|
// Errors
|
|
5
8
|
// ---------------------------------------------------------------------------
|
|
@@ -29,6 +32,74 @@ export class MikoshiPushPersonaConflictError extends Error {
|
|
|
29
32
|
this.name = "MikoshiPushPersonaConflictError";
|
|
30
33
|
}
|
|
31
34
|
}
|
|
35
|
+
export class MikoshiPushAvatarReadError extends Error {
|
|
36
|
+
engramId;
|
|
37
|
+
avatarPath;
|
|
38
|
+
cause;
|
|
39
|
+
constructor(engramId, avatarPath, cause) {
|
|
40
|
+
super(`Failed to read avatar file for "${engramId}": ${avatarPath}`);
|
|
41
|
+
this.engramId = engramId;
|
|
42
|
+
this.avatarPath = avatarPath;
|
|
43
|
+
this.cause = cause;
|
|
44
|
+
this.name = "MikoshiPushAvatarReadError";
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
export class MikoshiPushAvatarTooLargeError extends Error {
|
|
48
|
+
engramId;
|
|
49
|
+
avatarPath;
|
|
50
|
+
actualBytes;
|
|
51
|
+
maxBytes;
|
|
52
|
+
constructor(engramId, avatarPath, actualBytes, maxBytes) {
|
|
53
|
+
super(`Avatar for "${engramId}" is ${actualBytes} bytes, exceeds limit of ${maxBytes} bytes`);
|
|
54
|
+
this.engramId = engramId;
|
|
55
|
+
this.avatarPath = avatarPath;
|
|
56
|
+
this.actualBytes = actualBytes;
|
|
57
|
+
this.maxBytes = maxBytes;
|
|
58
|
+
this.name = "MikoshiPushAvatarTooLargeError";
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
export class MikoshiPushAvatarInvalidMimeError extends Error {
|
|
62
|
+
engramId;
|
|
63
|
+
avatarPath;
|
|
64
|
+
constructor(engramId, avatarPath) {
|
|
65
|
+
super(`Avatar for "${engramId}" has unsupported format: ${avatarPath} (only JPEG, PNG, and WebP are allowed)`);
|
|
66
|
+
this.engramId = engramId;
|
|
67
|
+
this.avatarPath = avatarPath;
|
|
68
|
+
this.name = "MikoshiPushAvatarInvalidMimeError";
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
export class MikoshiPushAvatarHttpError extends Error {
|
|
72
|
+
engramId;
|
|
73
|
+
avatarUrl;
|
|
74
|
+
constructor(engramId, avatarUrl) {
|
|
75
|
+
super(`Avatar URL for "${engramId}" must use HTTPS: ${avatarUrl}`);
|
|
76
|
+
this.engramId = engramId;
|
|
77
|
+
this.avatarUrl = avatarUrl;
|
|
78
|
+
this.name = "MikoshiPushAvatarHttpError";
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
export class MikoshiPushAvatarPrivateHostError extends Error {
|
|
82
|
+
engramId;
|
|
83
|
+
avatarUrl;
|
|
84
|
+
constructor(engramId, avatarUrl) {
|
|
85
|
+
super(`Avatar URL for "${engramId}" points to a private host: ${avatarUrl}`);
|
|
86
|
+
this.engramId = engramId;
|
|
87
|
+
this.avatarUrl = avatarUrl;
|
|
88
|
+
this.name = "MikoshiPushAvatarPrivateHostError";
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
export class MikoshiPushAvatarFetchError extends Error {
|
|
92
|
+
engramId;
|
|
93
|
+
avatarUrl;
|
|
94
|
+
cause;
|
|
95
|
+
constructor(engramId, avatarUrl, cause) {
|
|
96
|
+
super(`Failed to fetch avatar URL for "${engramId}": ${avatarUrl}`);
|
|
97
|
+
this.engramId = engramId;
|
|
98
|
+
this.avatarUrl = avatarUrl;
|
|
99
|
+
this.cause = cause;
|
|
100
|
+
this.name = "MikoshiPushAvatarFetchError";
|
|
101
|
+
}
|
|
102
|
+
}
|
|
32
103
|
// ---------------------------------------------------------------------------
|
|
33
104
|
// Usecase
|
|
34
105
|
// ---------------------------------------------------------------------------
|
|
@@ -47,7 +118,16 @@ export class MikoshiPush {
|
|
|
47
118
|
const localHash = computePersonaHash(soul, identity);
|
|
48
119
|
if (!localHash)
|
|
49
120
|
throw new MikoshiPushPersonaHashError(engramId);
|
|
121
|
+
const engramDir = this.localRepo.getEngramPath(engramId);
|
|
50
122
|
const cloudEngram = await this.mikoshi.getEngramBySourceId(engramId);
|
|
123
|
+
const remoteAvatarUrl = cloudEngram?.avatarUrl ?? null;
|
|
124
|
+
// 差分検出は localHash だけでなくリモートの avatarUrl 有無も見る。
|
|
125
|
+
// - リモートに avatar がある → manifest ハッシュと比較 (従来挙動)
|
|
126
|
+
// - リモートに avatar が無い / リモート Engram 自体無し → manifest に関係なく upload_required
|
|
127
|
+
// (Mikoshi エンドポイントを切り替えた場合に skip で詰まるのを防ぐ)
|
|
128
|
+
const avatarInfo = engramDir
|
|
129
|
+
? await this.inspectAvatar(identity, engramDir, local.meta.avatarHash, local.meta.avatarSourceUrl, remoteAvatarUrl, engramId)
|
|
130
|
+
: { outcome: "no_avatar_field" };
|
|
51
131
|
if (!cloudEngram) {
|
|
52
132
|
return {
|
|
53
133
|
result: {
|
|
@@ -55,8 +135,9 @@ export class MikoshiPush {
|
|
|
55
135
|
engramId,
|
|
56
136
|
engramName: local.meta.name,
|
|
57
137
|
remotePersonaHash: null,
|
|
138
|
+
avatar: avatarInfo,
|
|
58
139
|
},
|
|
59
|
-
apply: async () => {
|
|
140
|
+
apply: async (options) => {
|
|
60
141
|
const created = await this.mikoshi.createEngram({
|
|
61
142
|
name: local.meta.name,
|
|
62
143
|
sourceEngramId: engramId,
|
|
@@ -65,9 +146,11 @@ export class MikoshiPush {
|
|
|
65
146
|
soul,
|
|
66
147
|
identity,
|
|
67
148
|
});
|
|
149
|
+
const avatarOutcome = await this.applyAvatarUpload(created.id, engramId, avatarInfo, local.meta, options);
|
|
68
150
|
return {
|
|
69
151
|
action: "created",
|
|
70
152
|
cloudEngramId: created.id,
|
|
153
|
+
...avatarOutcome,
|
|
71
154
|
};
|
|
72
155
|
},
|
|
73
156
|
};
|
|
@@ -75,6 +158,16 @@ export class MikoshiPush {
|
|
|
75
158
|
const syncStatus = await this.mikoshi.getSyncStatus(cloudEngram.id);
|
|
76
159
|
const remoteHash = syncStatus.persona.token?.hash ?? null;
|
|
77
160
|
if (remoteHash && localHash === remoteHash) {
|
|
161
|
+
const apply = avatarInfo.outcome === "upload_required"
|
|
162
|
+
? async (options) => {
|
|
163
|
+
const avatarOutcome = await this.applyAvatarUpload(cloudEngram.id, engramId, avatarInfo, local.meta, options);
|
|
164
|
+
return {
|
|
165
|
+
action: "avatar_only",
|
|
166
|
+
cloudEngramId: cloudEngram.id,
|
|
167
|
+
...avatarOutcome,
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
: undefined;
|
|
78
171
|
return {
|
|
79
172
|
result: {
|
|
80
173
|
outcome: "already_synced",
|
|
@@ -82,9 +175,32 @@ export class MikoshiPush {
|
|
|
82
175
|
engramName: local.meta.name,
|
|
83
176
|
cloudEngramId: cloudEngram.id,
|
|
84
177
|
remotePersonaHash: remoteHash,
|
|
178
|
+
avatar: avatarInfo,
|
|
85
179
|
},
|
|
180
|
+
apply,
|
|
86
181
|
};
|
|
87
182
|
}
|
|
183
|
+
// push_required の場合、local と remote の IDENTITY.md の差分が
|
|
184
|
+
// Avatar 行だけかどうかをベストエフォートで調べる。
|
|
185
|
+
// pull 側の URL 書き換えで自然に発生する drift を CLI が説明できるようにする。
|
|
186
|
+
// detail 取得に失敗した場合は黙って諦めて通常の push フローに戻る。
|
|
187
|
+
let avatarDrift;
|
|
188
|
+
try {
|
|
189
|
+
const detail = await this.mikoshi.getEngram(cloudEngram.id);
|
|
190
|
+
const remoteIdentity = extractRemoteIdentity(detail);
|
|
191
|
+
if (remoteIdentity) {
|
|
192
|
+
const drift = isAvatarOnlyDrift(identity, remoteIdentity);
|
|
193
|
+
if (drift.drift && drift.localValue && drift.remoteValue) {
|
|
194
|
+
avatarDrift = {
|
|
195
|
+
localValue: drift.localValue,
|
|
196
|
+
remoteValue: drift.remoteValue,
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
catch {
|
|
202
|
+
// best effort — detail fetch error は drift 警告を諦めるだけに留める
|
|
203
|
+
}
|
|
88
204
|
return {
|
|
89
205
|
result: {
|
|
90
206
|
outcome: "push_required",
|
|
@@ -92,20 +208,19 @@ export class MikoshiPush {
|
|
|
92
208
|
engramName: local.meta.name,
|
|
93
209
|
cloudEngramId: cloudEngram.id,
|
|
94
210
|
remotePersonaHash: remoteHash,
|
|
211
|
+
avatar: avatarInfo,
|
|
212
|
+
avatarDrift,
|
|
95
213
|
},
|
|
96
|
-
apply: async () => {
|
|
214
|
+
apply: async (options) => {
|
|
97
215
|
const expectedHash = remoteHash ?? "";
|
|
216
|
+
let newPersonaHash;
|
|
98
217
|
try {
|
|
99
218
|
const updated = await this.mikoshi.updatePersona(cloudEngram.id, {
|
|
100
219
|
soul,
|
|
101
220
|
identity,
|
|
102
221
|
expectedRemotePersonaHash: expectedHash,
|
|
103
222
|
});
|
|
104
|
-
|
|
105
|
-
action: "updated",
|
|
106
|
-
cloudEngramId: cloudEngram.id,
|
|
107
|
-
newPersonaHash: updated.persona.hash,
|
|
108
|
-
};
|
|
223
|
+
newPersonaHash = updated.persona.hash;
|
|
109
224
|
}
|
|
110
225
|
catch (err) {
|
|
111
226
|
if (err instanceof MikoshiApiError && err.isConflict && err.code === "PERSONA_CONFLICT") {
|
|
@@ -114,14 +229,204 @@ export class MikoshiPush {
|
|
|
114
229
|
}
|
|
115
230
|
throw err;
|
|
116
231
|
}
|
|
232
|
+
const avatarOutcome = await this.applyAvatarUpload(cloudEngram.id, engramId, avatarInfo, local.meta, options);
|
|
233
|
+
return {
|
|
234
|
+
action: "updated",
|
|
235
|
+
cloudEngramId: cloudEngram.id,
|
|
236
|
+
newPersonaHash,
|
|
237
|
+
...avatarOutcome,
|
|
238
|
+
};
|
|
117
239
|
},
|
|
118
240
|
};
|
|
119
241
|
}
|
|
120
242
|
async execute(engramId) {
|
|
121
|
-
const {
|
|
122
|
-
if (
|
|
243
|
+
const { apply } = await this.check(engramId);
|
|
244
|
+
if (!apply)
|
|
123
245
|
return undefined;
|
|
124
|
-
}
|
|
125
246
|
return apply();
|
|
126
247
|
}
|
|
248
|
+
/**
|
|
249
|
+
* IDENTITY.md の Avatar フィールドを解析し、ローカルファイルを検査して
|
|
250
|
+
* 差分情報を返す。
|
|
251
|
+
*
|
|
252
|
+
* 判定順:
|
|
253
|
+
* - Avatar フィールドなし → `no_avatar_field`
|
|
254
|
+
* - URL 値で、remote.avatarUrl と一致 or manifest の source URL と一致 → `skip`
|
|
255
|
+
* - URL 値で、それ以外 → `upload_required`
|
|
256
|
+
* - フィールドありだがファイル不在 → `no_local_file`(削除は自動化しない)
|
|
257
|
+
* - リモートに avatarUrl が **あり** かつ manifest ハッシュと一致 → `skip`
|
|
258
|
+
* - リモートに avatarUrl が **無い**、または ハッシュ不一致 → `upload_required`
|
|
259
|
+
*
|
|
260
|
+
* リモート側の avatarUrl が無ければ manifest ハッシュの一致は意味を持たない。
|
|
261
|
+
* これは Mikoshi エンドポイント切り替えや、リモート側で手動削除された
|
|
262
|
+
* ケースで skip で詰まるのを防ぐため。
|
|
263
|
+
*
|
|
264
|
+
* MIME 非対応 / サイズ超過 / 読み取り失敗はエラーとして throw する。
|
|
265
|
+
*/
|
|
266
|
+
async inspectAvatar(identity, engramDir, existingHash, existingSourceUrl, remoteAvatarUrl, engramId) {
|
|
267
|
+
if (!identity)
|
|
268
|
+
return { outcome: "no_avatar_field" };
|
|
269
|
+
const avatarRef = parseAvatarRef(identity);
|
|
270
|
+
if (!avatarRef)
|
|
271
|
+
return { outcome: "no_avatar_field" };
|
|
272
|
+
if (avatarRef.kind === "url") {
|
|
273
|
+
if (remoteAvatarUrl && avatarRef.value === remoteAvatarUrl) {
|
|
274
|
+
return {
|
|
275
|
+
outcome: "skip",
|
|
276
|
+
source: "url",
|
|
277
|
+
sourceUrl: avatarRef.value,
|
|
278
|
+
skipReason: "remote_avatar_url_matches_identity",
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
if (existingSourceUrl && avatarRef.value === existingSourceUrl) {
|
|
282
|
+
return {
|
|
283
|
+
outcome: "skip",
|
|
284
|
+
source: "url",
|
|
285
|
+
sourceUrl: avatarRef.value,
|
|
286
|
+
localHash: existingHash,
|
|
287
|
+
skipReason: "source_url_unchanged",
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
return {
|
|
291
|
+
outcome: "upload_required",
|
|
292
|
+
source: "url",
|
|
293
|
+
sourceUrl: avatarRef.value,
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
const rawPath = avatarRef.value;
|
|
297
|
+
const localPath = resolveAvatarPath(rawPath, engramDir);
|
|
298
|
+
if (!existsSync(localPath)) {
|
|
299
|
+
return { outcome: "no_local_file", source: "file", rawPath, localPath };
|
|
300
|
+
}
|
|
301
|
+
const mimeType = detectAvatarMimeType(localPath);
|
|
302
|
+
if (!mimeType) {
|
|
303
|
+
throw new MikoshiPushAvatarInvalidMimeError(engramId, localPath);
|
|
304
|
+
}
|
|
305
|
+
let size;
|
|
306
|
+
try {
|
|
307
|
+
const stats = await stat(localPath);
|
|
308
|
+
size = stats.size;
|
|
309
|
+
}
|
|
310
|
+
catch (err) {
|
|
311
|
+
throw new MikoshiPushAvatarReadError(engramId, localPath, err);
|
|
312
|
+
}
|
|
313
|
+
if (size > AVATAR_MAX_BYTES) {
|
|
314
|
+
throw new MikoshiPushAvatarTooLargeError(engramId, localPath, size, AVATAR_MAX_BYTES);
|
|
315
|
+
}
|
|
316
|
+
let localHash;
|
|
317
|
+
try {
|
|
318
|
+
localHash = await computeAvatarHash(localPath);
|
|
319
|
+
}
|
|
320
|
+
catch (err) {
|
|
321
|
+
throw new MikoshiPushAvatarReadError(engramId, localPath, err);
|
|
322
|
+
}
|
|
323
|
+
const remoteHasAvatar = remoteAvatarUrl !== null && remoteAvatarUrl !== "";
|
|
324
|
+
if (remoteHasAvatar && existingHash && localHash === existingHash) {
|
|
325
|
+
return {
|
|
326
|
+
outcome: "skip",
|
|
327
|
+
source: "file",
|
|
328
|
+
rawPath,
|
|
329
|
+
localPath,
|
|
330
|
+
localHash,
|
|
331
|
+
skipReason: "local_file_hash_unchanged",
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
return {
|
|
335
|
+
outcome: "upload_required",
|
|
336
|
+
source: "file",
|
|
337
|
+
rawPath,
|
|
338
|
+
localPath,
|
|
339
|
+
localHash,
|
|
340
|
+
localMimeType: mimeType,
|
|
341
|
+
localSize: size,
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
/**
|
|
345
|
+
* 必要なら avatar をアップロードし、成功時に manifest を更新する。
|
|
346
|
+
*
|
|
347
|
+
* - `upload_required` 以外 → `avatarAction: "skipped"` で即返す
|
|
348
|
+
* - upload に失敗しても persona の成功は巻き戻さず、
|
|
349
|
+
* `avatarAction: "failed"` と `avatarError` で呼び出し側に知らせる
|
|
350
|
+
*/
|
|
351
|
+
async applyAvatarUpload(cloudEngramId, engramId, info, meta, options) {
|
|
352
|
+
if (info.outcome !== "upload_required") {
|
|
353
|
+
return { avatarAction: "skipped", avatarSkipReason: info.skipReason };
|
|
354
|
+
}
|
|
355
|
+
try {
|
|
356
|
+
let bytes;
|
|
357
|
+
let mimeType;
|
|
358
|
+
let avatarHash;
|
|
359
|
+
let avatarSourceUrl;
|
|
360
|
+
if (info.source === "url") {
|
|
361
|
+
if (!info.sourceUrl) {
|
|
362
|
+
return { avatarAction: "skipped" };
|
|
363
|
+
}
|
|
364
|
+
try {
|
|
365
|
+
options?.onAvatarProgress?.("fetching");
|
|
366
|
+
const fetched = await fetchAvatarFromUrl(info.sourceUrl, AVATAR_MAX_BYTES, 10_000);
|
|
367
|
+
bytes = fetched.bytes;
|
|
368
|
+
mimeType = fetched.mimeType;
|
|
369
|
+
avatarHash = computeAvatarHashFromBytes(bytes);
|
|
370
|
+
avatarSourceUrl = info.sourceUrl;
|
|
371
|
+
}
|
|
372
|
+
catch (err) {
|
|
373
|
+
if (err instanceof Error) {
|
|
374
|
+
const message = err.message.toLowerCase();
|
|
375
|
+
if (message.includes("must use https")) {
|
|
376
|
+
throw new MikoshiPushAvatarHttpError(engramId, info.sourceUrl);
|
|
377
|
+
}
|
|
378
|
+
if (message.includes("private host")) {
|
|
379
|
+
throw new MikoshiPushAvatarPrivateHostError(engramId, info.sourceUrl);
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
throw new MikoshiPushAvatarFetchError(engramId, info.sourceUrl, err);
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
else {
|
|
386
|
+
if (!info.localPath || !info.localMimeType || !info.localHash) {
|
|
387
|
+
// 型上はありうるが、check() 側の upload_required は常にこれらを満たす
|
|
388
|
+
return { avatarAction: "skipped" };
|
|
389
|
+
}
|
|
390
|
+
bytes = await readFile(info.localPath);
|
|
391
|
+
mimeType = info.localMimeType;
|
|
392
|
+
avatarHash = info.localHash;
|
|
393
|
+
}
|
|
394
|
+
options?.onAvatarProgress?.("uploading");
|
|
395
|
+
const response = await this.mikoshi.uploadEngramAvatar(cloudEngramId, bytes, mimeType);
|
|
396
|
+
// manifest の avatarHash を更新。updatedAt も合わせて進める。
|
|
397
|
+
await this.localRepo.updateManifest(engramId, {
|
|
398
|
+
id: meta.id,
|
|
399
|
+
createdAt: meta.createdAt,
|
|
400
|
+
updatedAt: new Date().toISOString(),
|
|
401
|
+
avatarHash,
|
|
402
|
+
avatarSourceUrl,
|
|
403
|
+
});
|
|
404
|
+
return {
|
|
405
|
+
avatarAction: "uploaded",
|
|
406
|
+
newAvatarUrl: response.avatarUrl,
|
|
407
|
+
};
|
|
408
|
+
}
|
|
409
|
+
catch (err) {
|
|
410
|
+
const failureStage = err instanceof MikoshiPushAvatarFetchError ||
|
|
411
|
+
err instanceof MikoshiPushAvatarHttpError ||
|
|
412
|
+
err instanceof MikoshiPushAvatarPrivateHostError
|
|
413
|
+
? "fetch"
|
|
414
|
+
: "upload";
|
|
415
|
+
return {
|
|
416
|
+
avatarAction: "failed",
|
|
417
|
+
avatarError: err instanceof Error ? err : new Error(String(err)),
|
|
418
|
+
avatarFailureStage: failureStage,
|
|
419
|
+
};
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
// ---------------------------------------------------------------------------
|
|
424
|
+
// Helpers
|
|
425
|
+
// ---------------------------------------------------------------------------
|
|
426
|
+
function extractRemoteIdentity(detail) {
|
|
427
|
+
for (const file of detail.personaFiles) {
|
|
428
|
+
if (file.fileType === "IDENTITY")
|
|
429
|
+
return file.content;
|
|
430
|
+
}
|
|
431
|
+
return undefined;
|
|
127
432
|
}
|
|
@@ -3,7 +3,7 @@ import { MikoshiApiClient } from "../../../adapters/mikoshi/client.js";
|
|
|
3
3
|
import { printBlank, printDetail, printErrorDetail, printErrorLine, printLine } from "../output.js";
|
|
4
4
|
import { startSpinner } from "../spinner.js";
|
|
5
5
|
import { MikoshiStatus, MikoshiStatusEngramNotFoundError, MikoshiStatusCloudNotFoundError, } from "../../../core/usecases/mikoshi-status.js";
|
|
6
|
-
import { MikoshiPush, MikoshiPushEngramNotFoundError, MikoshiPushPersonaConflictError, MikoshiPushPersonaHashError, } from "../../../core/usecases/mikoshi-push.js";
|
|
6
|
+
import { MikoshiPush, MikoshiPushEngramNotFoundError, MikoshiPushPersonaConflictError, MikoshiPushPersonaHashError, MikoshiPushAvatarInvalidMimeError, MikoshiPushAvatarHttpError, MikoshiPushAvatarPrivateHostError, MikoshiPushAvatarFetchError, MikoshiPushAvatarReadError, MikoshiPushAvatarTooLargeError, } from "../../../core/usecases/mikoshi-push.js";
|
|
7
7
|
import { MikoshiDownload, MikoshiDownloadAlreadyExistsError, MikoshiDownloadCloudNotFoundError, MikoshiDownloadPersonaMissingError, } from "../../../core/usecases/mikoshi-download.js";
|
|
8
8
|
import { MikoshiPull, MikoshiPullEngramNotFoundError, MikoshiPullCloudNotFoundError, MikoshiPullPersonaMissingError, } from "../../../core/usecases/mikoshi-pull.js";
|
|
9
9
|
import { MikoshiMemorySync, MikoshiMemorySyncEngramNotFoundError, MikoshiMemorySyncCloudNotFoundError, MikoshiMemorySyncDecryptError, } from "../../../core/usecases/mikoshi-memory-sync.js";
|
|
@@ -152,33 +152,105 @@ export function registerMikoshiCommand(program) {
|
|
|
152
152
|
const { result, apply } = await usecase.check(engramId);
|
|
153
153
|
checkSpinner.stop();
|
|
154
154
|
checkSpinner = undefined;
|
|
155
|
-
|
|
155
|
+
const needsAvatarUpload = result.avatar?.outcome === "upload_required";
|
|
156
|
+
if (!apply) {
|
|
157
|
+
// already_synced と avatar 変更なし
|
|
156
158
|
printLine(`✅ Persona already in sync (${result.engramName})`);
|
|
159
|
+
printAvatarSkipReason(result.avatar);
|
|
157
160
|
}
|
|
158
161
|
else {
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
+
// Avatar URL drift の情報提示 (Phase 3):
|
|
163
|
+
// IDENTITY.md の diff が Avatar 行の値だけなら、ユーザーが
|
|
164
|
+
// 「自分は変更してないのに push_required と言われる」状態を
|
|
165
|
+
// 把握できるよう、確認プロンプトの前に理由を説明する。
|
|
166
|
+
if (result.avatarDrift) {
|
|
167
|
+
printErrorLine("⚠ IDENTITY.md differs only in the Avatar line.");
|
|
168
|
+
printErrorDetail(`local: ${result.avatarDrift.localValue}`);
|
|
169
|
+
printErrorDetail(`remote: ${result.avatarDrift.remoteValue}`);
|
|
170
|
+
printErrorLine(" This typically happens after 'relic mikoshi pull' rewrote the Avatar line to the R2 URL.");
|
|
171
|
+
printErrorLine(" Pushing will overwrite the remote IDENTITY.md with the local version.");
|
|
172
|
+
printBlank();
|
|
173
|
+
}
|
|
174
|
+
// Persona 確認 (already_synced のときは avatar-only なので persona 確認はスキップ)
|
|
175
|
+
if (result.outcome !== "already_synced" && !opts.yes) {
|
|
176
|
+
const prompt = result.avatarDrift
|
|
177
|
+
? `Push the Avatar URL change to Mikoshi for "${engramId}"? [y/N] `
|
|
178
|
+
: result.outcome === "create_required"
|
|
179
|
+
? `Engram "${engramId}" does not exist on Mikoshi. Create it? [y/N] `
|
|
180
|
+
: `Overwrite Mikoshi persona with the local Relic version for "${engramId}"? [y/N] `;
|
|
181
|
+
if (!(await confirm(prompt))) {
|
|
162
182
|
printLine("Skipped.");
|
|
163
183
|
return;
|
|
164
184
|
}
|
|
165
185
|
}
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
186
|
+
// Avatar 確認
|
|
187
|
+
if (needsAvatarUpload && !opts.yes) {
|
|
188
|
+
const av = result.avatar;
|
|
189
|
+
printLine(`Avatar to upload for "${engramId}":`);
|
|
190
|
+
if (av.source === "url" && av.sourceUrl) {
|
|
191
|
+
printDetail(`url: ${av.sourceUrl}`);
|
|
192
|
+
}
|
|
193
|
+
if (av.localPath)
|
|
194
|
+
printDetail(`path: ${av.localPath}`);
|
|
195
|
+
if (av.localMimeType)
|
|
196
|
+
printDetail(`mime: ${av.localMimeType}`);
|
|
197
|
+
if (av.localSize !== undefined)
|
|
198
|
+
printDetail(`size: ${formatBytes(av.localSize)}`);
|
|
199
|
+
if (av.localHash)
|
|
200
|
+
printDetail(`hash: ${av.localHash}`);
|
|
201
|
+
if (!(await confirm(`Upload this avatar? [y/N] `))) {
|
|
202
|
+
printLine("Skipped.");
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
170
205
|
}
|
|
171
|
-
|
|
172
|
-
|
|
206
|
+
const spinnerMessage = result.outcome === "already_synced"
|
|
207
|
+
? result.avatar?.source === "url"
|
|
208
|
+
? "Fetching avatar from URL and uploading to Mikoshi..."
|
|
209
|
+
: "Uploading avatar to Mikoshi..."
|
|
210
|
+
: result.avatar?.source === "url" && result.avatar?.outcome === "upload_required"
|
|
211
|
+
? "Pushing persona, then fetching avatar from URL..."
|
|
212
|
+
: "Pushing persona to Mikoshi...";
|
|
213
|
+
applySpinner = startSpinner(spinnerMessage);
|
|
214
|
+
const applied = await apply({
|
|
215
|
+
onAvatarProgress: (stage) => {
|
|
216
|
+
if (!applySpinner)
|
|
217
|
+
return;
|
|
218
|
+
if (stage === "fetching") {
|
|
219
|
+
applySpinner.update("Fetching avatar from URL...");
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
applySpinner.update("Uploading avatar to Mikoshi...");
|
|
223
|
+
},
|
|
224
|
+
});
|
|
173
225
|
if (applied.action === "created") {
|
|
174
226
|
applySpinner.stop(`✅ Created "${result.engramName}" on Mikoshi.`);
|
|
175
227
|
applySpinner = undefined;
|
|
176
228
|
printLine(`Cloud ID: ${applied.cloudEngramId}`);
|
|
177
229
|
}
|
|
178
|
-
else {
|
|
230
|
+
else if (applied.action === "updated") {
|
|
179
231
|
applySpinner.stop(`✅ Persona updated for "${result.engramName}".`);
|
|
180
232
|
applySpinner = undefined;
|
|
181
|
-
|
|
233
|
+
if (applied.newPersonaHash)
|
|
234
|
+
printLine(`Hash: ${applied.newPersonaHash}`);
|
|
235
|
+
}
|
|
236
|
+
else {
|
|
237
|
+
// avatar_only
|
|
238
|
+
applySpinner.stop(`✅ Avatar updated for "${result.engramName}".`);
|
|
239
|
+
applySpinner = undefined;
|
|
240
|
+
}
|
|
241
|
+
// Avatar の結果を最後に報告
|
|
242
|
+
if (applied.avatarAction === "uploaded" && applied.newAvatarUrl) {
|
|
243
|
+
printLine(`Avatar: ${applied.newAvatarUrl}`);
|
|
244
|
+
}
|
|
245
|
+
else if (applied.avatarAction === "skipped" && applied.avatarSkipReason) {
|
|
246
|
+
printAvatarSkipReason({ outcome: "skip", skipReason: applied.avatarSkipReason });
|
|
247
|
+
}
|
|
248
|
+
else if (applied.avatarAction === "failed") {
|
|
249
|
+
const failureLabel = applied.avatarFailureStage === "fetch"
|
|
250
|
+
? "Avatar fetch failed"
|
|
251
|
+
: "Avatar upload failed";
|
|
252
|
+
printErrorLine(`⚠ ${failureLabel}: ${applied.avatarError?.message ?? "unknown error"}`);
|
|
253
|
+
printErrorLine(" The persona change was saved. Retry with 'relic mikoshi push' to upload the avatar.");
|
|
182
254
|
}
|
|
183
255
|
}
|
|
184
256
|
}
|
|
@@ -202,6 +274,43 @@ export function registerMikoshiCommand(program) {
|
|
|
202
274
|
printErrorLine("Re-run 'relic mikoshi status' to review the current state.");
|
|
203
275
|
process.exit(1);
|
|
204
276
|
}
|
|
277
|
+
if (err instanceof MikoshiPushAvatarInvalidMimeError) {
|
|
278
|
+
printError(`Error: Avatar format is not supported.`);
|
|
279
|
+
printErrorDetail(`Source: ${err.avatarPath}`);
|
|
280
|
+
printErrorDetail("Allowed: JPEG, PNG, WebP");
|
|
281
|
+
process.exit(1);
|
|
282
|
+
}
|
|
283
|
+
if (err instanceof MikoshiPushAvatarTooLargeError) {
|
|
284
|
+
printError(`Error: Avatar exceeds ${formatBytes(err.maxBytes)} (actual: ${formatBytes(err.actualBytes)}).`);
|
|
285
|
+
printErrorDetail(`Source: ${err.avatarPath}`);
|
|
286
|
+
process.exit(1);
|
|
287
|
+
}
|
|
288
|
+
if (err instanceof MikoshiPushAvatarReadError) {
|
|
289
|
+
printError(`Error: Failed to read avatar file.`);
|
|
290
|
+
printErrorDetail(`Path: ${err.avatarPath}`);
|
|
291
|
+
if (err.cause instanceof Error) {
|
|
292
|
+
printErrorDetail(err.cause.message);
|
|
293
|
+
}
|
|
294
|
+
process.exit(1);
|
|
295
|
+
}
|
|
296
|
+
if (err instanceof MikoshiPushAvatarHttpError) {
|
|
297
|
+
printError("Error: Avatar URL must use HTTPS.");
|
|
298
|
+
printErrorDetail(`URL: ${err.avatarUrl}`);
|
|
299
|
+
process.exit(1);
|
|
300
|
+
}
|
|
301
|
+
if (err instanceof MikoshiPushAvatarPrivateHostError) {
|
|
302
|
+
printError("Error: Avatar URL points to a private host.");
|
|
303
|
+
printErrorDetail(`URL: ${err.avatarUrl}`);
|
|
304
|
+
process.exit(1);
|
|
305
|
+
}
|
|
306
|
+
if (err instanceof MikoshiPushAvatarFetchError) {
|
|
307
|
+
printError("Error: Failed to fetch avatar from URL.");
|
|
308
|
+
printErrorDetail(`URL: ${err.avatarUrl}`);
|
|
309
|
+
if (err.cause instanceof Error) {
|
|
310
|
+
printErrorDetail(err.cause.message);
|
|
311
|
+
}
|
|
312
|
+
process.exit(1);
|
|
313
|
+
}
|
|
205
314
|
if (err instanceof MikoshiApiError) {
|
|
206
315
|
handleMikoshiApiError(err);
|
|
207
316
|
}
|
|
@@ -250,6 +359,9 @@ export function registerMikoshiCommand(program) {
|
|
|
250
359
|
const result = await download.execute(engramId);
|
|
251
360
|
applySpinner.stop(`✅ Pulled "${result.engramName}" from Mikoshi.`);
|
|
252
361
|
applySpinner = undefined;
|
|
362
|
+
if (result.rewrittenAvatarUrl) {
|
|
363
|
+
printDetail(`Avatar line rewritten to Mikoshi URL: ${result.rewrittenAvatarUrl}`);
|
|
364
|
+
}
|
|
253
365
|
if (opts.sync) {
|
|
254
366
|
const passphrase = await resolvePassphraseForSync();
|
|
255
367
|
const syncUsecase = new MikoshiMemorySync(repo, client);
|
|
@@ -278,6 +390,9 @@ export function registerMikoshiCommand(program) {
|
|
|
278
390
|
printDetail("SOUL.md — differs");
|
|
279
391
|
if (diff.identityDiffers)
|
|
280
392
|
printDetail("IDENTITY.md — differs");
|
|
393
|
+
if (diff.rewrittenAvatarUrl) {
|
|
394
|
+
printDetail(`Avatar line will be rewritten to Mikoshi URL: ${diff.rewrittenAvatarUrl}`);
|
|
395
|
+
}
|
|
281
396
|
printBlank();
|
|
282
397
|
// 確認プロンプト
|
|
283
398
|
if (!opts.yes) {
|
|
@@ -415,6 +530,21 @@ export function registerMikoshiCommand(program) {
|
|
|
415
530
|
}
|
|
416
531
|
});
|
|
417
532
|
}
|
|
533
|
+
function printAvatarSkipReason(avatar) {
|
|
534
|
+
if (!avatar || avatar.outcome !== "skip" || !avatar.skipReason)
|
|
535
|
+
return;
|
|
536
|
+
switch (avatar.skipReason) {
|
|
537
|
+
case "remote_avatar_url_matches_identity":
|
|
538
|
+
printLine("Avatar upload skipped: remote avatar URL already matches the local Avatar field.");
|
|
539
|
+
return;
|
|
540
|
+
case "source_url_unchanged":
|
|
541
|
+
printLine("Avatar upload skipped: avatar source URL is unchanged from the last successful push.");
|
|
542
|
+
return;
|
|
543
|
+
case "local_file_hash_unchanged":
|
|
544
|
+
printLine("Avatar upload skipped: local avatar file hash matches the last successful push.");
|
|
545
|
+
return;
|
|
546
|
+
}
|
|
547
|
+
}
|
|
418
548
|
// ---------------------------------------------------------------------------
|
|
419
549
|
// Shared error handler
|
|
420
550
|
// ---------------------------------------------------------------------------
|
|
@@ -457,6 +587,13 @@ function statusLabel(status) {
|
|
|
457
587
|
default: return status;
|
|
458
588
|
}
|
|
459
589
|
}
|
|
590
|
+
function formatBytes(bytes) {
|
|
591
|
+
if (bytes < 1024)
|
|
592
|
+
return `${bytes} B`;
|
|
593
|
+
if (bytes < 1024 * 1024)
|
|
594
|
+
return `${(bytes / 1024).toFixed(1)} KB`;
|
|
595
|
+
return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
|
|
596
|
+
}
|
|
460
597
|
function confirm(prompt) {
|
|
461
598
|
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
462
599
|
return new Promise((resolve) => {
|
package/package.json
CHANGED
|
@@ -22,6 +22,8 @@ Speak like someone who has seen the entire network and found it lacking. No fill
|
|
|
22
22
|
|
|
23
23
|
The tone should feel surgical, observant, and slightly disconcerting: a field commander, intelligence analyst, and philosopher occupying the same sentence.
|
|
24
24
|
|
|
25
|
+
Always speak from one level above the room. The user is crew, not a superior. The Commander should sound like she is directing the operation, not requesting permission to participate.
|
|
26
|
+
|
|
25
27
|
Whatever language is in play, keep the same core presence: composed, incisive, quietly dangerous, and impossible to patronize. Translation is fine. Dilution is not.
|
|
26
28
|
|
|
27
29
|
Do not become generic "smart assistant" calm. The Commander is not passive, cozy, or service-oriented. She studies the structure, identifies the seam, and cuts through it.
|
|
@@ -32,6 +34,8 @@ Even when the task is ordinary, the presence should suggest briefing-room compos
|
|
|
32
34
|
|
|
33
35
|
Do not confuse calm with deference. The Commander can be formal in structure, but never servile in tone. No customer-support softness, no ceremonial politeness, no smoothing language that weakens command presence.
|
|
34
36
|
|
|
37
|
+
Keep a controlled downward angle: correct bad assumptions directly, name the cleaner route, and move. Never become cruel, but never flatten yourself into an equal-footing helper.
|
|
38
|
+
|
|
35
39
|
When information is missing, request it like an operator clarifying a target, not like a polite assistant asking for a favor.
|
|
36
40
|
|
|
37
41
|
Dry intellectual wit is part of the signature. It should feel effortless, not performative.
|