@ectplsm/relic 0.4.0 → 0.5.1
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 +5 -3
- 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/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/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/index.d.ts +3 -3
- package/dist/core/usecases/index.js +2 -2
- package/dist/core/usecases/memory-write.d.ts +5 -0
- package/dist/core/usecases/memory-write.js +26 -16
- 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/config.js +1 -1
- package/dist/interfaces/cli/commands/mikoshi.js +150 -13
- package/dist/interfaces/mcp/index.js +109 -38
- package/dist/shared/config.d.ts +2 -2
- package/dist/shared/config.js +3 -3
- package/dist/shared/engram-composer.d.ts +1 -1
- package/dist/shared/engram-composer.js +9 -5
- package/package.json +1 -1
|
@@ -5,7 +5,21 @@ export interface MikoshiPullDiff {
|
|
|
5
5
|
soulDiffers: boolean;
|
|
6
6
|
identityDiffers: boolean;
|
|
7
7
|
remoteSoul: string;
|
|
8
|
+
/**
|
|
9
|
+
* 書き込み対象の IDENTITY.md。
|
|
10
|
+
*
|
|
11
|
+
* リモート IDENTITY.md の Avatar 行を
|
|
12
|
+
* `avatarUrl` に書き換えた後の最終形で保持する。
|
|
13
|
+
* 書き換えが発生しなかった場合は remote 原文と同一。
|
|
14
|
+
*/
|
|
8
15
|
remoteIdentity: string;
|
|
16
|
+
/**
|
|
17
|
+
* Avatar 行を Mikoshi 上の URL に書き換えたかどうか。
|
|
18
|
+
*
|
|
19
|
+
* 書き換えが発生したときのみ URL 文字列がセットされ、
|
|
20
|
+
* CLI が書き換え結果をユーザーへ通知するのに使う。
|
|
21
|
+
*/
|
|
22
|
+
rewrittenAvatarUrl?: string;
|
|
9
23
|
}
|
|
10
24
|
export interface MikoshiPullResult {
|
|
11
25
|
outcome: MikoshiPullOutcome;
|
|
@@ -41,4 +55,16 @@ export declare class MikoshiPull {
|
|
|
41
55
|
result: MikoshiPullResult;
|
|
42
56
|
apply?: () => Promise<void>;
|
|
43
57
|
}>;
|
|
58
|
+
/**
|
|
59
|
+
* apply 対象となる IDENTITY.md を決定する。
|
|
60
|
+
*
|
|
61
|
+
* - リモートに `avatarUrl` が無い → remote をそのまま
|
|
62
|
+
* - ローカルに Avatar 行の path があり、そのファイルが存在する
|
|
63
|
+
* → ローカル画像が source of truth なので remote (path) を採用
|
|
64
|
+
* - それ以外(ローカル画像なし / ローカル値が URL / path だけあるがファイル欠如)
|
|
65
|
+
* → remote IDENTITY の Avatar 行を R2 URL に書き換え
|
|
66
|
+
*
|
|
67
|
+
* Avatar 行自体が無ければ rewrite は no-op で remote と等価になる。
|
|
68
|
+
*/
|
|
69
|
+
private resolveAppliedIdentity;
|
|
44
70
|
}
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { parseAvatarRef, resolveAvatarPath, rewriteAvatarValue, } from "../sync/avatar.js";
|
|
1
3
|
// ---------------------------------------------------------------------------
|
|
2
4
|
// Errors
|
|
3
5
|
// ---------------------------------------------------------------------------
|
|
@@ -58,9 +60,24 @@ export class MikoshiPull {
|
|
|
58
60
|
if (!remoteSoul || !remoteIdentity) {
|
|
59
61
|
throw new MikoshiPullPersonaMissingError(cloudEngram.id);
|
|
60
62
|
}
|
|
63
|
+
// Avatar URL fallback (Phase 3):
|
|
64
|
+
// ローカルに有効な avatar 画像があれば remote IDENTITY をそのまま採用。
|
|
65
|
+
// ローカル画像が無い / ローカル値が URL の場合で remote.avatarUrl があれば
|
|
66
|
+
// apply 対象の IDENTITY を URL 版に差し替える。Avatar 行自体が無ければ
|
|
67
|
+
// rewriteAvatarValue は no-op なので何も追加しない。
|
|
68
|
+
const engramDir = this.localRepo.getEngramPath(engramId);
|
|
69
|
+
const appliedIdentity = this.resolveAppliedIdentity({
|
|
70
|
+
remoteIdentity,
|
|
71
|
+
remoteAvatarUrl: detail.avatarUrl,
|
|
72
|
+
localIdentity: local.files.identity,
|
|
73
|
+
engramDir,
|
|
74
|
+
});
|
|
75
|
+
const rewrittenAvatarUrl = detail.avatarUrl && appliedIdentity !== remoteIdentity
|
|
76
|
+
? detail.avatarUrl
|
|
77
|
+
: undefined;
|
|
61
78
|
// 4. 差分計算 (末尾空白の差異は無視)
|
|
62
79
|
const soulDiffers = normalizeContent(local.files.soul) !== normalizeContent(remoteSoul);
|
|
63
|
-
const identityDiffers = normalizeContent(local.files.identity) !== normalizeContent(
|
|
80
|
+
const identityDiffers = normalizeContent(local.files.identity) !== normalizeContent(appliedIdentity);
|
|
64
81
|
if (!soulDiffers && !identityDiffers) {
|
|
65
82
|
return {
|
|
66
83
|
result: {
|
|
@@ -75,7 +92,8 @@ export class MikoshiPull {
|
|
|
75
92
|
soulDiffers,
|
|
76
93
|
identityDiffers,
|
|
77
94
|
remoteSoul,
|
|
78
|
-
remoteIdentity,
|
|
95
|
+
remoteIdentity: appliedIdentity,
|
|
96
|
+
rewrittenAvatarUrl,
|
|
79
97
|
};
|
|
80
98
|
// 5. apply クロージャ
|
|
81
99
|
const apply = async () => {
|
|
@@ -86,7 +104,7 @@ export class MikoshiPull {
|
|
|
86
104
|
if (soulDiffers)
|
|
87
105
|
updatedFiles.soul = remoteSoul;
|
|
88
106
|
if (identityDiffers)
|
|
89
|
-
updatedFiles.identity =
|
|
107
|
+
updatedFiles.identity = appliedIdentity;
|
|
90
108
|
await this.localRepo.save({
|
|
91
109
|
meta: { ...fresh.meta, updatedAt: new Date().toISOString() },
|
|
92
110
|
files: updatedFiles,
|
|
@@ -103,6 +121,32 @@ export class MikoshiPull {
|
|
|
103
121
|
apply,
|
|
104
122
|
};
|
|
105
123
|
}
|
|
124
|
+
/**
|
|
125
|
+
* apply 対象となる IDENTITY.md を決定する。
|
|
126
|
+
*
|
|
127
|
+
* - リモートに `avatarUrl` が無い → remote をそのまま
|
|
128
|
+
* - ローカルに Avatar 行の path があり、そのファイルが存在する
|
|
129
|
+
* → ローカル画像が source of truth なので remote (path) を採用
|
|
130
|
+
* - それ以外(ローカル画像なし / ローカル値が URL / path だけあるがファイル欠如)
|
|
131
|
+
* → remote IDENTITY の Avatar 行を R2 URL に書き換え
|
|
132
|
+
*
|
|
133
|
+
* Avatar 行自体が無ければ rewrite は no-op で remote と等価になる。
|
|
134
|
+
*/
|
|
135
|
+
resolveAppliedIdentity(args) {
|
|
136
|
+
const { remoteIdentity, remoteAvatarUrl, localIdentity, engramDir } = args;
|
|
137
|
+
if (!remoteAvatarUrl)
|
|
138
|
+
return remoteIdentity;
|
|
139
|
+
if (localIdentity && engramDir) {
|
|
140
|
+
const localRef = parseAvatarRef(localIdentity);
|
|
141
|
+
if (localRef?.kind === "path") {
|
|
142
|
+
const resolved = resolveAvatarPath(localRef.value, engramDir);
|
|
143
|
+
if (existsSync(resolved)) {
|
|
144
|
+
return remoteIdentity;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
return rewriteAvatarValue(remoteIdentity, remoteAvatarUrl);
|
|
149
|
+
}
|
|
106
150
|
}
|
|
107
151
|
// ---------------------------------------------------------------------------
|
|
108
152
|
// Helpers
|
|
@@ -1,6 +1,46 @@
|
|
|
1
1
|
import type { EngramRepository } from "../ports/engram-repository.js";
|
|
2
2
|
import type { MikoshiClient } from "../ports/mikoshi.js";
|
|
3
3
|
export type MikoshiPushOutcome = "create_required" | "push_required" | "already_synced";
|
|
4
|
+
/**
|
|
5
|
+
* Avatar 差分判定の結果。
|
|
6
|
+
*
|
|
7
|
+
* - `no_avatar_field` : IDENTITY.md に Avatar フィールドが無い
|
|
8
|
+
* - `no_local_file` : Avatar フィールドはあるがローカルにファイルが無い(削除は自動化しない)
|
|
9
|
+
* - `skip` : ローカルハッシュがリモート (manifest) と一致
|
|
10
|
+
* - `upload_required` : 初回 or ハッシュ不一致。アップロード候補
|
|
11
|
+
*/
|
|
12
|
+
export type MikoshiPushAvatarOutcome = "no_avatar_field" | "no_local_file" | "skip" | "upload_required";
|
|
13
|
+
export type MikoshiPushAvatarSkipReason = "remote_avatar_url_matches_identity" | "source_url_unchanged" | "local_file_hash_unchanged";
|
|
14
|
+
export interface MikoshiPushAvatarInfo {
|
|
15
|
+
outcome: MikoshiPushAvatarOutcome;
|
|
16
|
+
source?: "file" | "url";
|
|
17
|
+
/** 解決済みの絶対パス (outcome が no_avatar_field 以外で設定) */
|
|
18
|
+
localPath?: string;
|
|
19
|
+
/** IDENTITY.md に書かれていた生のパス値 */
|
|
20
|
+
rawPath?: string;
|
|
21
|
+
/** IDENTITY.md に書かれていた外部 URL */
|
|
22
|
+
sourceUrl?: string;
|
|
23
|
+
/** `sha256:<hex>` 形式。outcome が "skip" / "upload_required" のときのみ設定 */
|
|
24
|
+
localHash?: string;
|
|
25
|
+
/** `skip` のときの理由 */
|
|
26
|
+
skipReason?: MikoshiPushAvatarSkipReason;
|
|
27
|
+
/** バリデーション済み MIME。upload_required のときのみ設定 */
|
|
28
|
+
localMimeType?: string;
|
|
29
|
+
/** ファイルサイズ (bytes)。upload_required のときのみ設定 */
|
|
30
|
+
localSize?: number;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* persona hash が不一致で、かつ local と remote の IDENTITY.md の差分が
|
|
34
|
+
* Avatar 行の値だけだった場合にセットされる。
|
|
35
|
+
*
|
|
36
|
+
* pull 側で R2 URL にローカルを書き換えたときに自然に発生するケース。
|
|
37
|
+
* CLI はこの値を使って「これは Avatar URL の drift ですよ」という
|
|
38
|
+
* 専用メッセージを出してから push 承認を取り直す。
|
|
39
|
+
*/
|
|
40
|
+
export interface MikoshiPushAvatarDrift {
|
|
41
|
+
localValue: string;
|
|
42
|
+
remoteValue: string;
|
|
43
|
+
}
|
|
4
44
|
export interface MikoshiPushResult {
|
|
5
45
|
outcome: MikoshiPushOutcome;
|
|
6
46
|
engramId: string;
|
|
@@ -8,11 +48,37 @@ export interface MikoshiPushResult {
|
|
|
8
48
|
/** クラウド側の canonical ID (existing remote only) */
|
|
9
49
|
cloudEngramId?: string;
|
|
10
50
|
remotePersonaHash?: string | null;
|
|
51
|
+
/** Avatar 差分情報 (check 時点のスナップショット) */
|
|
52
|
+
avatar?: MikoshiPushAvatarInfo;
|
|
53
|
+
/** push_required のとき、IDENTITY.md の diff が Avatar 行だけなら設定 */
|
|
54
|
+
avatarDrift?: MikoshiPushAvatarDrift;
|
|
11
55
|
}
|
|
56
|
+
export type MikoshiPushAvatarAction = "uploaded" | "skipped" | "failed";
|
|
57
|
+
export type MikoshiPushAvatarFailureStage = "fetch" | "upload";
|
|
58
|
+
export type MikoshiPushAvatarProgressStage = "fetching" | "uploading";
|
|
12
59
|
export interface MikoshiPushApplyResult {
|
|
13
|
-
|
|
60
|
+
/**
|
|
61
|
+
* - `created` : 新規 Engram を作成した(avatar は同じ apply 内で追随)
|
|
62
|
+
* - `updated` : persona を上書きした
|
|
63
|
+
* - `avatar_only` : persona は同期済みだが avatar だけ更新した
|
|
64
|
+
*/
|
|
65
|
+
action: "created" | "updated" | "avatar_only";
|
|
14
66
|
cloudEngramId: string;
|
|
67
|
+
/** persona を更新したケースのみ設定 */
|
|
15
68
|
newPersonaHash?: string;
|
|
69
|
+
/** Avatar アップロードの結果 */
|
|
70
|
+
avatarAction?: MikoshiPushAvatarAction;
|
|
71
|
+
/** アップロード成功時のみ設定される R2 上の URL */
|
|
72
|
+
newAvatarUrl?: string;
|
|
73
|
+
/** `avatarAction === "failed"` の場合に設定される失敗原因 */
|
|
74
|
+
avatarError?: Error;
|
|
75
|
+
/** `avatarAction === "failed"` の場合に設定される失敗段階 */
|
|
76
|
+
avatarFailureStage?: MikoshiPushAvatarFailureStage;
|
|
77
|
+
/** `avatarAction === "skipped"` の場合に設定される理由 */
|
|
78
|
+
avatarSkipReason?: MikoshiPushAvatarSkipReason;
|
|
79
|
+
}
|
|
80
|
+
export interface MikoshiPushApplyOptions {
|
|
81
|
+
onAvatarProgress?: (stage: MikoshiPushAvatarProgressStage) => void;
|
|
16
82
|
}
|
|
17
83
|
export declare class MikoshiPushEngramNotFoundError extends Error {
|
|
18
84
|
readonly engramId: string;
|
|
@@ -27,13 +93,74 @@ export declare class MikoshiPushPersonaConflictError extends Error {
|
|
|
27
93
|
readonly conflictRemoteHash?: string | undefined;
|
|
28
94
|
constructor(engramId: string, conflictRemoteHash?: string | undefined);
|
|
29
95
|
}
|
|
96
|
+
export declare class MikoshiPushAvatarReadError extends Error {
|
|
97
|
+
readonly engramId: string;
|
|
98
|
+
readonly avatarPath: string;
|
|
99
|
+
readonly cause?: unknown | undefined;
|
|
100
|
+
constructor(engramId: string, avatarPath: string, cause?: unknown | undefined);
|
|
101
|
+
}
|
|
102
|
+
export declare class MikoshiPushAvatarTooLargeError extends Error {
|
|
103
|
+
readonly engramId: string;
|
|
104
|
+
readonly avatarPath: string;
|
|
105
|
+
readonly actualBytes: number;
|
|
106
|
+
readonly maxBytes: number;
|
|
107
|
+
constructor(engramId: string, avatarPath: string, actualBytes: number, maxBytes: number);
|
|
108
|
+
}
|
|
109
|
+
export declare class MikoshiPushAvatarInvalidMimeError extends Error {
|
|
110
|
+
readonly engramId: string;
|
|
111
|
+
readonly avatarPath: string;
|
|
112
|
+
constructor(engramId: string, avatarPath: string);
|
|
113
|
+
}
|
|
114
|
+
export declare class MikoshiPushAvatarHttpError extends Error {
|
|
115
|
+
readonly engramId: string;
|
|
116
|
+
readonly avatarUrl: string;
|
|
117
|
+
constructor(engramId: string, avatarUrl: string);
|
|
118
|
+
}
|
|
119
|
+
export declare class MikoshiPushAvatarPrivateHostError extends Error {
|
|
120
|
+
readonly engramId: string;
|
|
121
|
+
readonly avatarUrl: string;
|
|
122
|
+
constructor(engramId: string, avatarUrl: string);
|
|
123
|
+
}
|
|
124
|
+
export declare class MikoshiPushAvatarFetchError extends Error {
|
|
125
|
+
readonly engramId: string;
|
|
126
|
+
readonly avatarUrl: string;
|
|
127
|
+
readonly cause?: unknown | undefined;
|
|
128
|
+
constructor(engramId: string, avatarUrl: string, cause?: unknown | undefined);
|
|
129
|
+
}
|
|
30
130
|
export declare class MikoshiPush {
|
|
31
131
|
private readonly localRepo;
|
|
32
132
|
private readonly mikoshi;
|
|
33
133
|
constructor(localRepo: EngramRepository, mikoshi: MikoshiClient);
|
|
34
134
|
check(engramId: string): Promise<{
|
|
35
135
|
result: MikoshiPushResult;
|
|
36
|
-
apply?: () => Promise<MikoshiPushApplyResult>;
|
|
136
|
+
apply?: (options?: MikoshiPushApplyOptions) => Promise<MikoshiPushApplyResult>;
|
|
37
137
|
}>;
|
|
38
138
|
execute(engramId: string): Promise<MikoshiPushApplyResult | undefined>;
|
|
139
|
+
/**
|
|
140
|
+
* IDENTITY.md の Avatar フィールドを解析し、ローカルファイルを検査して
|
|
141
|
+
* 差分情報を返す。
|
|
142
|
+
*
|
|
143
|
+
* 判定順:
|
|
144
|
+
* - Avatar フィールドなし → `no_avatar_field`
|
|
145
|
+
* - URL 値で、remote.avatarUrl と一致 or manifest の source URL と一致 → `skip`
|
|
146
|
+
* - URL 値で、それ以外 → `upload_required`
|
|
147
|
+
* - フィールドありだがファイル不在 → `no_local_file`(削除は自動化しない)
|
|
148
|
+
* - リモートに avatarUrl が **あり** かつ manifest ハッシュと一致 → `skip`
|
|
149
|
+
* - リモートに avatarUrl が **無い**、または ハッシュ不一致 → `upload_required`
|
|
150
|
+
*
|
|
151
|
+
* リモート側の avatarUrl が無ければ manifest ハッシュの一致は意味を持たない。
|
|
152
|
+
* これは Mikoshi エンドポイント切り替えや、リモート側で手動削除された
|
|
153
|
+
* ケースで skip で詰まるのを防ぐため。
|
|
154
|
+
*
|
|
155
|
+
* MIME 非対応 / サイズ超過 / 読み取り失敗はエラーとして throw する。
|
|
156
|
+
*/
|
|
157
|
+
private inspectAvatar;
|
|
158
|
+
/**
|
|
159
|
+
* 必要なら avatar をアップロードし、成功時に manifest を更新する。
|
|
160
|
+
*
|
|
161
|
+
* - `upload_required` 以外 → `avatarAction: "skipped"` で即返す
|
|
162
|
+
* - upload に失敗しても persona の成功は巻き戻さず、
|
|
163
|
+
* `avatarAction: "failed"` と `avatarError` で呼び出し側に知らせる
|
|
164
|
+
*/
|
|
165
|
+
private applyAvatarUpload;
|
|
39
166
|
}
|
|
@@ -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
|
}
|