@ectplsm/relic 0.3.2 → 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.
Files changed (60) hide show
  1. package/LICENCE.md +1 -1
  2. package/README.md +19 -12
  3. package/dist/adapters/mikoshi/client.d.ts +21 -0
  4. package/dist/adapters/mikoshi/client.js +84 -0
  5. package/dist/core/ports/index.d.ts +1 -0
  6. package/dist/core/ports/mikoshi.d.ts +937 -0
  7. package/dist/core/ports/mikoshi.js +209 -0
  8. package/dist/core/sync/crypto.d.ts +55 -0
  9. package/dist/core/sync/crypto.js +189 -0
  10. package/dist/core/sync/memory-hash.d.ts +35 -0
  11. package/dist/core/sync/memory-hash.js +61 -0
  12. package/dist/core/sync/memory-merge.d.ts +13 -0
  13. package/dist/core/sync/memory-merge.js +84 -0
  14. package/dist/core/sync/normalize.d.ts +10 -0
  15. package/dist/core/sync/normalize.js +21 -0
  16. package/dist/core/sync/persona-hash.d.ts +15 -0
  17. package/dist/core/sync/persona-hash.js +36 -0
  18. package/dist/core/usecases/claw-pull.d.ts +44 -0
  19. package/dist/core/usecases/claw-pull.js +117 -0
  20. package/dist/core/usecases/extract.d.ts +0 -1
  21. package/dist/core/usecases/extract.js +13 -27
  22. package/dist/core/usecases/index.d.ts +8 -0
  23. package/dist/core/usecases/index.js +8 -0
  24. package/dist/core/usecases/inject.d.ts +1 -0
  25. package/dist/core/usecases/inject.js +2 -0
  26. package/dist/core/usecases/mikoshi-clone.d.ts +29 -0
  27. package/dist/core/usecases/mikoshi-clone.js +94 -0
  28. package/dist/core/usecases/mikoshi-download.d.ts +29 -0
  29. package/dist/core/usecases/mikoshi-download.js +94 -0
  30. package/dist/core/usecases/mikoshi-memory-pull.d.ts +38 -0
  31. package/dist/core/usecases/mikoshi-memory-pull.js +142 -0
  32. package/dist/core/usecases/mikoshi-memory-push.d.ts +34 -0
  33. package/dist/core/usecases/mikoshi-memory-push.js +105 -0
  34. package/dist/core/usecases/mikoshi-memory-sync.d.ts +36 -0
  35. package/dist/core/usecases/mikoshi-memory-sync.js +175 -0
  36. package/dist/core/usecases/mikoshi-pull.d.ts +44 -0
  37. package/dist/core/usecases/mikoshi-pull.js +124 -0
  38. package/dist/core/usecases/mikoshi-push.d.ts +39 -0
  39. package/dist/core/usecases/mikoshi-push.js +127 -0
  40. package/dist/core/usecases/mikoshi-status.d.ts +37 -0
  41. package/dist/core/usecases/mikoshi-status.js +92 -0
  42. package/dist/core/usecases/sync.d.ts +0 -5
  43. package/dist/core/usecases/sync.js +23 -61
  44. package/dist/interfaces/cli/commands/claw.js +134 -85
  45. package/dist/interfaces/cli/commands/config.js +59 -0
  46. package/dist/interfaces/cli/commands/create.js +17 -16
  47. package/dist/interfaces/cli/commands/init.js +11 -10
  48. package/dist/interfaces/cli/commands/list.js +9 -7
  49. package/dist/interfaces/cli/commands/migrate.js +8 -7
  50. package/dist/interfaces/cli/commands/mikoshi.d.ts +2 -0
  51. package/dist/interfaces/cli/commands/mikoshi.js +565 -0
  52. package/dist/interfaces/cli/commands/refresh-samples.js +10 -9
  53. package/dist/interfaces/cli/index.js +2 -0
  54. package/dist/interfaces/cli/output.d.ts +5 -0
  55. package/dist/interfaces/cli/output.js +16 -0
  56. package/dist/interfaces/cli/spinner.d.ts +13 -0
  57. package/dist/interfaces/cli/spinner.js +47 -0
  58. package/dist/shared/config.d.ts +23 -3
  59. package/dist/shared/config.js +30 -2
  60. 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
+ }
@@ -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
+ }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * テキスト正規化 — mikoshi-sync.md 共通ルール
3
+ *
4
+ * - UTF-8 BOM 除去
5
+ * - 改行を \n に統一
6
+ * - stripTrailingNewlines: true なら末尾 \n を除去(persona 用)
7
+ */
8
+ export declare function normalizeText(text: string, opts?: {
9
+ stripTrailingNewlines?: boolean;
10
+ }): string;
@@ -0,0 +1,21 @@
1
+ /**
2
+ * テキスト正規化 — mikoshi-sync.md 共通ルール
3
+ *
4
+ * - UTF-8 BOM 除去
5
+ * - 改行を \n に統一
6
+ * - stripTrailingNewlines: true なら末尾 \n を除去(persona 用)
7
+ */
8
+ export function normalizeText(text, opts) {
9
+ let s = text;
10
+ // strip leading BOM
11
+ if (s.charCodeAt(0) === 0xfeff) {
12
+ s = s.slice(1);
13
+ }
14
+ // normalize line endings to \n
15
+ s = s.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
16
+ // strip trailing \n (persona only)
17
+ if (opts?.stripTrailingNewlines) {
18
+ s = s.replace(/\n+$/, "");
19
+ }
20
+ return s;
21
+ }