@ectplsm/relic 0.3.1 → 0.4.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 +19 -12
- package/dist/adapters/mikoshi/client.d.ts +21 -0
- package/dist/adapters/mikoshi/client.js +84 -0
- package/dist/adapters/shells/claude-shell.js +6 -5
- package/dist/adapters/shells/codex-shell.js +6 -5
- package/dist/adapters/shells/gemini-shell.js +8 -0
- package/dist/adapters/shells/resume-detect.d.ts +19 -0
- package/dist/adapters/shells/resume-detect.js +51 -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/ports/shell-launcher.d.ts +6 -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/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 +8 -0
- package/dist/core/usecases/index.js +8 -0
- package/dist/core/usecases/inject.d.ts +1 -0
- package/dist/core/usecases/inject.js +2 -0
- 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 +59 -0
- 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/commands/shell.js +17 -3
- 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/shared/config.d.ts +23 -3
- package/dist/shared/config.js +30 -2
- package/package.json +1 -1
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
// ---------------------------------------------------------------------------
|
|
3
|
+
// Shared sub-schemas
|
|
4
|
+
// ---------------------------------------------------------------------------
|
|
5
|
+
export const PersonaFileSchema = z.object({
|
|
6
|
+
fileType: z.enum(["SOUL", "IDENTITY", "ENGRAM_JSON"]),
|
|
7
|
+
filename: z.string(),
|
|
8
|
+
});
|
|
9
|
+
export const MemorySummarySchema = z.object({
|
|
10
|
+
hasMemory: z.boolean(),
|
|
11
|
+
hasUserFile: z.boolean().optional(),
|
|
12
|
+
hasMemoryIndex: z.boolean().optional(),
|
|
13
|
+
memoryEntryCount: z.number().optional(),
|
|
14
|
+
latestMemoryDate: z.string().nullable().optional(),
|
|
15
|
+
memoryUpdatedAt: z.string().nullable().optional(),
|
|
16
|
+
});
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
// Engram list / detail
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
export const MikoshiEngramSchema = z.object({
|
|
21
|
+
id: z.string(),
|
|
22
|
+
sourceEngramId: z.string(),
|
|
23
|
+
name: z.string(),
|
|
24
|
+
description: z.string().nullable(),
|
|
25
|
+
visibility: z.enum(["PRIVATE", "UNLISTED", "PUBLIC"]),
|
|
26
|
+
tags: z.array(z.string()),
|
|
27
|
+
avatarUrl: z.string().nullable(),
|
|
28
|
+
createdAt: z.string(),
|
|
29
|
+
updatedAt: z.string(),
|
|
30
|
+
personaFiles: z.array(PersonaFileSchema),
|
|
31
|
+
memory: MemorySummarySchema.optional(),
|
|
32
|
+
});
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
// Engram detail (with persona content)
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
export const PersonaFileWithContentSchema = z.object({
|
|
37
|
+
fileType: z.enum(["SOUL", "IDENTITY", "ENGRAM_JSON"]),
|
|
38
|
+
filename: z.string(),
|
|
39
|
+
content: z.string(),
|
|
40
|
+
});
|
|
41
|
+
export const MikoshiEngramDetailSchema = z.object({
|
|
42
|
+
id: z.string(),
|
|
43
|
+
sourceEngramId: z.string(),
|
|
44
|
+
name: z.string(),
|
|
45
|
+
description: z.string().nullable(),
|
|
46
|
+
visibility: z.enum(["PRIVATE", "UNLISTED", "PUBLIC"]),
|
|
47
|
+
tags: z.array(z.string()),
|
|
48
|
+
avatarUrl: z.string().nullable(),
|
|
49
|
+
createdAt: z.string(),
|
|
50
|
+
updatedAt: z.string(),
|
|
51
|
+
personaFiles: z.array(PersonaFileWithContentSchema),
|
|
52
|
+
memory: MemorySummarySchema.optional(),
|
|
53
|
+
});
|
|
54
|
+
// ---------------------------------------------------------------------------
|
|
55
|
+
// Create engram
|
|
56
|
+
// ---------------------------------------------------------------------------
|
|
57
|
+
export const CreateEngramInputSchema = z.object({
|
|
58
|
+
name: z.string(),
|
|
59
|
+
sourceEngramId: z.string(),
|
|
60
|
+
description: z.string().optional(),
|
|
61
|
+
visibility: z.enum(["PRIVATE", "UNLISTED", "PUBLIC"]).optional(),
|
|
62
|
+
tags: z.array(z.string()).optional(),
|
|
63
|
+
soul: z.string(),
|
|
64
|
+
identity: z.string(),
|
|
65
|
+
});
|
|
66
|
+
export const CreateEngramResponseSchema = z.object({
|
|
67
|
+
id: z.string(),
|
|
68
|
+
sourceEngramId: z.string(),
|
|
69
|
+
name: z.string(),
|
|
70
|
+
visibility: z.string(),
|
|
71
|
+
url: z.string(),
|
|
72
|
+
});
|
|
73
|
+
// ---------------------------------------------------------------------------
|
|
74
|
+
// Update persona
|
|
75
|
+
// ---------------------------------------------------------------------------
|
|
76
|
+
export const UpdatePersonaInputSchema = z.object({
|
|
77
|
+
soul: z.string(),
|
|
78
|
+
identity: z.string(),
|
|
79
|
+
expectedRemotePersonaHash: z.string(),
|
|
80
|
+
});
|
|
81
|
+
export const UpdatePersonaResponseSchema = z.object({
|
|
82
|
+
engramId: z.string(),
|
|
83
|
+
persona: z.object({
|
|
84
|
+
hash: z.string(),
|
|
85
|
+
updatedAt: z.string(),
|
|
86
|
+
}),
|
|
87
|
+
});
|
|
88
|
+
// ---------------------------------------------------------------------------
|
|
89
|
+
// Sync status
|
|
90
|
+
// ---------------------------------------------------------------------------
|
|
91
|
+
export const PersonaSyncTokenSchema = z.object({
|
|
92
|
+
hash: z.string(),
|
|
93
|
+
updatedAt: z.string(),
|
|
94
|
+
});
|
|
95
|
+
export const PersonaSyncStatusSchema = z.object({
|
|
96
|
+
exists: z.boolean(),
|
|
97
|
+
token: PersonaSyncTokenSchema.nullable(),
|
|
98
|
+
files: z.object({
|
|
99
|
+
hasSoul: z.boolean(),
|
|
100
|
+
hasIdentity: z.boolean(),
|
|
101
|
+
}),
|
|
102
|
+
});
|
|
103
|
+
export const MemorySyncTokenSchema = z.object({
|
|
104
|
+
memoryContentHash: z.string(),
|
|
105
|
+
bundleHash: z.string(),
|
|
106
|
+
version: z.number(),
|
|
107
|
+
updatedAt: z.string(),
|
|
108
|
+
});
|
|
109
|
+
export const MemorySyncSummarySchema = z.object({
|
|
110
|
+
hasUserFile: z.boolean(),
|
|
111
|
+
hasMemoryIndex: z.boolean(),
|
|
112
|
+
memoryEntryCount: z.number(),
|
|
113
|
+
latestMemoryDate: z.string().nullable(),
|
|
114
|
+
});
|
|
115
|
+
export const MemorySyncStatusSchema = z.object({
|
|
116
|
+
exists: z.boolean(),
|
|
117
|
+
token: MemorySyncTokenSchema.nullable(),
|
|
118
|
+
summary: MemorySyncSummarySchema.nullable(),
|
|
119
|
+
});
|
|
120
|
+
export const SyncStatusResponseSchema = z.object({
|
|
121
|
+
engramId: z.string(),
|
|
122
|
+
persona: PersonaSyncStatusSchema,
|
|
123
|
+
memory: MemorySyncStatusSchema,
|
|
124
|
+
});
|
|
125
|
+
// ---------------------------------------------------------------------------
|
|
126
|
+
// Memory upload
|
|
127
|
+
// ---------------------------------------------------------------------------
|
|
128
|
+
export const ScryptParamsSchema = z.object({
|
|
129
|
+
N: z.number(),
|
|
130
|
+
r: z.number(),
|
|
131
|
+
p: z.number(),
|
|
132
|
+
dkLen: z.number(),
|
|
133
|
+
});
|
|
134
|
+
export const MemoryManifestSchema = z.object({
|
|
135
|
+
payloadKind: z.literal("memory"),
|
|
136
|
+
bundleVersion: z.number(),
|
|
137
|
+
hasUserFile: z.boolean(),
|
|
138
|
+
hasMemoryIndex: z.boolean(),
|
|
139
|
+
memoryEntryCount: z.number(),
|
|
140
|
+
latestMemoryDate: z.string().nullable(),
|
|
141
|
+
});
|
|
142
|
+
export const UploadMemoryInputSchema = z.object({
|
|
143
|
+
ciphertext: z.string(),
|
|
144
|
+
cipherAlgorithm: z.literal("AES-256-GCM"),
|
|
145
|
+
cipherNonce: z.string(),
|
|
146
|
+
wrappedBundleKey: z.string(),
|
|
147
|
+
wrapAlgorithm: z.literal("AES-256-GCM"),
|
|
148
|
+
kdfAlgorithm: z.literal("scrypt"),
|
|
149
|
+
kdfSalt: z.string(),
|
|
150
|
+
kdfParams: ScryptParamsSchema,
|
|
151
|
+
manifest: MemoryManifestSchema,
|
|
152
|
+
expectedRemoteMemoryContentHash: z.string().nullable(),
|
|
153
|
+
memoryContentHash: z.string(),
|
|
154
|
+
bundleHash: z.string(),
|
|
155
|
+
});
|
|
156
|
+
export const UploadMemoryResponseSchema = z.object({
|
|
157
|
+
engramId: z.string(),
|
|
158
|
+
version: z.number(),
|
|
159
|
+
memoryContentHash: z.string(),
|
|
160
|
+
bundleHash: z.string(),
|
|
161
|
+
updatedAt: z.string(),
|
|
162
|
+
});
|
|
163
|
+
// ---------------------------------------------------------------------------
|
|
164
|
+
// Memory download
|
|
165
|
+
// ---------------------------------------------------------------------------
|
|
166
|
+
export const DownloadMemoryResponseSchema = z.discriminatedUnion("hasMemory", [
|
|
167
|
+
z.object({
|
|
168
|
+
engramId: z.string(),
|
|
169
|
+
hasMemory: z.literal(true),
|
|
170
|
+
version: z.number(),
|
|
171
|
+
ciphertext: z.string(),
|
|
172
|
+
cipherAlgorithm: z.literal("AES-256-GCM"),
|
|
173
|
+
cipherNonce: z.string(),
|
|
174
|
+
wrappedBundleKey: z.string(),
|
|
175
|
+
wrapAlgorithm: z.literal("AES-256-GCM"),
|
|
176
|
+
kdfAlgorithm: z.literal("scrypt"),
|
|
177
|
+
kdfSalt: z.string(),
|
|
178
|
+
kdfParams: ScryptParamsSchema,
|
|
179
|
+
manifest: MemoryManifestSchema,
|
|
180
|
+
memoryContentHash: z.string(),
|
|
181
|
+
bundleHash: z.string(),
|
|
182
|
+
createdAt: z.string(),
|
|
183
|
+
updatedAt: z.string(),
|
|
184
|
+
}),
|
|
185
|
+
z.object({
|
|
186
|
+
engramId: z.string(),
|
|
187
|
+
hasMemory: z.literal(false),
|
|
188
|
+
}),
|
|
189
|
+
]);
|
|
190
|
+
// ---------------------------------------------------------------------------
|
|
191
|
+
// Error types
|
|
192
|
+
// ---------------------------------------------------------------------------
|
|
193
|
+
export class MikoshiApiError extends Error {
|
|
194
|
+
status;
|
|
195
|
+
code;
|
|
196
|
+
body;
|
|
197
|
+
constructor(status, code, message, body) {
|
|
198
|
+
super(message);
|
|
199
|
+
this.status = status;
|
|
200
|
+
this.code = code;
|
|
201
|
+
this.body = body;
|
|
202
|
+
this.name = "MikoshiApiError";
|
|
203
|
+
}
|
|
204
|
+
get isUnauthorized() { return this.status === 401; }
|
|
205
|
+
get isForbidden() { return this.status === 403; }
|
|
206
|
+
get isNotFound() { return this.status === 404; }
|
|
207
|
+
get isConflict() { return this.status === 409; }
|
|
208
|
+
get isRateLimited() { return this.status === 429; }
|
|
209
|
+
}
|
|
@@ -17,6 +17,12 @@ export interface ShellLaunchOptions {
|
|
|
17
17
|
cwd?: string;
|
|
18
18
|
/** 注入するEngram ID(Shell固有のセットアップに使用) */
|
|
19
19
|
engramId?: string;
|
|
20
|
+
/**
|
|
21
|
+
* true の場合、Engram prompt の injection をスキップする。
|
|
22
|
+
* resume 系操作では前回セッションに injection 済みのため再注入は不要。
|
|
23
|
+
* hook セットアップと RELIC_ENGRAM_ID は skipInjection 時も維持される。
|
|
24
|
+
*/
|
|
25
|
+
skipInjection?: boolean;
|
|
20
26
|
}
|
|
21
27
|
/**
|
|
22
28
|
* ShellLauncher — AI CLIにEngramを注入して起動する抽象ポート
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
export interface KdfParams {
|
|
2
|
+
N: number;
|
|
3
|
+
r: number;
|
|
4
|
+
p: number;
|
|
5
|
+
dkLen: number;
|
|
6
|
+
}
|
|
7
|
+
export interface MemoryManifest {
|
|
8
|
+
payloadKind: "memory";
|
|
9
|
+
bundleVersion: 1;
|
|
10
|
+
hasUserFile: boolean;
|
|
11
|
+
hasMemoryIndex: boolean;
|
|
12
|
+
memoryEntryCount: number;
|
|
13
|
+
latestMemoryDate: string | null;
|
|
14
|
+
}
|
|
15
|
+
/** 暗号化結果 — Mikoshi upload payload にそのまま乗せられる形 */
|
|
16
|
+
export interface EncryptedMemoryBundle {
|
|
17
|
+
ciphertext: string;
|
|
18
|
+
cipherAlgorithm: "AES-256-GCM";
|
|
19
|
+
cipherNonce: string;
|
|
20
|
+
wrappedBundleKey: string;
|
|
21
|
+
wrapAlgorithm: "AES-256-GCM";
|
|
22
|
+
kdfAlgorithm: "scrypt";
|
|
23
|
+
kdfSalt: string;
|
|
24
|
+
kdfParams: KdfParams;
|
|
25
|
+
manifest: MemoryManifest;
|
|
26
|
+
bundleHash: string;
|
|
27
|
+
}
|
|
28
|
+
/** 復号結果 — ファイルパス→コンテンツの Map */
|
|
29
|
+
export type DecryptedMemoryFiles = Record<string, string>;
|
|
30
|
+
/**
|
|
31
|
+
* Memory ファイル群を暗号化して EncryptedMemoryBundle を返す。
|
|
32
|
+
*
|
|
33
|
+
* @param files パス → コンテンツ (例: { "USER.md": "...", "memory/2026-04-03.md": "..." })
|
|
34
|
+
* @param passphrase ユーザーのパスフレーズ
|
|
35
|
+
*/
|
|
36
|
+
export declare function encryptMemoryBundle(files: Record<string, string>, passphrase: string): EncryptedMemoryBundle;
|
|
37
|
+
/**
|
|
38
|
+
* EncryptedMemoryBundle を復号して memory ファイル群を返す。
|
|
39
|
+
*
|
|
40
|
+
* @param bundle Mikoshi からダウンロードした暗号化バンドル
|
|
41
|
+
* @param passphrase ユーザーのパスフレーズ
|
|
42
|
+
* @throws パスフレーズ不一致やデータ破損時に Error
|
|
43
|
+
*/
|
|
44
|
+
export declare function decryptMemoryBundle(bundle: {
|
|
45
|
+
ciphertext: string;
|
|
46
|
+
cipherNonce: string;
|
|
47
|
+
wrappedBundleKey: string;
|
|
48
|
+
kdfSalt: string;
|
|
49
|
+
kdfParams: KdfParams;
|
|
50
|
+
}, passphrase: string): DecryptedMemoryFiles;
|
|
51
|
+
/**
|
|
52
|
+
* TTY から非表示入力でパスフレーズを読む。
|
|
53
|
+
* 入力中は文字が画面に出ない。
|
|
54
|
+
*/
|
|
55
|
+
export declare function readPassphrase(prompt: string): Promise<string>;
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import { createCipheriv, createDecipheriv, createHash, randomBytes, scryptSync, } from "node:crypto";
|
|
2
|
+
// ---------------------------------------------------------------------------
|
|
3
|
+
// Constants
|
|
4
|
+
// ---------------------------------------------------------------------------
|
|
5
|
+
const CIPHER_ALGORITHM = "aes-256-gcm";
|
|
6
|
+
const DEFAULT_KDF_PARAMS = {
|
|
7
|
+
N: 16384,
|
|
8
|
+
r: 8,
|
|
9
|
+
p: 1,
|
|
10
|
+
dkLen: 32,
|
|
11
|
+
};
|
|
12
|
+
const SALT_BYTES = 16;
|
|
13
|
+
const DEK_BYTES = 32;
|
|
14
|
+
const NONCE_BYTES = 12; // AES-256-GCM standard IV
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
// Encrypt
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
/**
|
|
19
|
+
* Memory ファイル群を暗号化して EncryptedMemoryBundle を返す。
|
|
20
|
+
*
|
|
21
|
+
* @param files パス → コンテンツ (例: { "USER.md": "...", "memory/2026-04-03.md": "..." })
|
|
22
|
+
* @param passphrase ユーザーのパスフレーズ
|
|
23
|
+
*/
|
|
24
|
+
export function encryptMemoryBundle(files, passphrase) {
|
|
25
|
+
// 1. scrypt: passphrase → KEK
|
|
26
|
+
const salt = randomBytes(SALT_BYTES);
|
|
27
|
+
const kek = deriveKek(passphrase, salt, DEFAULT_KDF_PARAMS);
|
|
28
|
+
// 2. Random DEK
|
|
29
|
+
const dek = randomBytes(DEK_BYTES);
|
|
30
|
+
// 3. Bundle: files → JSON → UTF-8 bytes
|
|
31
|
+
const plaintext = Buffer.from(JSON.stringify(files), "utf-8");
|
|
32
|
+
// 4. DEK + AES-256-GCM → ciphertext
|
|
33
|
+
const dataNonce = randomBytes(NONCE_BYTES);
|
|
34
|
+
const { encrypted: ciphertext, authTag: dataTag } = aesGcmEncrypt(dek, dataNonce, plaintext);
|
|
35
|
+
// GCM の authTag を ciphertext 末尾に結合 (16 bytes)
|
|
36
|
+
const ciphertextWithTag = Buffer.concat([ciphertext, dataTag]);
|
|
37
|
+
// 5. KEK + AES-256-GCM → wrappedBundleKey (nonce(12) + encrypted + authTag(16))
|
|
38
|
+
const wrapNonce = randomBytes(NONCE_BYTES);
|
|
39
|
+
const { encrypted: wrappedKey, authTag: wrapTag } = aesGcmEncrypt(kek, wrapNonce, dek);
|
|
40
|
+
const wrappedBundleKey = Buffer.concat([wrapNonce, wrappedKey, wrapTag]);
|
|
41
|
+
// 6. Manifest
|
|
42
|
+
const manifest = buildManifest(files);
|
|
43
|
+
// 7. bundleHash = SHA-256(ciphertext with tag)
|
|
44
|
+
const bundleHash = `sha256:${createHash("sha256").update(ciphertextWithTag).digest("hex")}`;
|
|
45
|
+
return {
|
|
46
|
+
ciphertext: ciphertextWithTag.toString("base64"),
|
|
47
|
+
cipherAlgorithm: "AES-256-GCM",
|
|
48
|
+
cipherNonce: dataNonce.toString("base64"),
|
|
49
|
+
wrappedBundleKey: wrappedBundleKey.toString("base64"),
|
|
50
|
+
wrapAlgorithm: "AES-256-GCM",
|
|
51
|
+
kdfAlgorithm: "scrypt",
|
|
52
|
+
kdfSalt: salt.toString("base64"),
|
|
53
|
+
kdfParams: { ...DEFAULT_KDF_PARAMS },
|
|
54
|
+
manifest,
|
|
55
|
+
bundleHash,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
// ---------------------------------------------------------------------------
|
|
59
|
+
// Decrypt
|
|
60
|
+
// ---------------------------------------------------------------------------
|
|
61
|
+
/**
|
|
62
|
+
* EncryptedMemoryBundle を復号して memory ファイル群を返す。
|
|
63
|
+
*
|
|
64
|
+
* @param bundle Mikoshi からダウンロードした暗号化バンドル
|
|
65
|
+
* @param passphrase ユーザーのパスフレーズ
|
|
66
|
+
* @throws パスフレーズ不一致やデータ破損時に Error
|
|
67
|
+
*/
|
|
68
|
+
export function decryptMemoryBundle(bundle, passphrase) {
|
|
69
|
+
// 1. scrypt: passphrase → KEK
|
|
70
|
+
const salt = Buffer.from(bundle.kdfSalt, "base64");
|
|
71
|
+
const kek = deriveKek(passphrase, salt, bundle.kdfParams);
|
|
72
|
+
// 2. KEK → DEK を復号
|
|
73
|
+
const wrappedBuf = Buffer.from(bundle.wrappedBundleKey, "base64");
|
|
74
|
+
const dek = aesGcmDecrypt(kek, wrappedBuf);
|
|
75
|
+
// 3. DEK → plaintext を復号
|
|
76
|
+
const nonce = Buffer.from(bundle.cipherNonce, "base64");
|
|
77
|
+
const ciphertextBuf = Buffer.from(bundle.ciphertext, "base64");
|
|
78
|
+
const plaintext = aesGcmDecryptWithNonce(dek, nonce, ciphertextBuf);
|
|
79
|
+
// 4. JSON parse
|
|
80
|
+
return JSON.parse(plaintext.toString("utf-8"));
|
|
81
|
+
}
|
|
82
|
+
// ---------------------------------------------------------------------------
|
|
83
|
+
// Passphrase input
|
|
84
|
+
// ---------------------------------------------------------------------------
|
|
85
|
+
/**
|
|
86
|
+
* TTY から非表示入力でパスフレーズを読む。
|
|
87
|
+
* 入力中は文字が画面に出ない。
|
|
88
|
+
*/
|
|
89
|
+
export function readPassphrase(prompt) {
|
|
90
|
+
return new Promise((resolve, reject) => {
|
|
91
|
+
if (!process.stdin.isTTY) {
|
|
92
|
+
reject(new Error("Cannot read passphrase: stdin is not a TTY"));
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
process.stdout.write(prompt);
|
|
96
|
+
const raw = process.stdin.setRawMode?.(true);
|
|
97
|
+
if (!raw) {
|
|
98
|
+
reject(new Error("Cannot enable raw mode on stdin"));
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
process.stdin.resume();
|
|
102
|
+
process.stdin.setEncoding("utf-8");
|
|
103
|
+
let input = "";
|
|
104
|
+
const onData = (ch) => {
|
|
105
|
+
const c = ch.toString();
|
|
106
|
+
if (c === "\n" || c === "\r" || c === "\u0004") {
|
|
107
|
+
// Enter or Ctrl+D
|
|
108
|
+
process.stdin.setRawMode?.(false);
|
|
109
|
+
process.stdin.pause();
|
|
110
|
+
process.stdin.removeListener("data", onData);
|
|
111
|
+
process.stdout.write("\n");
|
|
112
|
+
resolve(input);
|
|
113
|
+
}
|
|
114
|
+
else if (c === "\u0003") {
|
|
115
|
+
// Ctrl+C
|
|
116
|
+
process.stdin.setRawMode?.(false);
|
|
117
|
+
process.stdin.pause();
|
|
118
|
+
process.stdin.removeListener("data", onData);
|
|
119
|
+
process.stdout.write("\n");
|
|
120
|
+
reject(new Error("Cancelled"));
|
|
121
|
+
}
|
|
122
|
+
else if (c === "\u007f" || c === "\b") {
|
|
123
|
+
// Backspace
|
|
124
|
+
if (input.length > 0) {
|
|
125
|
+
input = input.slice(0, -1);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
else {
|
|
129
|
+
input += c;
|
|
130
|
+
}
|
|
131
|
+
};
|
|
132
|
+
process.stdin.on("data", onData);
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
// ---------------------------------------------------------------------------
|
|
136
|
+
// Internal helpers
|
|
137
|
+
// ---------------------------------------------------------------------------
|
|
138
|
+
function deriveKek(passphrase, salt, params) {
|
|
139
|
+
return scryptSync(passphrase, salt, params.dkLen, {
|
|
140
|
+
N: params.N,
|
|
141
|
+
r: params.r,
|
|
142
|
+
p: params.p,
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
function aesGcmEncrypt(key, nonce, plaintext) {
|
|
146
|
+
const cipher = createCipheriv(CIPHER_ALGORITHM, key, nonce);
|
|
147
|
+
const encrypted = Buffer.concat([cipher.update(plaintext), cipher.final()]);
|
|
148
|
+
const authTag = cipher.getAuthTag();
|
|
149
|
+
return { encrypted, authTag };
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* wrappedBundleKey 復号用 — nonce は先頭 12 bytes に含まれている想定ではなく、
|
|
153
|
+
* wrappedKey = encrypted(32) + authTag(16) = 48 bytes の固定構造。
|
|
154
|
+
* nonce は wrap 時にランダム生成したものだが、upload payload には cipherNonce しかない。
|
|
155
|
+
*
|
|
156
|
+
* → wrappedBundleKey は nonce を自己完結させる必要がある。
|
|
157
|
+
* 構造を nonce(12) + encrypted + authTag(16) に変更する。
|
|
158
|
+
*/
|
|
159
|
+
function aesGcmDecrypt(key, data) {
|
|
160
|
+
// data = nonce(12) + encrypted + authTag(16)
|
|
161
|
+
const nonce = data.subarray(0, NONCE_BYTES);
|
|
162
|
+
const authTag = data.subarray(data.length - 16);
|
|
163
|
+
const encrypted = data.subarray(NONCE_BYTES, data.length - 16);
|
|
164
|
+
const decipher = createDecipheriv(CIPHER_ALGORITHM, key, nonce);
|
|
165
|
+
decipher.setAuthTag(authTag);
|
|
166
|
+
return Buffer.concat([decipher.update(encrypted), decipher.final()]);
|
|
167
|
+
}
|
|
168
|
+
function aesGcmDecryptWithNonce(key, nonce, data) {
|
|
169
|
+
// data = encrypted + authTag(16)
|
|
170
|
+
const authTag = data.subarray(data.length - 16);
|
|
171
|
+
const encrypted = data.subarray(0, data.length - 16);
|
|
172
|
+
const decipher = createDecipheriv(CIPHER_ALGORITHM, key, nonce);
|
|
173
|
+
decipher.setAuthTag(authTag);
|
|
174
|
+
return Buffer.concat([decipher.update(encrypted), decipher.final()]);
|
|
175
|
+
}
|
|
176
|
+
function buildManifest(files) {
|
|
177
|
+
const memoryEntryPaths = Object.keys(files).filter((p) => p.startsWith("memory/"));
|
|
178
|
+
const dates = memoryEntryPaths
|
|
179
|
+
.map((p) => p.replace("memory/", "").replace(".md", ""))
|
|
180
|
+
.sort();
|
|
181
|
+
return {
|
|
182
|
+
payloadKind: "memory",
|
|
183
|
+
bundleVersion: 1,
|
|
184
|
+
hasUserFile: "USER.md" in files,
|
|
185
|
+
hasMemoryIndex: "MEMORY.md" in files,
|
|
186
|
+
memoryEntryCount: memoryEntryPaths.length,
|
|
187
|
+
latestMemoryDate: dates.length > 0 ? dates[dates.length - 1] : null,
|
|
188
|
+
};
|
|
189
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { EngramFiles } from "../entities/engram.js";
|
|
2
|
+
/**
|
|
3
|
+
* Memory ファイルエントリ
|
|
4
|
+
* path はソート用の相対パス ("USER.md", "MEMORY.md", "memory/2026-04-03.md" など)
|
|
5
|
+
*/
|
|
6
|
+
export interface MemoryFileEntry {
|
|
7
|
+
path: string;
|
|
8
|
+
content: string;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Memory content hash canonicalization — mikoshi-sync.md 準拠
|
|
12
|
+
*
|
|
13
|
+
* 正規化ルール:
|
|
14
|
+
* - UTF-8 BOM 除去
|
|
15
|
+
* - 改行を \n に統一
|
|
16
|
+
* - 末尾 \n は除去しない(persona と異な���)
|
|
17
|
+
*
|
|
18
|
+
* Canonical payload:
|
|
19
|
+
* header \n ( path \n byteLen \n content \n )*
|
|
20
|
+
* ファイルはパス昇順でソート
|
|
21
|
+
*
|
|
22
|
+
* 出力: "sha256:<64 lowercase hex>"
|
|
23
|
+
* 対象ファイルが0件なら null
|
|
24
|
+
*/
|
|
25
|
+
export declare function computeMemoryContentHash(files: MemoryFileEntry[]): string | null;
|
|
26
|
+
/**
|
|
27
|
+
* EngramFiles から memory 系ファイルを MemoryFileEntry[] に変換。
|
|
28
|
+
* 対象: USER.md, MEMORY.md, memory/*.md
|
|
29
|
+
*/
|
|
30
|
+
export declare function collectMemoryFiles(files: EngramFiles): MemoryFileEntry[];
|
|
31
|
+
/**
|
|
32
|
+
* MemoryFileEntry[] を { "USER.md": "...", "memory/2026-04-03.md": "..." } に変換。
|
|
33
|
+
* 暗号化バンドルの入力用。
|
|
34
|
+
*/
|
|
35
|
+
export declare function memoryFilesToRecord(entries: MemoryFileEntry[]): Record<string, string>;
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { normalizeText } from "./normalize.js";
|
|
3
|
+
const HEADER = "mikoshi.memory.v1";
|
|
4
|
+
/**
|
|
5
|
+
* Memory content hash canonicalization — mikoshi-sync.md 準拠
|
|
6
|
+
*
|
|
7
|
+
* 正規化ルール:
|
|
8
|
+
* - UTF-8 BOM 除去
|
|
9
|
+
* - 改行を \n に統一
|
|
10
|
+
* - 末尾 \n は除去しない(persona と異な���)
|
|
11
|
+
*
|
|
12
|
+
* Canonical payload:
|
|
13
|
+
* header \n ( path \n byteLen \n content \n )*
|
|
14
|
+
* ファイルはパス昇順でソート
|
|
15
|
+
*
|
|
16
|
+
* 出力: "sha256:<64 lowercase hex>"
|
|
17
|
+
* 対象ファイルが0件なら null
|
|
18
|
+
*/
|
|
19
|
+
export function computeMemoryContentHash(files) {
|
|
20
|
+
if (files.length === 0)
|
|
21
|
+
return null;
|
|
22
|
+
// パス昇順ソート
|
|
23
|
+
const sorted = [...files].sort((a, b) => a.path.localeCompare(b.path));
|
|
24
|
+
const parts = [HEADER];
|
|
25
|
+
for (const file of sorted) {
|
|
26
|
+
const norm = normalizeText(file.content);
|
|
27
|
+
const byteLen = Buffer.byteLength(norm, "utf-8");
|
|
28
|
+
parts.push(file.path, String(byteLen), norm);
|
|
29
|
+
}
|
|
30
|
+
const payload = parts.join("\n");
|
|
31
|
+
const hex = createHash("sha256").update(payload, "utf-8").digest("hex");
|
|
32
|
+
return `sha256:${hex}`;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* EngramFiles から memory 系ファイルを MemoryFileEntry[] に変換。
|
|
36
|
+
* 対象: USER.md, MEMORY.md, memory/*.md
|
|
37
|
+
*/
|
|
38
|
+
export function collectMemoryFiles(files) {
|
|
39
|
+
const entries = [];
|
|
40
|
+
if (files.user)
|
|
41
|
+
entries.push({ path: "USER.md", content: files.user });
|
|
42
|
+
if (files.memory)
|
|
43
|
+
entries.push({ path: "MEMORY.md", content: files.memory });
|
|
44
|
+
if (files.memoryEntries) {
|
|
45
|
+
for (const [date, content] of Object.entries(files.memoryEntries)) {
|
|
46
|
+
entries.push({ path: `memory/${date}.md`, content });
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return entries;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* MemoryFileEntry[] を { "USER.md": "...", "memory/2026-04-03.md": "..." } に変換。
|
|
53
|
+
* 暗号化バンドルの入力用。
|
|
54
|
+
*/
|
|
55
|
+
export function memoryFilesToRecord(entries) {
|
|
56
|
+
const record = {};
|
|
57
|
+
for (const e of entries) {
|
|
58
|
+
record[e.path] = e.content;
|
|
59
|
+
}
|
|
60
|
+
return record;
|
|
61
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { EngramFiles } from "../entities/engram.js";
|
|
2
|
+
export interface MemoryRecordDiff {
|
|
3
|
+
added: string[];
|
|
4
|
+
changed: string[];
|
|
5
|
+
removed: string[];
|
|
6
|
+
}
|
|
7
|
+
export interface MergeMemoryRecordsResult {
|
|
8
|
+
merged: Record<string, string>;
|
|
9
|
+
changedPaths: string[];
|
|
10
|
+
}
|
|
11
|
+
export declare function computeMemoryRecordDiff(local: Record<string, string>, remote: Record<string, string>): MemoryRecordDiff;
|
|
12
|
+
export declare function mergeMemoryRecords(local: Record<string, string>, remote: Record<string, string>): MergeMemoryRecordsResult;
|
|
13
|
+
export declare function applyMemoryRecordToFiles(files: EngramFiles, record: Record<string, string>): EngramFiles;
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { normalizeText } from "./normalize.js";
|
|
2
|
+
export function computeMemoryRecordDiff(local, remote) {
|
|
3
|
+
const allPaths = new Set([...Object.keys(local), ...Object.keys(remote)]);
|
|
4
|
+
const added = [];
|
|
5
|
+
const changed = [];
|
|
6
|
+
const removed = [];
|
|
7
|
+
for (const path of [...allPaths].sort()) {
|
|
8
|
+
const inLocal = path in local;
|
|
9
|
+
const inRemote = path in remote;
|
|
10
|
+
if (inRemote && !inLocal) {
|
|
11
|
+
added.push(path);
|
|
12
|
+
}
|
|
13
|
+
else if (inLocal && !inRemote) {
|
|
14
|
+
removed.push(path);
|
|
15
|
+
}
|
|
16
|
+
else if (inLocal && inRemote && local[path] !== remote[path]) {
|
|
17
|
+
changed.push(path);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
return { added, changed, removed };
|
|
21
|
+
}
|
|
22
|
+
export function mergeMemoryRecords(local, remote) {
|
|
23
|
+
const allPaths = new Set([...Object.keys(local), ...Object.keys(remote)]);
|
|
24
|
+
const merged = {};
|
|
25
|
+
const changedPaths = [];
|
|
26
|
+
for (const path of [...allPaths].sort()) {
|
|
27
|
+
const localContent = local[path];
|
|
28
|
+
const remoteContent = remote[path];
|
|
29
|
+
const mergedContent = mergeTextMonotonic(localContent, remoteContent);
|
|
30
|
+
if (mergedContent === undefined)
|
|
31
|
+
continue;
|
|
32
|
+
merged[path] = mergedContent;
|
|
33
|
+
if (mergedContent !== localContent || mergedContent !== remoteContent) {
|
|
34
|
+
changedPaths.push(path);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return { merged, changedPaths };
|
|
38
|
+
}
|
|
39
|
+
export function applyMemoryRecordToFiles(files, record) {
|
|
40
|
+
const updatedFiles = {
|
|
41
|
+
...files,
|
|
42
|
+
user: record["USER.md"] ?? undefined,
|
|
43
|
+
memory: record["MEMORY.md"] ?? undefined,
|
|
44
|
+
};
|
|
45
|
+
const memoryEntries = {};
|
|
46
|
+
for (const [path, content] of Object.entries(record)) {
|
|
47
|
+
if (path.startsWith("memory/") && path.endsWith(".md")) {
|
|
48
|
+
const date = path.replace("memory/", "").replace(".md", "");
|
|
49
|
+
memoryEntries[date] = content;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
updatedFiles.memoryEntries = Object.keys(memoryEntries).length > 0 ? memoryEntries : undefined;
|
|
53
|
+
return updatedFiles;
|
|
54
|
+
}
|
|
55
|
+
function mergeTextMonotonic(local, remote) {
|
|
56
|
+
if (local === undefined)
|
|
57
|
+
return remote;
|
|
58
|
+
if (remote === undefined)
|
|
59
|
+
return local;
|
|
60
|
+
if (local === remote)
|
|
61
|
+
return local;
|
|
62
|
+
const blocks = [
|
|
63
|
+
...splitBlocks(local),
|
|
64
|
+
...splitBlocks(remote),
|
|
65
|
+
];
|
|
66
|
+
const seen = new Set();
|
|
67
|
+
const mergedBlocks = [];
|
|
68
|
+
for (const block of blocks) {
|
|
69
|
+
if (!seen.has(block)) {
|
|
70
|
+
seen.add(block);
|
|
71
|
+
mergedBlocks.push(block);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return mergedBlocks.join("\n\n");
|
|
75
|
+
}
|
|
76
|
+
function splitBlocks(content) {
|
|
77
|
+
const normalized = normalizeText(content).trim();
|
|
78
|
+
if (!normalized)
|
|
79
|
+
return [];
|
|
80
|
+
return normalized
|
|
81
|
+
.split(/\n{2,}/)
|
|
82
|
+
.map((block) => block.trim())
|
|
83
|
+
.filter((block) => block.length > 0);
|
|
84
|
+
}
|