@h1v35/hivex 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 +21 -0
- package/README.md +213 -0
- package/docs/CONTEXT.md +59 -0
- package/docs/README.md +14 -0
- package/docs/adr/0003-independent-bun-installation.md +37 -0
- package/docs/adr/0010-practical-knowledge-assistance.md +92 -0
- package/docs/engineering.md +174 -0
- package/package.json +64 -0
- package/skills/hivex/SKILL.md +108 -0
- package/skills/hivex/references/markdown.md +64 -0
- package/src/cli/diagnostic.ts +26 -0
- package/src/cli.ts +92 -0
- package/src/documents.ts +575 -0
- package/src/errors.ts +15 -0
- package/src/implementation.ts +191 -0
- package/src/ingestion-units.ts +155 -0
- package/src/knowledge-maintenance.ts +76 -0
- package/src/knowledge-model.ts +418 -0
- package/src/knowledge-store.ts +657 -0
- package/src/knowledge.ts +1184 -0
- package/src/markdown.ts +98 -0
- package/src/model/connection.ts +207 -0
- package/src/model/failure.ts +33 -0
- package/src/model/invoke.ts +265 -0
- package/src/model/profile.ts +211 -0
- package/src/model/server.ts +174 -0
- package/src/model/thread.ts +50 -0
- package/src/model/transcript.ts +114 -0
- package/src/retrieval/lexical.ts +92 -0
- package/src/review.ts +129 -0
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import { lstatSync, readFileSync, readlinkSync, realpathSync } from 'node:fs';
|
|
3
|
+
import { resolve } from 'node:path';
|
|
4
|
+
import { HivexError } from './errors.ts';
|
|
5
|
+
import { digest } from './knowledge-model.ts';
|
|
6
|
+
import { rawMarkdownLines, lineContent } from './markdown.ts';
|
|
7
|
+
|
|
8
|
+
type Version = { version: string; lines: Array<[number, string]> };
|
|
9
|
+
export type Implementation = {
|
|
10
|
+
baseCommit: string;
|
|
11
|
+
fingerprint: string;
|
|
12
|
+
diff: string;
|
|
13
|
+
files: Array<{ path: string; before: Version | null; after: Version | null }>;
|
|
14
|
+
warnings: string[];
|
|
15
|
+
};
|
|
16
|
+
const maxBytes = 256 * 1024;
|
|
17
|
+
const maxFileBytes = 4 * 1024 * 1024;
|
|
18
|
+
const protectedDirectories = new Set(['.git', '.hivex', 'node_modules', '.codex']);
|
|
19
|
+
const decoder = new TextDecoder('utf-8', { fatal: true });
|
|
20
|
+
|
|
21
|
+
function git(root: string, args: string[]) {
|
|
22
|
+
const result = spawnSync('git', ['--literal-pathspecs', ...args], {
|
|
23
|
+
cwd: root,
|
|
24
|
+
maxBuffer: maxFileBytes + 1,
|
|
25
|
+
timeout: 30000,
|
|
26
|
+
env: { ...process.env, LC_ALL: 'C', GIT_OPTIONAL_LOCKS: '0' },
|
|
27
|
+
});
|
|
28
|
+
if (result.error || result.status !== 0) {
|
|
29
|
+
const tooLarge = result.error && 'code' in result.error && result.error.code === 'ENOBUFS';
|
|
30
|
+
throw new HivexError({
|
|
31
|
+
code: tooLarge ? 'IMPLEMENTATION_TOO_LARGE' : 'GIT_COMMAND_FAILED',
|
|
32
|
+
message: result.error?.message ?? result.stderr.toString('utf8').trim().slice(0, 1024),
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
return result.stdout;
|
|
36
|
+
}
|
|
37
|
+
function text(root: string, args: string[]) {
|
|
38
|
+
return decoder.decode(git(root, args));
|
|
39
|
+
}
|
|
40
|
+
function checkSize(bytes: number, limit = maxBytes) {
|
|
41
|
+
if (bytes > limit)
|
|
42
|
+
throw new HivexError({
|
|
43
|
+
code: 'IMPLEMENTATION_TOO_LARGE',
|
|
44
|
+
message: `Implementation exceeds ${limit} bytes; split the change into coherent reviews.`,
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
function version(bytes: Buffer, label: string, warnings: string[]): Version | null {
|
|
48
|
+
checkSize(bytes.byteLength, maxFileBytes);
|
|
49
|
+
let content: string;
|
|
50
|
+
try {
|
|
51
|
+
content = decoder.decode(bytes);
|
|
52
|
+
if (content.includes('\0')) throw new Error('binary');
|
|
53
|
+
} catch {
|
|
54
|
+
warnings.push(
|
|
55
|
+
`Unsupported binary or invalid UTF-8 content: ${label} (${digest(bytes.toString('base64'))})`,
|
|
56
|
+
);
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
return {
|
|
60
|
+
version: digest(bytes),
|
|
61
|
+
lines: rawMarkdownLines(content).map((line, index) => [index + 1, lineContent(line)]),
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
function beforeVersion(root: string, base: string, path: string, warnings: string[]) {
|
|
65
|
+
const entry = text(root, ['ls-tree', '-z', base, '--', path])
|
|
66
|
+
.split('\0')
|
|
67
|
+
.find((row) => row.slice(row.indexOf('\t') + 1) === path);
|
|
68
|
+
if (!entry) return null;
|
|
69
|
+
const [mode, kind, object] = entry.split('\t')[0]!.split(' ');
|
|
70
|
+
if (kind !== 'blob' || !mode?.startsWith('100')) {
|
|
71
|
+
warnings.push(`Unsupported base file: ${path} (${mode} ${object})`);
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
return version(git(root, ['cat-file', 'blob', object!]), `before ${path}`, warnings);
|
|
75
|
+
}
|
|
76
|
+
function afterVersion(root: string, path: string, warnings: string[]) {
|
|
77
|
+
const absolute = resolve(root, path);
|
|
78
|
+
let stat;
|
|
79
|
+
try {
|
|
80
|
+
stat = lstatSync(absolute);
|
|
81
|
+
} catch (error) {
|
|
82
|
+
if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT')
|
|
83
|
+
return null;
|
|
84
|
+
throw error;
|
|
85
|
+
}
|
|
86
|
+
if (stat.isSymbolicLink()) {
|
|
87
|
+
warnings.push(`Unsupported working symlink: ${path} (${digest(readlinkSync(absolute))})`);
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
if (!stat.isFile() || !realpathSync(absolute).startsWith(root + '/')) {
|
|
91
|
+
warnings.push(`Unsupported working file: ${path}`);
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
checkSize(stat.size, maxFileBytes);
|
|
95
|
+
return version(readFileSync(absolute), `after ${path}`, warnings);
|
|
96
|
+
}
|
|
97
|
+
function patch(root: string, base: string, paths: string[]) {
|
|
98
|
+
if (!paths.length) return '';
|
|
99
|
+
return text(root, [
|
|
100
|
+
'diff',
|
|
101
|
+
'--no-ext-diff',
|
|
102
|
+
'--no-textconv',
|
|
103
|
+
'--no-renames',
|
|
104
|
+
'--no-color',
|
|
105
|
+
'--src-prefix=a/',
|
|
106
|
+
'--dst-prefix=b/',
|
|
107
|
+
'--unified=3',
|
|
108
|
+
base,
|
|
109
|
+
'--',
|
|
110
|
+
...paths,
|
|
111
|
+
]);
|
|
112
|
+
}
|
|
113
|
+
function fileContext(
|
|
114
|
+
root: string,
|
|
115
|
+
base: string,
|
|
116
|
+
file: Implementation['files'][number],
|
|
117
|
+
warnings: string[],
|
|
118
|
+
) {
|
|
119
|
+
if (Buffer.byteLength(JSON.stringify(file)) <= 32768) return file;
|
|
120
|
+
const hunks = [
|
|
121
|
+
...patch(root, base, [file.path]).matchAll(/^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/gm),
|
|
122
|
+
];
|
|
123
|
+
if (!hunks.length) return file;
|
|
124
|
+
const excerpt = (version: Version | null, offset: number) =>
|
|
125
|
+
version && {
|
|
126
|
+
...version,
|
|
127
|
+
lines: version.lines.filter(([line]) =>
|
|
128
|
+
hunks.some(
|
|
129
|
+
(hunk) =>
|
|
130
|
+
line >= Number(hunk[offset]) &&
|
|
131
|
+
line < Number(hunk[offset]) + Number(hunk[offset + 1] ?? 1),
|
|
132
|
+
),
|
|
133
|
+
),
|
|
134
|
+
};
|
|
135
|
+
warnings.push(`Only changed ranges are supplied for ${file.path}; unchanged code is omitted.`);
|
|
136
|
+
return { ...file, before: excerpt(file.before, 1), after: excerpt(file.after, 3) };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function captureImplementation(root: string, base: string): Implementation {
|
|
140
|
+
const actualRoot = realpathSync(resolve(root));
|
|
141
|
+
if (realpathSync(text(actualRoot, ['rev-parse', '--show-toplevel']).trim()) !== actualRoot)
|
|
142
|
+
throw new HivexError({ code: 'INVALID_ROOT', message: 'Review from the Git project root.' });
|
|
143
|
+
const baseCommit = text(actualRoot, [
|
|
144
|
+
'rev-parse',
|
|
145
|
+
'--verify',
|
|
146
|
+
'--end-of-options',
|
|
147
|
+
base + '^{commit}',
|
|
148
|
+
]).trim();
|
|
149
|
+
const tracked = text(actualRoot, [
|
|
150
|
+
'diff',
|
|
151
|
+
'--no-ext-diff',
|
|
152
|
+
'--no-textconv',
|
|
153
|
+
'--no-renames',
|
|
154
|
+
'--name-only',
|
|
155
|
+
'-z',
|
|
156
|
+
baseCommit,
|
|
157
|
+
'--',
|
|
158
|
+
]).split('\0');
|
|
159
|
+
const untracked = text(actualRoot, ['ls-files', '--others', '--exclude-standard', '-z']).split(
|
|
160
|
+
'\0',
|
|
161
|
+
);
|
|
162
|
+
const paths = [...new Set([...tracked, ...untracked])]
|
|
163
|
+
.filter((path) => path && !path.split('/').some((part) => protectedDirectories.has(part)))
|
|
164
|
+
.sort();
|
|
165
|
+
if (paths.length > 64)
|
|
166
|
+
throw new HivexError({
|
|
167
|
+
code: 'IMPLEMENTATION_TOO_LARGE',
|
|
168
|
+
message: 'Implementation exceeds 64 files; split the change into coherent reviews.',
|
|
169
|
+
});
|
|
170
|
+
const warnings: string[] = [];
|
|
171
|
+
const files = paths.map((path) =>
|
|
172
|
+
fileContext(
|
|
173
|
+
actualRoot,
|
|
174
|
+
baseCommit,
|
|
175
|
+
{
|
|
176
|
+
path,
|
|
177
|
+
before: beforeVersion(actualRoot, baseCommit, path, warnings),
|
|
178
|
+
after: afterVersion(actualRoot, path, warnings),
|
|
179
|
+
},
|
|
180
|
+
warnings,
|
|
181
|
+
),
|
|
182
|
+
);
|
|
183
|
+
let diff = patch(actualRoot, baseCommit, paths);
|
|
184
|
+
diff += paths
|
|
185
|
+
.filter((path) => untracked.includes(path))
|
|
186
|
+
.map((path) => `\nNew untracked file: ${JSON.stringify(path)}\n`)
|
|
187
|
+
.join('');
|
|
188
|
+
const packet = { baseCommit, diff, files, warnings };
|
|
189
|
+
checkSize(Buffer.byteLength(JSON.stringify(packet)));
|
|
190
|
+
return { ...packet, fingerprint: digest(JSON.stringify(packet)) };
|
|
191
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import type { Document } from './documents.ts';
|
|
2
|
+
import { rawMarkdownLines } from './markdown.ts';
|
|
3
|
+
import { digest } from './knowledge-model.ts';
|
|
4
|
+
|
|
5
|
+
const MAX_BYTES = 8192;
|
|
6
|
+
|
|
7
|
+
export type IngestionUnit = {
|
|
8
|
+
id: string;
|
|
9
|
+
document: string;
|
|
10
|
+
hash: string;
|
|
11
|
+
lineStart: number;
|
|
12
|
+
lineEnd: number;
|
|
13
|
+
text: string;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
type SourceLine = {
|
|
17
|
+
number: number;
|
|
18
|
+
text: string;
|
|
19
|
+
bytes: number;
|
|
20
|
+
blank: boolean;
|
|
21
|
+
heading: boolean;
|
|
22
|
+
fence: { marker: string; length: number; closing: boolean } | null;
|
|
23
|
+
};
|
|
24
|
+
type Warning = { path: string; message: string };
|
|
25
|
+
|
|
26
|
+
const contentOf = (text: string) => text.replace(/(?:\r\n|\r|\n)$/, '');
|
|
27
|
+
function fenceOf(content: string) {
|
|
28
|
+
const match = /^ {0,3}(`{3,}|~{3,})(.*)$/.exec(content);
|
|
29
|
+
return match?.[1]
|
|
30
|
+
? { marker: match[1].charAt(0), length: match[1].length, closing: !match[2]?.trim() }
|
|
31
|
+
: null;
|
|
32
|
+
}
|
|
33
|
+
const headingOf = (content: string) => /^\s{0,3}#{1,6}(?:\s|$)/.test(content);
|
|
34
|
+
|
|
35
|
+
function sourceLines(text: string) {
|
|
36
|
+
return rawMarkdownLines(text)
|
|
37
|
+
.filter((line) => line !== '')
|
|
38
|
+
.map((line, index) => {
|
|
39
|
+
const content = contentOf(line);
|
|
40
|
+
return {
|
|
41
|
+
number: index + 1,
|
|
42
|
+
text: line,
|
|
43
|
+
bytes: Buffer.byteLength(line, 'utf8'),
|
|
44
|
+
blank: content.trim() === '',
|
|
45
|
+
heading: headingOf(content),
|
|
46
|
+
fence: fenceOf(content),
|
|
47
|
+
};
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function blocksFor(document: Document, warnings: Warning[]) {
|
|
52
|
+
const blocks: SourceLine[][] = [];
|
|
53
|
+
let block: SourceLine[] = [];
|
|
54
|
+
let activeFence: SourceLine['fence'] = null;
|
|
55
|
+
const flush = () => {
|
|
56
|
+
if (block.length) blocks.push(block);
|
|
57
|
+
block = [];
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
for (const line of sourceLines(document.text)) {
|
|
61
|
+
const inFence = activeFence !== null;
|
|
62
|
+
const closingFence =
|
|
63
|
+
activeFence &&
|
|
64
|
+
line.fence?.closing &&
|
|
65
|
+
line.fence.marker === activeFence.marker &&
|
|
66
|
+
line.fence.length >= activeFence.length;
|
|
67
|
+
if (closingFence) activeFence = null;
|
|
68
|
+
else if (!activeFence) activeFence = line.fence;
|
|
69
|
+
if (line.bytes > MAX_BYTES) {
|
|
70
|
+
flush();
|
|
71
|
+
warnings.push({
|
|
72
|
+
path: document.path,
|
|
73
|
+
message: `Line ${line.number} is ${line.bytes} UTF-8 bytes, exceeding the ${MAX_BYTES}-byte limit; omitted as unread.`,
|
|
74
|
+
});
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
if (!inFence && line.heading) flush();
|
|
78
|
+
block.push(line);
|
|
79
|
+
if (!activeFence && (line.blank || closingFence)) flush();
|
|
80
|
+
}
|
|
81
|
+
flush();
|
|
82
|
+
return blocks;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function splitBlock(block: SourceLine[]) {
|
|
86
|
+
const pieces: SourceLine[][] = [];
|
|
87
|
+
let piece: SourceLine[] = [];
|
|
88
|
+
let bytes = 0;
|
|
89
|
+
for (const line of block) {
|
|
90
|
+
if (piece.length && bytes + line.bytes > MAX_BYTES) {
|
|
91
|
+
pieces.push(piece);
|
|
92
|
+
piece = [];
|
|
93
|
+
bytes = 0;
|
|
94
|
+
}
|
|
95
|
+
piece.push(line);
|
|
96
|
+
bytes += line.bytes;
|
|
97
|
+
}
|
|
98
|
+
if (piece.length) pieces.push(piece);
|
|
99
|
+
return pieces;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function packedBlocks(blocks: SourceLine[][]) {
|
|
103
|
+
const packed: SourceLine[][] = [];
|
|
104
|
+
let current: SourceLine[] = [];
|
|
105
|
+
let bytes = 0;
|
|
106
|
+
const flush = () => {
|
|
107
|
+
if (current.length) packed.push(current);
|
|
108
|
+
current = [];
|
|
109
|
+
bytes = 0;
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
for (const block of blocks.flatMap((item) => splitBlock(item))) {
|
|
113
|
+
const first = block.at(0);
|
|
114
|
+
if (!first) continue;
|
|
115
|
+
const blockBytes = block.reduce((total, line) => total + line.bytes, 0);
|
|
116
|
+
const last = current.at(-1);
|
|
117
|
+
if (
|
|
118
|
+
current.length &&
|
|
119
|
+
(bytes + blockBytes > MAX_BYTES || !last || last.number + 1 !== first.number)
|
|
120
|
+
)
|
|
121
|
+
flush();
|
|
122
|
+
current.push(...block);
|
|
123
|
+
bytes += blockBytes;
|
|
124
|
+
}
|
|
125
|
+
flush();
|
|
126
|
+
return packed;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function makeUnit(document: Document, lines: SourceLine[]): IngestionUnit {
|
|
130
|
+
const first = lines.at(0);
|
|
131
|
+
const last = lines.at(-1);
|
|
132
|
+
if (!first || !last) throw new Error('Cannot create an empty ingestion unit');
|
|
133
|
+
const text = lines.map((line) => line.text).join('');
|
|
134
|
+
return {
|
|
135
|
+
id: `${document.path}:${first.number}-${last.number}`,
|
|
136
|
+
document: document.id,
|
|
137
|
+
hash: digest(text),
|
|
138
|
+
lineStart: first.number,
|
|
139
|
+
lineEnd: last.number,
|
|
140
|
+
text,
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export function ingestionUnits(documents: Document[]): {
|
|
145
|
+
units: IngestionUnit[];
|
|
146
|
+
warnings: Warning[];
|
|
147
|
+
} {
|
|
148
|
+
const units: IngestionUnit[] = [];
|
|
149
|
+
const warnings: Warning[] = [];
|
|
150
|
+
for (const document of documents)
|
|
151
|
+
units.push(
|
|
152
|
+
...packedBlocks(blocksFor(document, warnings)).map((lines) => makeUnit(document, lines)),
|
|
153
|
+
);
|
|
154
|
+
return { units, warnings };
|
|
155
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { parseArgs } from 'node:util';
|
|
2
|
+
import { HivexError } from './errors.ts';
|
|
3
|
+
import { KnowledgeStore } from './knowledge-store.ts';
|
|
4
|
+
|
|
5
|
+
const DEFAULT_KEEP_COMPLETED = 8;
|
|
6
|
+
const DEFAULT_KEEP_CACHES = 64;
|
|
7
|
+
|
|
8
|
+
function retention(value: string | undefined, name: string, fallback: number) {
|
|
9
|
+
if (value === undefined) return fallback;
|
|
10
|
+
const number = Number(value);
|
|
11
|
+
if (!Number.isInteger(number) || number < 0 || number > 4096)
|
|
12
|
+
throw new HivexError({
|
|
13
|
+
code: 'INVALID_ARGUMENT',
|
|
14
|
+
message: `${name} must be an integer between 0 and 4096`,
|
|
15
|
+
});
|
|
16
|
+
return number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function knowledgeMaintenance(args: string[]) {
|
|
20
|
+
const parsed = parseArgs({
|
|
21
|
+
args,
|
|
22
|
+
allowPositionals: true,
|
|
23
|
+
strict: true,
|
|
24
|
+
options: {
|
|
25
|
+
root: { type: 'string' },
|
|
26
|
+
'keep-completed': { type: 'string' },
|
|
27
|
+
'keep-caches': { type: 'string' },
|
|
28
|
+
'acknowledge-uncertain': { type: 'boolean' },
|
|
29
|
+
},
|
|
30
|
+
});
|
|
31
|
+
const command = parsed.positionals[0];
|
|
32
|
+
if (!command || parsed.positionals.length !== 1 || !['recover', 'prune'].includes(command))
|
|
33
|
+
throw new HivexError({
|
|
34
|
+
code: 'INVALID_ARGUMENT',
|
|
35
|
+
message:
|
|
36
|
+
'Use recover [--acknowledge-uncertain] or prune [--keep-completed <count>] [--keep-caches <count>]',
|
|
37
|
+
});
|
|
38
|
+
if (
|
|
39
|
+
command === 'recover' &&
|
|
40
|
+
(parsed.values['keep-completed'] !== undefined || parsed.values['keep-caches'] !== undefined)
|
|
41
|
+
)
|
|
42
|
+
throw new HivexError({
|
|
43
|
+
code: 'INVALID_ARGUMENT',
|
|
44
|
+
message: 'recover does not accept retention options',
|
|
45
|
+
});
|
|
46
|
+
if (command === 'prune' && parsed.values['acknowledge-uncertain'])
|
|
47
|
+
throw new HivexError({
|
|
48
|
+
code: 'INVALID_ARGUMENT',
|
|
49
|
+
message: 'prune does not accept --acknowledge-uncertain',
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
const root = parsed.values.root ?? process.cwd();
|
|
53
|
+
using store = new KnowledgeStore(root);
|
|
54
|
+
if (command === 'recover')
|
|
55
|
+
return {
|
|
56
|
+
command,
|
|
57
|
+
modelCalls: 0,
|
|
58
|
+
...store.recover({
|
|
59
|
+
acknowledgeUncertain: parsed.values['acknowledge-uncertain'] ?? false,
|
|
60
|
+
}),
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
using _lease = store.updateLease();
|
|
64
|
+
return {
|
|
65
|
+
command,
|
|
66
|
+
modelCalls: 0,
|
|
67
|
+
...store.prune({
|
|
68
|
+
keepCompleted: retention(
|
|
69
|
+
parsed.values['keep-completed'],
|
|
70
|
+
'--keep-completed',
|
|
71
|
+
DEFAULT_KEEP_COMPLETED,
|
|
72
|
+
),
|
|
73
|
+
keepCaches: retention(parsed.values['keep-caches'], '--keep-caches', DEFAULT_KEEP_CACHES),
|
|
74
|
+
}),
|
|
75
|
+
};
|
|
76
|
+
}
|