@nuzo/memory-core 0.1.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/LICENSE +176 -0
- package/README.md +12 -0
- package/dist/errors.d.ts +6 -0
- package/dist/errors.js +15 -0
- package/dist/export-format.d.ts +2 -0
- package/dist/export-format.js +58 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +10 -0
- package/dist/policy.d.ts +12 -0
- package/dist/policy.js +63 -0
- package/dist/ports.d.ts +45 -0
- package/dist/ports.js +1 -0
- package/dist/runtime.d.ts +8 -0
- package/dist/runtime.js +14 -0
- package/dist/secrets.d.ts +4 -0
- package/dist/secrets.js +64 -0
- package/dist/service.d.ts +23 -0
- package/dist/service.js +455 -0
- package/dist/sqlite/adapter.d.ts +26 -0
- package/dist/sqlite/adapter.js +241 -0
- package/dist/sqlite/schema.d.ts +3 -0
- package/dist/sqlite/schema.js +54 -0
- package/dist/types.d.ts +114 -0
- package/dist/types.js +7 -0
- package/package.json +45 -0
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
import Database from "better-sqlite3";
|
|
2
|
+
import { NuzoMemoryError } from "../errors.js";
|
|
3
|
+
import { migrate } from "./schema.js";
|
|
4
|
+
export class SQLiteMemoryDatabase {
|
|
5
|
+
database;
|
|
6
|
+
transactionQueue = Promise.resolve();
|
|
7
|
+
constructor(options) {
|
|
8
|
+
this.database = new Database(options.path);
|
|
9
|
+
try {
|
|
10
|
+
migrate(this.database);
|
|
11
|
+
}
|
|
12
|
+
catch (error) {
|
|
13
|
+
this.database.close();
|
|
14
|
+
throw error;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
close() {
|
|
18
|
+
this.database.close();
|
|
19
|
+
}
|
|
20
|
+
getSchemaVersion() {
|
|
21
|
+
return this.database.pragma("user_version", { simple: true });
|
|
22
|
+
}
|
|
23
|
+
async run(operation) {
|
|
24
|
+
const previous = this.transactionQueue;
|
|
25
|
+
let release;
|
|
26
|
+
this.transactionQueue = new Promise((resolve) => {
|
|
27
|
+
release = resolve;
|
|
28
|
+
});
|
|
29
|
+
await previous;
|
|
30
|
+
let started = false;
|
|
31
|
+
try {
|
|
32
|
+
this.database.exec("BEGIN IMMEDIATE");
|
|
33
|
+
started = true;
|
|
34
|
+
const result = await operation();
|
|
35
|
+
this.database.exec("COMMIT");
|
|
36
|
+
return result;
|
|
37
|
+
}
|
|
38
|
+
catch (error) {
|
|
39
|
+
if (started && this.database.inTransaction) {
|
|
40
|
+
this.database.exec("ROLLBACK");
|
|
41
|
+
}
|
|
42
|
+
throw error;
|
|
43
|
+
}
|
|
44
|
+
finally {
|
|
45
|
+
release();
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
async create(memory) {
|
|
49
|
+
this.database
|
|
50
|
+
.prepare(`
|
|
51
|
+
INSERT INTO memories (
|
|
52
|
+
id, scope, kind, content, tags, source, confidence,
|
|
53
|
+
created_at, updated_at, last_used_at, archived_at
|
|
54
|
+
)
|
|
55
|
+
VALUES (
|
|
56
|
+
@id, @scope, @kind, @content, @tags, @source, @confidence,
|
|
57
|
+
@created_at, @updated_at, @last_used_at, @archived_at
|
|
58
|
+
)
|
|
59
|
+
`)
|
|
60
|
+
.run(toMemoryRow(memory));
|
|
61
|
+
}
|
|
62
|
+
async update(memory) {
|
|
63
|
+
this.database
|
|
64
|
+
.prepare(`
|
|
65
|
+
UPDATE memories
|
|
66
|
+
SET scope = @scope,
|
|
67
|
+
kind = @kind,
|
|
68
|
+
content = @content,
|
|
69
|
+
tags = @tags,
|
|
70
|
+
source = @source,
|
|
71
|
+
confidence = @confidence,
|
|
72
|
+
created_at = @created_at,
|
|
73
|
+
updated_at = @updated_at,
|
|
74
|
+
last_used_at = @last_used_at,
|
|
75
|
+
archived_at = @archived_at
|
|
76
|
+
WHERE id = @id
|
|
77
|
+
`)
|
|
78
|
+
.run(toMemoryRow(memory));
|
|
79
|
+
}
|
|
80
|
+
async findById(id) {
|
|
81
|
+
const row = this.database.prepare("SELECT * FROM memories WHERE id = ?").get(id);
|
|
82
|
+
return row ? fromMemoryRow(row) : null;
|
|
83
|
+
}
|
|
84
|
+
async archive(id, archivedAt) {
|
|
85
|
+
this.database
|
|
86
|
+
.prepare("UPDATE memories SET archived_at = @archived_at, updated_at = @archived_at WHERE id = @id")
|
|
87
|
+
.run({ id, archived_at: archivedAt.toISOString() });
|
|
88
|
+
}
|
|
89
|
+
async delete(id) {
|
|
90
|
+
this.database.prepare("DELETE FROM memories WHERE id = ?").run(id);
|
|
91
|
+
}
|
|
92
|
+
async index(memory) {
|
|
93
|
+
this.database.prepare("DELETE FROM memories_fts WHERE id = ?").run(memory.id);
|
|
94
|
+
if (memory.archivedAt) {
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
this.database
|
|
98
|
+
.prepare("INSERT INTO memories_fts (id, scope, content, tags) VALUES (?, ?, ?, ?)")
|
|
99
|
+
.run(memory.id, memory.scope, memory.content, memory.tags.join(" "));
|
|
100
|
+
}
|
|
101
|
+
async remove(memoryId) {
|
|
102
|
+
this.database.prepare("DELETE FROM memories_fts WHERE id = ?").run(memoryId);
|
|
103
|
+
}
|
|
104
|
+
async search(input) {
|
|
105
|
+
const limit = input.limit ?? 8;
|
|
106
|
+
const query = toFtsQuery(input.query);
|
|
107
|
+
if (!query) {
|
|
108
|
+
return [];
|
|
109
|
+
}
|
|
110
|
+
const scopeClause = input.includeGlobal === true
|
|
111
|
+
? "AND (m.scope = @scope OR m.scope = 'user:default')"
|
|
112
|
+
: "AND m.scope = @scope";
|
|
113
|
+
const rows = this.database
|
|
114
|
+
.prepare(`
|
|
115
|
+
SELECT m.*, bm25(memories_fts) * -1 AS score
|
|
116
|
+
FROM memories_fts
|
|
117
|
+
JOIN memories m ON m.id = memories_fts.id
|
|
118
|
+
WHERE memories_fts MATCH @query
|
|
119
|
+
AND m.archived_at IS NULL
|
|
120
|
+
${scopeClause}
|
|
121
|
+
ORDER BY bm25(memories_fts)
|
|
122
|
+
LIMIT @limit
|
|
123
|
+
`)
|
|
124
|
+
.all({ query, scope: input.scope, limit });
|
|
125
|
+
return rows.map((row) => ({
|
|
126
|
+
memory: fromMemoryRow(row),
|
|
127
|
+
score: row.score,
|
|
128
|
+
reason: `Matched FTS query: ${query}`,
|
|
129
|
+
}));
|
|
130
|
+
}
|
|
131
|
+
async append(event) {
|
|
132
|
+
this.database
|
|
133
|
+
.prepare(`
|
|
134
|
+
INSERT INTO memory_events (id, memory_id, event_type, actor, payload, created_at)
|
|
135
|
+
VALUES (@id, @memory_id, @event_type, @actor, @payload, @created_at)
|
|
136
|
+
`)
|
|
137
|
+
.run(toEventRow(event));
|
|
138
|
+
}
|
|
139
|
+
async list(memoryIdOrFilter) {
|
|
140
|
+
if (typeof memoryIdOrFilter !== "string") {
|
|
141
|
+
return this.listMemories(memoryIdOrFilter);
|
|
142
|
+
}
|
|
143
|
+
const rows = this.database
|
|
144
|
+
.prepare("SELECT * FROM memory_events WHERE memory_id = ? ORDER BY created_at ASC")
|
|
145
|
+
.all(memoryIdOrFilter);
|
|
146
|
+
return rows.map(fromEventRow);
|
|
147
|
+
}
|
|
148
|
+
async listMemories(filter) {
|
|
149
|
+
const where = [];
|
|
150
|
+
const params = {};
|
|
151
|
+
if (filter.scope) {
|
|
152
|
+
where.push("scope = @scope");
|
|
153
|
+
params.scope = filter.scope;
|
|
154
|
+
}
|
|
155
|
+
if (filter.includeArchived !== true) {
|
|
156
|
+
where.push("archived_at IS NULL");
|
|
157
|
+
}
|
|
158
|
+
const sql = `
|
|
159
|
+
SELECT *
|
|
160
|
+
FROM memories
|
|
161
|
+
${where.length > 0 ? `WHERE ${where.join(" AND ")}` : ""}
|
|
162
|
+
ORDER BY updated_at DESC, created_at DESC
|
|
163
|
+
`;
|
|
164
|
+
const rows = this.database.prepare(sql).all(params);
|
|
165
|
+
return rows
|
|
166
|
+
.map(fromMemoryRow)
|
|
167
|
+
.filter((memory) => !filter.tags || filter.tags.every((tag) => memory.tags.includes(tag)));
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
function toMemoryRow(memory) {
|
|
171
|
+
return {
|
|
172
|
+
id: memory.id,
|
|
173
|
+
scope: memory.scope,
|
|
174
|
+
kind: memory.kind,
|
|
175
|
+
content: memory.content,
|
|
176
|
+
tags: JSON.stringify(memory.tags),
|
|
177
|
+
source: memory.source,
|
|
178
|
+
confidence: memory.confidence,
|
|
179
|
+
created_at: memory.createdAt.toISOString(),
|
|
180
|
+
updated_at: memory.updatedAt.toISOString(),
|
|
181
|
+
last_used_at: memory.lastUsedAt?.toISOString() ?? null,
|
|
182
|
+
archived_at: memory.archivedAt?.toISOString() ?? null,
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
function fromMemoryRow(row) {
|
|
186
|
+
return {
|
|
187
|
+
id: row.id,
|
|
188
|
+
scope: row.scope,
|
|
189
|
+
kind: row.kind,
|
|
190
|
+
content: row.content,
|
|
191
|
+
tags: parseTags(row.tags),
|
|
192
|
+
source: row.source,
|
|
193
|
+
confidence: row.confidence,
|
|
194
|
+
createdAt: new Date(row.created_at),
|
|
195
|
+
updatedAt: new Date(row.updated_at),
|
|
196
|
+
lastUsedAt: row.last_used_at ? new Date(row.last_used_at) : null,
|
|
197
|
+
archivedAt: row.archived_at ? new Date(row.archived_at) : null,
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
function toEventRow(event) {
|
|
201
|
+
return {
|
|
202
|
+
id: event.id,
|
|
203
|
+
memory_id: event.memoryId,
|
|
204
|
+
event_type: event.eventType,
|
|
205
|
+
actor: event.actor,
|
|
206
|
+
payload: JSON.stringify(event.payload),
|
|
207
|
+
created_at: event.createdAt.toISOString(),
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
function fromEventRow(row) {
|
|
211
|
+
return {
|
|
212
|
+
id: row.id,
|
|
213
|
+
memoryId: row.memory_id,
|
|
214
|
+
eventType: row.event_type,
|
|
215
|
+
actor: row.actor,
|
|
216
|
+
payload: parsePayload(row.payload),
|
|
217
|
+
createdAt: new Date(row.created_at),
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
function parseTags(value) {
|
|
221
|
+
const parsed = JSON.parse(value);
|
|
222
|
+
if (!Array.isArray(parsed) || !parsed.every((tag) => typeof tag === "string")) {
|
|
223
|
+
throw new NuzoMemoryError("MEMORY_TAGS_INVALID", "Stored memory tags are invalid.");
|
|
224
|
+
}
|
|
225
|
+
return parsed;
|
|
226
|
+
}
|
|
227
|
+
function parsePayload(value) {
|
|
228
|
+
const parsed = JSON.parse(value);
|
|
229
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
230
|
+
throw new NuzoMemoryError("MEMORY_EVENT_PAYLOAD_INVALID", "Stored memory event payload is invalid.");
|
|
231
|
+
}
|
|
232
|
+
return parsed;
|
|
233
|
+
}
|
|
234
|
+
function toFtsQuery(query) {
|
|
235
|
+
return query
|
|
236
|
+
.trim()
|
|
237
|
+
.split(/\W+/)
|
|
238
|
+
.filter(Boolean)
|
|
239
|
+
.map((term) => `"${term.replaceAll('"', '""')}"`)
|
|
240
|
+
.join(" OR ");
|
|
241
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { NuzoMemoryError } from "../errors.js";
|
|
2
|
+
export const schemaVersion = 1;
|
|
3
|
+
export function migrate(database) {
|
|
4
|
+
database.pragma("journal_mode = WAL");
|
|
5
|
+
database.pragma("foreign_keys = ON");
|
|
6
|
+
const currentVersion = database.pragma("user_version", { simple: true });
|
|
7
|
+
if (currentVersion > schemaVersion) {
|
|
8
|
+
throw new NuzoMemoryError("MEMORY_SCHEMA_UNSUPPORTED", "SQLite memory schema is newer than this Nuzo version supports.", {
|
|
9
|
+
currentVersion,
|
|
10
|
+
supportedVersion: schemaVersion,
|
|
11
|
+
});
|
|
12
|
+
}
|
|
13
|
+
if (currentVersion < 1) {
|
|
14
|
+
migrateToV1(database);
|
|
15
|
+
database.pragma(`user_version = ${schemaVersion}`);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
function migrateToV1(database) {
|
|
19
|
+
database.exec(`
|
|
20
|
+
CREATE TABLE IF NOT EXISTS memories (
|
|
21
|
+
id TEXT PRIMARY KEY,
|
|
22
|
+
scope TEXT NOT NULL,
|
|
23
|
+
kind TEXT NOT NULL,
|
|
24
|
+
content TEXT NOT NULL,
|
|
25
|
+
tags TEXT NOT NULL DEFAULT '[]',
|
|
26
|
+
source TEXT NOT NULL,
|
|
27
|
+
confidence REAL NOT NULL DEFAULT 1.0,
|
|
28
|
+
created_at TEXT NOT NULL,
|
|
29
|
+
updated_at TEXT NOT NULL,
|
|
30
|
+
last_used_at TEXT,
|
|
31
|
+
archived_at TEXT
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
CREATE TABLE IF NOT EXISTS memory_events (
|
|
35
|
+
id TEXT PRIMARY KEY,
|
|
36
|
+
memory_id TEXT,
|
|
37
|
+
event_type TEXT NOT NULL,
|
|
38
|
+
actor TEXT NOT NULL,
|
|
39
|
+
payload TEXT NOT NULL,
|
|
40
|
+
created_at TEXT NOT NULL
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS memories_fts USING fts5(
|
|
44
|
+
id UNINDEXED,
|
|
45
|
+
scope UNINDEXED,
|
|
46
|
+
content,
|
|
47
|
+
tags
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
CREATE INDEX IF NOT EXISTS idx_memories_scope ON memories(scope);
|
|
51
|
+
CREATE INDEX IF NOT EXISTS idx_memories_archived_at ON memories(archived_at);
|
|
52
|
+
CREATE INDEX IF NOT EXISTS idx_memory_events_memory_id ON memory_events(memory_id);
|
|
53
|
+
`);
|
|
54
|
+
}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
export type MemoryKind = "preference" | "project_decision" | "fact" | "instruction" | "note";
|
|
2
|
+
export declare const memoryKinds: readonly ["preference", "project_decision", "fact", "instruction", "note"];
|
|
3
|
+
export type MemoryScope = `user:${string}` | `project:${string}` | `agent:${string}` | `team:${string}`;
|
|
4
|
+
export interface MemoryRecord {
|
|
5
|
+
id: string;
|
|
6
|
+
scope: MemoryScope;
|
|
7
|
+
kind: MemoryKind;
|
|
8
|
+
content: string;
|
|
9
|
+
tags: string[];
|
|
10
|
+
source: string;
|
|
11
|
+
confidence: number;
|
|
12
|
+
createdAt: Date;
|
|
13
|
+
updatedAt: Date;
|
|
14
|
+
lastUsedAt: Date | null;
|
|
15
|
+
archivedAt: Date | null;
|
|
16
|
+
}
|
|
17
|
+
export interface MemoryEvent {
|
|
18
|
+
id: string;
|
|
19
|
+
memoryId: string | null;
|
|
20
|
+
eventType: "memory.created" | "memory.updated" | "memory.archived" | "memory.deleted" | "memory.imported" | "memory.exported" | "memory.recalled";
|
|
21
|
+
actor: string;
|
|
22
|
+
payload: Record<string, unknown>;
|
|
23
|
+
createdAt: Date;
|
|
24
|
+
}
|
|
25
|
+
export interface RememberMemoryInput {
|
|
26
|
+
content: string;
|
|
27
|
+
kind: MemoryKind;
|
|
28
|
+
scope: MemoryScope;
|
|
29
|
+
tags?: string[];
|
|
30
|
+
source: string;
|
|
31
|
+
confidence?: number;
|
|
32
|
+
}
|
|
33
|
+
export interface RecallMemoriesInput {
|
|
34
|
+
query: string;
|
|
35
|
+
scope: MemoryScope;
|
|
36
|
+
limit?: number;
|
|
37
|
+
includeGlobal?: boolean;
|
|
38
|
+
recordUsage?: boolean;
|
|
39
|
+
}
|
|
40
|
+
export interface ListMemoriesInput {
|
|
41
|
+
scope?: MemoryScope;
|
|
42
|
+
tags?: string[];
|
|
43
|
+
includeArchived?: boolean;
|
|
44
|
+
}
|
|
45
|
+
export interface ForgetMemoryInput {
|
|
46
|
+
id: string;
|
|
47
|
+
mode?: "archive" | "delete";
|
|
48
|
+
confirm?: boolean;
|
|
49
|
+
actor: string;
|
|
50
|
+
reason?: string;
|
|
51
|
+
}
|
|
52
|
+
export interface ForgetMemoriesInput {
|
|
53
|
+
scope?: MemoryScope;
|
|
54
|
+
tags?: string[];
|
|
55
|
+
all?: boolean;
|
|
56
|
+
mode?: "archive" | "delete";
|
|
57
|
+
confirm?: boolean;
|
|
58
|
+
dryRun?: boolean;
|
|
59
|
+
actor: string;
|
|
60
|
+
reason?: string;
|
|
61
|
+
}
|
|
62
|
+
export interface ForgetMemoriesResult {
|
|
63
|
+
matched: number;
|
|
64
|
+
affected: number;
|
|
65
|
+
mode: "archive" | "delete";
|
|
66
|
+
dryRun: boolean;
|
|
67
|
+
ids: string[];
|
|
68
|
+
}
|
|
69
|
+
export interface UpdateMemoryInput {
|
|
70
|
+
id: string;
|
|
71
|
+
content?: string;
|
|
72
|
+
kind?: MemoryKind;
|
|
73
|
+
scope?: MemoryScope;
|
|
74
|
+
tags?: string[];
|
|
75
|
+
confidence?: number;
|
|
76
|
+
actor: string;
|
|
77
|
+
}
|
|
78
|
+
export interface ExportMemoriesInput extends ListMemoriesInput {
|
|
79
|
+
actor: string;
|
|
80
|
+
}
|
|
81
|
+
export interface ImportMemoriesInput {
|
|
82
|
+
document: MemoryExportDocument;
|
|
83
|
+
actor: string;
|
|
84
|
+
scope?: MemoryScope;
|
|
85
|
+
dryRun?: boolean;
|
|
86
|
+
}
|
|
87
|
+
export interface ImportMemoriesResult {
|
|
88
|
+
imported: number;
|
|
89
|
+
skipped: number;
|
|
90
|
+
dryRun: boolean;
|
|
91
|
+
}
|
|
92
|
+
export interface MemoryExportDocument {
|
|
93
|
+
format: "nuzo-memory-export";
|
|
94
|
+
version: 1;
|
|
95
|
+
exported_at: string;
|
|
96
|
+
memories: MemoryExportItem[];
|
|
97
|
+
}
|
|
98
|
+
export interface MemoryExportItem {
|
|
99
|
+
scope: MemoryScope;
|
|
100
|
+
kind: MemoryKind;
|
|
101
|
+
content: string;
|
|
102
|
+
tags: string[];
|
|
103
|
+
source: string;
|
|
104
|
+
confidence: number;
|
|
105
|
+
created_at: string;
|
|
106
|
+
updated_at: string;
|
|
107
|
+
last_used_at: string | null;
|
|
108
|
+
archived_at: string | null;
|
|
109
|
+
}
|
|
110
|
+
export interface RecallMemoryResult {
|
|
111
|
+
memory: MemoryRecord;
|
|
112
|
+
score: number;
|
|
113
|
+
reason: string;
|
|
114
|
+
}
|
package/dist/types.js
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@nuzo/memory-core",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Core memory lifecycle, ports, and domain contracts for Nuzo.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"ai-agents",
|
|
7
|
+
"memory",
|
|
8
|
+
"local-first",
|
|
9
|
+
"sqlite"
|
|
10
|
+
],
|
|
11
|
+
"license": "Apache-2.0",
|
|
12
|
+
"type": "module",
|
|
13
|
+
"engines": {
|
|
14
|
+
"node": ">=22",
|
|
15
|
+
"npm": ">=10"
|
|
16
|
+
},
|
|
17
|
+
"main": "dist/index.js",
|
|
18
|
+
"types": "dist/index.d.ts",
|
|
19
|
+
"exports": {
|
|
20
|
+
".": {
|
|
21
|
+
"types": "./dist/index.d.ts",
|
|
22
|
+
"import": "./dist/index.js"
|
|
23
|
+
}
|
|
24
|
+
},
|
|
25
|
+
"files": [
|
|
26
|
+
"dist",
|
|
27
|
+
"README.md",
|
|
28
|
+
"LICENSE"
|
|
29
|
+
],
|
|
30
|
+
"dependencies": {
|
|
31
|
+
"better-sqlite3": "^12.10.0"
|
|
32
|
+
},
|
|
33
|
+
"publishConfig": {
|
|
34
|
+
"access": "public"
|
|
35
|
+
},
|
|
36
|
+
"repository": {
|
|
37
|
+
"type": "git",
|
|
38
|
+
"url": "git+https://github.com/fabionfsc/nuzo-memory.git",
|
|
39
|
+
"directory": "packages/core"
|
|
40
|
+
},
|
|
41
|
+
"homepage": "https://nuzo.com.br/",
|
|
42
|
+
"bugs": {
|
|
43
|
+
"url": "https://github.com/fabionfsc/nuzo-memory/issues"
|
|
44
|
+
}
|
|
45
|
+
}
|