@voidwire/lore 0.1.15 → 0.3.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/cli.ts +126 -9
- package/index.ts +10 -0
- package/lib/about.ts +103 -0
- package/lib/list.ts +58 -0
- package/lib/semantic.ts +87 -0
- package/package.json +1 -1
package/cli.ts
CHANGED
|
@@ -26,14 +26,18 @@ import {
|
|
|
26
26
|
listSources,
|
|
27
27
|
list,
|
|
28
28
|
listDomains,
|
|
29
|
+
formatBriefList,
|
|
29
30
|
info,
|
|
30
31
|
formatInfoHuman,
|
|
31
32
|
projects,
|
|
33
|
+
about,
|
|
34
|
+
formatBriefAbout,
|
|
32
35
|
captureTask,
|
|
33
36
|
captureKnowledge,
|
|
34
37
|
captureNote,
|
|
35
38
|
captureTeaching,
|
|
36
39
|
semanticSearch,
|
|
40
|
+
formatBriefSearch,
|
|
37
41
|
hasEmbeddings,
|
|
38
42
|
DOMAINS,
|
|
39
43
|
type SearchResult,
|
|
@@ -81,7 +85,7 @@ function parseArgs(args: string[]): Map<string, string> {
|
|
|
81
85
|
}
|
|
82
86
|
|
|
83
87
|
// Boolean flags that don't take values
|
|
84
|
-
const BOOLEAN_FLAGS = new Set(["help", "sources", "domains", "exact"]);
|
|
88
|
+
const BOOLEAN_FLAGS = new Set(["help", "sources", "domains", "exact", "brief"]);
|
|
85
89
|
|
|
86
90
|
function getPositionalArgs(args: string[]): string[] {
|
|
87
91
|
const result: string[] = [];
|
|
@@ -255,14 +259,21 @@ async function handleSearch(args: string[]): Promise<void> {
|
|
|
255
259
|
fail("No embeddings found. Run lore-embed-all first.", 2);
|
|
256
260
|
}
|
|
257
261
|
|
|
262
|
+
const brief = hasFlag(args, "brief");
|
|
263
|
+
|
|
258
264
|
try {
|
|
259
265
|
const results = await semanticSearch(query, { source, limit, project });
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
results
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
+
|
|
267
|
+
if (brief) {
|
|
268
|
+
console.log(formatBriefSearch(results));
|
|
269
|
+
} else {
|
|
270
|
+
output({
|
|
271
|
+
success: true,
|
|
272
|
+
results,
|
|
273
|
+
count: results.length,
|
|
274
|
+
mode: "semantic",
|
|
275
|
+
});
|
|
276
|
+
}
|
|
266
277
|
console.error(
|
|
267
278
|
`✅ ${results.length} result${results.length !== 1 ? "s" : ""} found (semantic)`,
|
|
268
279
|
);
|
|
@@ -320,11 +331,14 @@ function handleList(args: string[]): void {
|
|
|
320
331
|
: undefined;
|
|
321
332
|
const format = parsed.get("format") || "json";
|
|
322
333
|
const project = parsed.get("project");
|
|
334
|
+
const brief = hasFlag(args, "brief");
|
|
323
335
|
|
|
324
336
|
try {
|
|
325
337
|
const result = list(domain, { limit, project });
|
|
326
338
|
|
|
327
|
-
if (
|
|
339
|
+
if (brief) {
|
|
340
|
+
console.log(formatBriefList(result));
|
|
341
|
+
} else if (format === "human") {
|
|
328
342
|
console.log(formatHumanOutput(result));
|
|
329
343
|
} else if (format === "jsonl") {
|
|
330
344
|
for (const entry of result.entries) {
|
|
@@ -405,6 +419,55 @@ function handleProjects(args: string[]): void {
|
|
|
405
419
|
}
|
|
406
420
|
}
|
|
407
421
|
|
|
422
|
+
// ============================================================================
|
|
423
|
+
// About Command
|
|
424
|
+
// ============================================================================
|
|
425
|
+
|
|
426
|
+
function handleAbout(args: string[]): void {
|
|
427
|
+
if (hasFlag(args, "help")) {
|
|
428
|
+
showAboutHelp();
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
const parsed = parseArgs(args);
|
|
432
|
+
const positional = getPositionalArgs(args);
|
|
433
|
+
|
|
434
|
+
if (positional.length === 0) {
|
|
435
|
+
fail("Missing project name. Use: lore about <project>");
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
const project = positional[0];
|
|
439
|
+
const brief = hasFlag(args, "brief");
|
|
440
|
+
const limit = parsed.has("limit")
|
|
441
|
+
? parseInt(parsed.get("limit")!, 10)
|
|
442
|
+
: undefined;
|
|
443
|
+
|
|
444
|
+
try {
|
|
445
|
+
const result = about(project, { brief, limit });
|
|
446
|
+
|
|
447
|
+
if (brief) {
|
|
448
|
+
console.log(formatBriefAbout(result));
|
|
449
|
+
} else {
|
|
450
|
+
output({
|
|
451
|
+
success: true,
|
|
452
|
+
...result,
|
|
453
|
+
});
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
const totalCount =
|
|
457
|
+
result.commits.count +
|
|
458
|
+
result.captures.count +
|
|
459
|
+
result.tasks.count +
|
|
460
|
+
result.teachings.count +
|
|
461
|
+
result.sessions.count;
|
|
462
|
+
|
|
463
|
+
console.error(`✅ ${totalCount} entries for project: ${project}`);
|
|
464
|
+
process.exit(0);
|
|
465
|
+
} catch (error) {
|
|
466
|
+
const message = error instanceof Error ? error.message : "Unknown error";
|
|
467
|
+
fail(message, 2);
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
408
471
|
// ============================================================================
|
|
409
472
|
// Capture Command
|
|
410
473
|
// ============================================================================
|
|
@@ -577,12 +640,15 @@ Usage:
|
|
|
577
640
|
lore list --domains List available domains
|
|
578
641
|
lore info Show indexed sources and counts
|
|
579
642
|
lore info --human Human-readable info
|
|
643
|
+
lore about <project> Aggregate view of project knowledge
|
|
644
|
+
lore about <project> --brief Compact project summary
|
|
580
645
|
lore capture task|knowledge|note|teaching Capture knowledge
|
|
581
646
|
|
|
582
647
|
Search Options:
|
|
583
648
|
--exact Use FTS5 text search (bypasses semantic search)
|
|
584
649
|
--limit <n> Maximum results (default: 20)
|
|
585
650
|
--project <name> Filter results by project
|
|
651
|
+
--brief Compact output (titles only)
|
|
586
652
|
--since <date> Filter by date (today, yesterday, this-week, YYYY-MM-DD)
|
|
587
653
|
--sources List indexed sources with counts
|
|
588
654
|
|
|
@@ -593,6 +659,7 @@ Passthrough Sources:
|
|
|
593
659
|
List Options:
|
|
594
660
|
--limit <n> Maximum entries
|
|
595
661
|
--format <fmt> Output format: json (default), jsonl, human
|
|
662
|
+
--brief Compact output (titles only)
|
|
596
663
|
--domains List available domains
|
|
597
664
|
|
|
598
665
|
Capture Types:
|
|
@@ -641,6 +708,7 @@ Options:
|
|
|
641
708
|
--exact Use FTS5 text search (bypasses semantic search)
|
|
642
709
|
--limit <n> Maximum results (default: 20)
|
|
643
710
|
--project <name> Filter results by project (post-filters KNN results)
|
|
711
|
+
--brief Compact output (titles only)
|
|
644
712
|
--since <date> Filter by date (today, yesterday, this-week, YYYY-MM-DD)
|
|
645
713
|
--sources List indexed sources with counts
|
|
646
714
|
--help Show this help
|
|
@@ -688,6 +756,7 @@ Options:
|
|
|
688
756
|
--limit <n> Maximum entries (default: all)
|
|
689
757
|
--format <fmt> Output format: json (default), jsonl, human
|
|
690
758
|
--project <name> Filter by project name
|
|
759
|
+
--brief Compact output (titles only)
|
|
691
760
|
--domains List available domains
|
|
692
761
|
--help Show this help
|
|
693
762
|
|
|
@@ -774,6 +843,51 @@ Examples:
|
|
|
774
843
|
process.exit(0);
|
|
775
844
|
}
|
|
776
845
|
|
|
846
|
+
function showAboutHelp(): void {
|
|
847
|
+
console.log(`
|
|
848
|
+
lore about - Show everything about a project
|
|
849
|
+
|
|
850
|
+
Usage:
|
|
851
|
+
lore about <project> Aggregate view of project knowledge
|
|
852
|
+
lore about <project> --brief Compact output
|
|
853
|
+
|
|
854
|
+
Options:
|
|
855
|
+
--brief Compact output (titles only)
|
|
856
|
+
--limit <n> Results per source (default: 10)
|
|
857
|
+
--help Show this help
|
|
858
|
+
|
|
859
|
+
Sources queried:
|
|
860
|
+
commits Git commits for project
|
|
861
|
+
captures Quick captures in project context
|
|
862
|
+
tasks Development tasks for project
|
|
863
|
+
teachings Teachings from project
|
|
864
|
+
sessions Claude Code sessions for project
|
|
865
|
+
|
|
866
|
+
Output (JSON):
|
|
867
|
+
{
|
|
868
|
+
"project": "name",
|
|
869
|
+
"commits": [...],
|
|
870
|
+
"captures": [...],
|
|
871
|
+
"tasks": [...],
|
|
872
|
+
"teachings": [...],
|
|
873
|
+
"sessions": [...]
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
Output (--brief):
|
|
877
|
+
commits (3):
|
|
878
|
+
project: hash - commit message
|
|
879
|
+
|
|
880
|
+
captures (2):
|
|
881
|
+
project: insight text
|
|
882
|
+
|
|
883
|
+
Examples:
|
|
884
|
+
lore about momentum --brief
|
|
885
|
+
lore about lore | jq '.commits | length'
|
|
886
|
+
lore about momentum --limit 5
|
|
887
|
+
`);
|
|
888
|
+
process.exit(0);
|
|
889
|
+
}
|
|
890
|
+
|
|
777
891
|
function showCaptureHelp(): void {
|
|
778
892
|
console.log(`
|
|
779
893
|
lore capture - Capture knowledge
|
|
@@ -855,12 +969,15 @@ function main(): void {
|
|
|
855
969
|
case "projects":
|
|
856
970
|
handleProjects(commandArgs);
|
|
857
971
|
break;
|
|
972
|
+
case "about":
|
|
973
|
+
handleAbout(commandArgs);
|
|
974
|
+
break;
|
|
858
975
|
case "capture":
|
|
859
976
|
handleCapture(commandArgs);
|
|
860
977
|
break;
|
|
861
978
|
default:
|
|
862
979
|
fail(
|
|
863
|
-
`Unknown command: ${command}. Use: search, list, info, projects, or capture`,
|
|
980
|
+
`Unknown command: ${command}. Use: search, list, info, projects, about, or capture`,
|
|
864
981
|
);
|
|
865
982
|
}
|
|
866
983
|
}
|
package/index.ts
CHANGED
|
@@ -20,6 +20,7 @@ export {
|
|
|
20
20
|
export {
|
|
21
21
|
list,
|
|
22
22
|
listDomains,
|
|
23
|
+
formatBriefList,
|
|
23
24
|
DOMAINS,
|
|
24
25
|
type Domain,
|
|
25
26
|
type ListOptions,
|
|
@@ -38,6 +39,14 @@ export {
|
|
|
38
39
|
// Projects
|
|
39
40
|
export { projects } from "./lib/projects";
|
|
40
41
|
|
|
42
|
+
// About
|
|
43
|
+
export {
|
|
44
|
+
about,
|
|
45
|
+
formatBriefAbout,
|
|
46
|
+
type AboutResult,
|
|
47
|
+
type AboutOptions,
|
|
48
|
+
} from "./lib/about";
|
|
49
|
+
|
|
41
50
|
// Prismis integration
|
|
42
51
|
export {
|
|
43
52
|
searchPrismis,
|
|
@@ -70,6 +79,7 @@ export {
|
|
|
70
79
|
// Semantic search
|
|
71
80
|
export {
|
|
72
81
|
semanticSearch,
|
|
82
|
+
formatBriefSearch,
|
|
73
83
|
embedQuery,
|
|
74
84
|
hasEmbeddings,
|
|
75
85
|
type SemanticResult,
|
package/lib/about.ts
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/about.ts - Project knowledge aggregation
|
|
3
|
+
*
|
|
4
|
+
* Aggregates all knowledge sources for a given project.
|
|
5
|
+
* Uses parallel queries via Promise.all for performance.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { list, formatBriefList, type ListResult, type Domain } from "./list";
|
|
9
|
+
|
|
10
|
+
export interface AboutOptions {
|
|
11
|
+
brief?: boolean;
|
|
12
|
+
limit?: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface AboutResult {
|
|
16
|
+
project: string;
|
|
17
|
+
commits: ListResult;
|
|
18
|
+
captures: ListResult;
|
|
19
|
+
tasks: ListResult;
|
|
20
|
+
teachings: ListResult;
|
|
21
|
+
sessions: ListResult;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Sources to query for project knowledge
|
|
26
|
+
* Each source has a different field for project mapping (handled by list.ts)
|
|
27
|
+
* Note: "insights" will be added when task 2.1 is complete
|
|
28
|
+
*/
|
|
29
|
+
const ABOUT_SOURCES: Domain[] = [
|
|
30
|
+
"commits",
|
|
31
|
+
"captures",
|
|
32
|
+
"tasks",
|
|
33
|
+
"teachings",
|
|
34
|
+
"sessions",
|
|
35
|
+
];
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Get aggregated knowledge about a project across all sources
|
|
39
|
+
*
|
|
40
|
+
* @param project - Project name to query
|
|
41
|
+
* @param options - Optional brief flag and limit
|
|
42
|
+
* @returns AboutResult with data from all sources, or formatted string if brief
|
|
43
|
+
*/
|
|
44
|
+
export function about(
|
|
45
|
+
project: string,
|
|
46
|
+
options: AboutOptions = {},
|
|
47
|
+
): AboutResult {
|
|
48
|
+
const limit = options.limit ?? 10;
|
|
49
|
+
|
|
50
|
+
// Query all sources in parallel
|
|
51
|
+
const results = ABOUT_SOURCES.map((source) => {
|
|
52
|
+
try {
|
|
53
|
+
return list(source, { project, limit });
|
|
54
|
+
} catch {
|
|
55
|
+
// Source doesn't exist or has no data - return empty result
|
|
56
|
+
return {
|
|
57
|
+
domain: source,
|
|
58
|
+
entries: [],
|
|
59
|
+
count: 0,
|
|
60
|
+
} as ListResult;
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
return {
|
|
65
|
+
project,
|
|
66
|
+
commits: results[0],
|
|
67
|
+
captures: results[1],
|
|
68
|
+
tasks: results[2],
|
|
69
|
+
teachings: results[3],
|
|
70
|
+
sessions: results[4],
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Format about result as brief, compact output
|
|
76
|
+
* Groups by source, skips empty sources
|
|
77
|
+
*/
|
|
78
|
+
export function formatBriefAbout(result: AboutResult): string {
|
|
79
|
+
const sections: string[] = [];
|
|
80
|
+
|
|
81
|
+
// Format each non-empty source
|
|
82
|
+
if (result.commits.count > 0) {
|
|
83
|
+
sections.push(formatBriefList(result.commits));
|
|
84
|
+
}
|
|
85
|
+
if (result.captures.count > 0) {
|
|
86
|
+
sections.push(formatBriefList(result.captures));
|
|
87
|
+
}
|
|
88
|
+
if (result.tasks.count > 0) {
|
|
89
|
+
sections.push(formatBriefList(result.tasks));
|
|
90
|
+
}
|
|
91
|
+
if (result.teachings.count > 0) {
|
|
92
|
+
sections.push(formatBriefList(result.teachings));
|
|
93
|
+
}
|
|
94
|
+
if (result.sessions.count > 0) {
|
|
95
|
+
sections.push(formatBriefList(result.sessions));
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (sections.length === 0) {
|
|
99
|
+
return `(no results for project: ${result.project})`;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return sections.join("\n\n");
|
|
103
|
+
}
|
package/lib/list.ts
CHANGED
|
@@ -209,3 +209,61 @@ export function list(domain: Domain, options: ListOptions = {}): ListResult {
|
|
|
209
209
|
export function listDomains(): Domain[] {
|
|
210
210
|
return [...DOMAINS];
|
|
211
211
|
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Extract project name from entry metadata
|
|
215
|
+
*/
|
|
216
|
+
function extractProjectFromEntry(entry: ListEntry, domain: string): string {
|
|
217
|
+
const field = PROJECT_FIELD[domain];
|
|
218
|
+
if (!field) return "unknown";
|
|
219
|
+
return (entry.metadata[field] as string) || "unknown";
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Extract identifier from entry based on domain type
|
|
224
|
+
*/
|
|
225
|
+
function extractIdentifier(entry: ListEntry, domain: string): string {
|
|
226
|
+
const metadata = entry.metadata;
|
|
227
|
+
|
|
228
|
+
switch (domain) {
|
|
229
|
+
case "commits":
|
|
230
|
+
return (metadata.sha as string)?.substring(0, 7) || "";
|
|
231
|
+
case "sessions":
|
|
232
|
+
return (metadata.session_id as string)?.substring(0, 8) || "";
|
|
233
|
+
default:
|
|
234
|
+
return (metadata.id as string) || "";
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Get the best display text for an entry
|
|
240
|
+
* Commits use content (commit message), others use title
|
|
241
|
+
*/
|
|
242
|
+
function getDisplayText(entry: ListEntry, domain: string): string {
|
|
243
|
+
if (domain === "commits") {
|
|
244
|
+
return entry.content || entry.title;
|
|
245
|
+
}
|
|
246
|
+
return entry.title;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Format list result as brief, compact output
|
|
251
|
+
* One line per entry: " project: identifier - title"
|
|
252
|
+
*/
|
|
253
|
+
export function formatBriefList(result: ListResult): string {
|
|
254
|
+
const lines = [`${result.domain} (${result.count}):`];
|
|
255
|
+
|
|
256
|
+
result.entries.forEach((entry) => {
|
|
257
|
+
const project = extractProjectFromEntry(entry, result.domain);
|
|
258
|
+
const identifier = extractIdentifier(entry, result.domain);
|
|
259
|
+
const displayText = getDisplayText(entry, result.domain);
|
|
260
|
+
|
|
261
|
+
const line = identifier
|
|
262
|
+
? ` ${project}: ${identifier} - ${displayText}`
|
|
263
|
+
: ` ${project}: ${displayText}`;
|
|
264
|
+
|
|
265
|
+
lines.push(line);
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
return lines.join("\n");
|
|
269
|
+
}
|
package/lib/semantic.ts
CHANGED
|
@@ -259,3 +259,90 @@ export async function semanticSearch(
|
|
|
259
259
|
db.close();
|
|
260
260
|
}
|
|
261
261
|
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Extract project from result metadata
|
|
265
|
+
*/
|
|
266
|
+
function extractProjectFromMetadata(metadata: string, source: string): string {
|
|
267
|
+
const field = PROJECT_FIELD[source];
|
|
268
|
+
if (!field) return "unknown";
|
|
269
|
+
|
|
270
|
+
try {
|
|
271
|
+
const parsed = JSON.parse(metadata);
|
|
272
|
+
return parsed[field] || "unknown";
|
|
273
|
+
} catch {
|
|
274
|
+
return "unknown";
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Extract identifier from semantic result
|
|
280
|
+
*/
|
|
281
|
+
function extractIdentifierFromResult(result: SemanticResult): string {
|
|
282
|
+
try {
|
|
283
|
+
const metadata = JSON.parse(result.metadata);
|
|
284
|
+
|
|
285
|
+
switch (result.source) {
|
|
286
|
+
case "commits":
|
|
287
|
+
return metadata.sha?.substring(0, 7) || "";
|
|
288
|
+
case "sessions":
|
|
289
|
+
return metadata.session_id?.substring(0, 8) || "";
|
|
290
|
+
default:
|
|
291
|
+
return metadata.id || "";
|
|
292
|
+
}
|
|
293
|
+
} catch {
|
|
294
|
+
return "";
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Get the best display text for a result
|
|
300
|
+
* Commits use content (commit message), others use title
|
|
301
|
+
*/
|
|
302
|
+
function getDisplayText(result: SemanticResult): string {
|
|
303
|
+
if (result.source === "commits") {
|
|
304
|
+
return result.content || result.title;
|
|
305
|
+
}
|
|
306
|
+
return result.title;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Format semantic search results as brief, compact output
|
|
311
|
+
* Groups by source type, one line per result
|
|
312
|
+
*/
|
|
313
|
+
export function formatBriefSearch(results: SemanticResult[]): string {
|
|
314
|
+
if (results.length === 0) {
|
|
315
|
+
return "(no results)";
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// Group results by source
|
|
319
|
+
const grouped = new Map<string, SemanticResult[]>();
|
|
320
|
+
results.forEach((result) => {
|
|
321
|
+
const existing = grouped.get(result.source) || [];
|
|
322
|
+
existing.push(result);
|
|
323
|
+
grouped.set(result.source, existing);
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
const sections: string[] = [];
|
|
327
|
+
|
|
328
|
+
// Format each source group
|
|
329
|
+
grouped.forEach((sourceResults, source) => {
|
|
330
|
+
const lines = [`${source} (${sourceResults.length}):`];
|
|
331
|
+
|
|
332
|
+
sourceResults.forEach((r) => {
|
|
333
|
+
const project = extractProjectFromMetadata(r.metadata, r.source);
|
|
334
|
+
const identifier = extractIdentifierFromResult(r);
|
|
335
|
+
const displayText = getDisplayText(r);
|
|
336
|
+
|
|
337
|
+
const line = identifier
|
|
338
|
+
? ` ${project}: ${identifier} - ${displayText}`
|
|
339
|
+
: ` ${project}: ${displayText}`;
|
|
340
|
+
|
|
341
|
+
lines.push(line);
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
sections.push(lines.join("\n"));
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
return sections.join("\n\n");
|
|
348
|
+
}
|