cogmem 3.6.5 → 3.7.1
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/CHANGELOG.md +24 -0
- package/MEMORY_ATLAS.md +59 -12
- package/README.md +21 -13
- package/RELEASE_CHECKLIST.md +5 -4
- package/dist/agent/AgentMemoryBackend.d.ts +24 -3
- package/dist/agent/AgentMemoryBackend.js +285 -55
- package/dist/agent/AgentRecallQueryCompiler.d.ts +1 -1
- package/dist/agent/AgentRecallQueryCompiler.js +13 -1
- package/dist/agent/MemoryUsageReceipt.js +8 -10
- package/dist/agent/SessionWorkingState.js +3 -2
- package/dist/agent/UntrustedMemorySerializer.d.ts +2 -0
- package/dist/agent/UntrustedMemorySerializer.js +14 -0
- package/dist/atlas/ActionFrameExtractor.js +2 -0
- package/dist/atlas/EpisodeTitleGenerator.d.ts +31 -0
- package/dist/atlas/EpisodeTitleGenerator.js +191 -0
- package/dist/atlas/FacetQueryPlanner.d.ts +33 -0
- package/dist/atlas/FacetQueryPlanner.js +229 -0
- package/dist/atlas/GraphCurator.d.ts +30 -0
- package/dist/atlas/GraphCurator.js +501 -0
- package/dist/atlas/MemoryAtlasIndexer.d.ts +15 -0
- package/dist/atlas/MemoryAtlasIndexer.js +55 -6
- package/dist/atlas/MemoryAtlasQueryCompiler.js +10 -7
- package/dist/atlas/MemoryAtlasService.d.ts +4 -1
- package/dist/atlas/MemoryAtlasService.js +174 -11
- package/dist/atlas/MemoryAtlasTypes.d.ts +75 -11
- package/dist/bin/memory.js +18 -5
- package/dist/factory.d.ts +18 -0
- package/dist/factory.js +41 -2
- package/dist/host/openclaw/AutoMemoryPluginInstaller.js +135 -39
- package/dist/mcp/server.js +1 -1
- package/dist/recall/RecallExplanation.d.ts +1 -1
- package/dist/store/MemoryAtlasStore.d.ts +12 -1
- package/dist/store/MemoryAtlasStore.js +353 -15
- package/dist/utils/ActionKindRegistry.d.ts +10 -0
- package/dist/utils/ActionKindRegistry.js +18 -0
- package/dist/utils/EntityCueExtractor.d.ts +13 -0
- package/dist/utils/EntityCueExtractor.js +81 -0
- package/examples/hermes-backend/AGENTS.md +3 -3
- package/examples/hermes-backend/README.md +1 -1
- package/examples/hermes-backend/SKILL.md +14 -5
- package/examples/hermes-backend/references/operations.md +12 -9
- package/examples/openclaw-backend/AGENTS.md +3 -2
- package/examples/openclaw-backend/README.md +3 -2
- package/examples/openclaw-backend/SKILL.md +31 -9
- package/examples/openclaw-backend/references/operations.md +29 -11
- package/package.json +1 -1
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
+
export const MEMORY_ATLAS_PROJECTION_NAME = 'memory_atlas.v2';
|
|
3
|
+
export const MEMORY_ATLAS_PROJECTION_SCHEMA_VERSION = '3.7.2';
|
|
2
4
|
export class MemoryAtlasStore {
|
|
3
5
|
db;
|
|
4
6
|
constructor(db) {
|
|
@@ -21,12 +23,13 @@ export class MemoryAtlasStore {
|
|
|
21
23
|
this.refreshFtsNode(input.id);
|
|
22
24
|
}
|
|
23
25
|
getNode(nodeId, projectId) {
|
|
26
|
+
const scopedNodeId = scopedEntityNodeId(this.db, nodeId, projectId);
|
|
24
27
|
const row = this.db.prepare(`
|
|
25
28
|
SELECT d.*, COALESCE(a.activation, 0) AS activation
|
|
26
29
|
FROM memory_atlas_documents d LEFT JOIN memory_atlas_activation a
|
|
27
30
|
ON a.project_id=d.project_id AND a.node_id=d.node_id
|
|
28
31
|
WHERE d.node_id=? AND d.project_id=?
|
|
29
|
-
`).get(
|
|
32
|
+
`).get(scopedNodeId, projectId);
|
|
30
33
|
return row ? mapNode(row, 0) : null;
|
|
31
34
|
}
|
|
32
35
|
listNodes(projectId, limit) {
|
|
@@ -82,12 +85,83 @@ export class MemoryAtlasStore {
|
|
|
82
85
|
return mapNode(row, matches + Number(row.activation || 0));
|
|
83
86
|
}).sort((a, b) => b.score - a.score);
|
|
84
87
|
}
|
|
88
|
+
searchCanonicalEpisodeCards(projectId, plan, limit) {
|
|
89
|
+
const plannedFacets = plan.facets;
|
|
90
|
+
if (!plannedFacets.length)
|
|
91
|
+
return [];
|
|
92
|
+
const facetMatches = plannedFacets.map((facet) => ({ facet, rows: this.episodeEdgesForFacet(projectId, facet) }));
|
|
93
|
+
const facetGroups = groupFacetMatches(facetMatches);
|
|
94
|
+
if (plan.operator === 'intersection' && facetGroups.some((group) => group.every((match) => match.rows.length === 0)))
|
|
95
|
+
return [];
|
|
96
|
+
const candidateIds = plan.operator === 'intersection'
|
|
97
|
+
? intersectSets(facetGroups.map((group) => new Set(group.flatMap((match) => match.rows.map((row) => row.source_id)))))
|
|
98
|
+
: new Set(facetMatches.flatMap((match) => match.rows.map((row) => row.source_id)));
|
|
99
|
+
if (!candidateIds.size)
|
|
100
|
+
return [];
|
|
101
|
+
const rows = this.episodeRows(projectId, Array.from(candidateIds));
|
|
102
|
+
const rankedCards = rows.map((row) => {
|
|
103
|
+
const candidateEdges = facetMatches.flatMap((match) => match.rows.filter((edge) => edge.source_id === row.source_id));
|
|
104
|
+
return this.cardFromEpisodeRow(row, candidateEdges, projectId);
|
|
105
|
+
}).sort((left, right) => cardRank(right) - cardRank(left) || left.canonicalId.localeCompare(right.canonicalId));
|
|
106
|
+
const cards = dedupeCardsByEvidence(rankedCards).slice(0, Math.max(1, Math.min(limit, 100)));
|
|
107
|
+
const selectedIds = new Set(cards.map((card) => card.canonicalId));
|
|
108
|
+
for (const card of cards) {
|
|
109
|
+
card.relatedButNotSelected = mergeRelatedCards(card.relatedButNotSelected ?? [], this.relatedEpisodeCards(projectId, card.canonicalId, selectedIds, 4), this.topicRelatedCardsForPlan(projectId, plan, selectedIds, 4)).slice(0, 4);
|
|
110
|
+
}
|
|
111
|
+
return cards;
|
|
112
|
+
}
|
|
113
|
+
relatedEpisodeCards(projectId, canonicalId, selectedIds, limit) {
|
|
114
|
+
const parsed = parseNodeId(canonicalId, projectId);
|
|
115
|
+
if (!parsed || parsed.type !== 'episode')
|
|
116
|
+
return [];
|
|
117
|
+
const rows = this.db.prepare(`
|
|
118
|
+
SELECT e.relation_type, e.confidence, d.node_id, d.label, d.metadata_json
|
|
119
|
+
FROM memory_edges e
|
|
120
|
+
JOIN memory_atlas_documents d ON d.project_id=e.project_id AND d.node_id=CASE
|
|
121
|
+
WHEN e.source_type='episode' AND e.source_id=? THEN 'episode:' || e.target_id
|
|
122
|
+
ELSE 'episode:' || e.source_id
|
|
123
|
+
END
|
|
124
|
+
WHERE e.project_id=? AND e.status IN ('active','weak') AND e.relation_type IN ('SAME_ISSUE','FOLLOWS_UP','REFINES','CORRECTS','CONTRADICTS','SUPERSEDES','RELATED_TO')
|
|
125
|
+
AND ((e.source_type='episode' AND e.source_id=?) OR (e.target_type='episode' AND e.target_id=?))
|
|
126
|
+
ORDER BY CASE e.relation_type WHEN 'SAME_ISSUE' THEN 1 WHEN 'FOLLOWS_UP' THEN 2 WHEN 'REFINES' THEN 3 ELSE 4 END, e.confidence DESC
|
|
127
|
+
LIMIT ?
|
|
128
|
+
`).all(parsed.id, projectId, parsed.id, parsed.id, Math.max(1, Math.min(limit * 3, 30)));
|
|
129
|
+
const result = [];
|
|
130
|
+
for (const row of rows) {
|
|
131
|
+
if (selectedIds.has(row.node_id))
|
|
132
|
+
continue;
|
|
133
|
+
result.push({
|
|
134
|
+
canonicalId: row.node_id,
|
|
135
|
+
displayTitle: row.label,
|
|
136
|
+
reason: row.relation_type === 'RELATED_TO' ? 'related topic but different issue/date' : row.relation_type.toLowerCase(),
|
|
137
|
+
});
|
|
138
|
+
if (result.length >= limit)
|
|
139
|
+
break;
|
|
140
|
+
}
|
|
141
|
+
return result;
|
|
142
|
+
}
|
|
143
|
+
topicRelatedCardsForPlan(projectId, plan, selectedIds, limit) {
|
|
144
|
+
const topicFacets = plan.facets.filter((facet) => facet.type === 'topic');
|
|
145
|
+
if (!topicFacets.length)
|
|
146
|
+
return [];
|
|
147
|
+
const rows = topicFacets.flatMap((facet) => this.episodeEdgesForFacet(projectId, facet));
|
|
148
|
+
const candidateIds = Array.from(new Set(rows.map((row) => row.source_id))).filter((id) => !selectedIds.has(`episode:${id}`));
|
|
149
|
+
if (!candidateIds.length)
|
|
150
|
+
return [];
|
|
151
|
+
return this.episodeRows(projectId, candidateIds)
|
|
152
|
+
.map((row) => ({
|
|
153
|
+
canonicalId: row.node_id,
|
|
154
|
+
displayTitle: row.label,
|
|
155
|
+
reason: 'related topic but different issue/date',
|
|
156
|
+
}))
|
|
157
|
+
.slice(0, Math.max(1, Math.min(limit, 20)));
|
|
158
|
+
}
|
|
85
159
|
resolveTargetNodeIds(projectId, query) {
|
|
86
160
|
const normalizedQuery = normalizeLookup(query);
|
|
87
161
|
const candidates = this.db.prepare(`
|
|
88
162
|
SELECT node_id,node_type,source_id,label,topic_path,metadata_json,evidence_event_ids_json
|
|
89
163
|
FROM memory_atlas_documents
|
|
90
|
-
WHERE project_id=? AND node_type IN ('entity','topic','project') AND status NOT IN ('rejected','archived')
|
|
164
|
+
WHERE project_id=? AND node_type IN ('entity','topic','issue','project') AND status NOT IN ('rejected','archived')
|
|
91
165
|
`).all(projectId);
|
|
92
166
|
const seeds = [];
|
|
93
167
|
const labels = [];
|
|
@@ -375,12 +449,34 @@ export class MemoryAtlasStore {
|
|
|
375
449
|
}
|
|
376
450
|
projectionNeedsRefresh(projectId) {
|
|
377
451
|
const row = this.db.prepare(`
|
|
452
|
+
SELECT status, metadata_json FROM memory_atlas_projection_state
|
|
453
|
+
WHERE project_id=? AND projection_name=?
|
|
454
|
+
`).get(projectId, MEMORY_ATLAS_PROJECTION_NAME);
|
|
455
|
+
if (!row || row.status !== 'clean')
|
|
456
|
+
return true;
|
|
457
|
+
const metadata = parseMetadata(row.metadata_json);
|
|
458
|
+
if (metadata.projectionSchemaVersion !== MEMORY_ATLAS_PROJECTION_SCHEMA_VERSION)
|
|
459
|
+
return true;
|
|
460
|
+
const legacyDirty = this.db.prepare(`
|
|
378
461
|
SELECT status FROM memory_atlas_projection_state
|
|
379
|
-
WHERE project_id=? AND projection_name='memory_atlas.v1'
|
|
462
|
+
WHERE project_id=? AND projection_name='memory_atlas.v1' AND status<>'clean'
|
|
380
463
|
`).get(projectId);
|
|
381
|
-
return
|
|
464
|
+
return Boolean(legacyDirty);
|
|
382
465
|
}
|
|
383
466
|
markProjectionClean(projectId, metadata = {}, now = Date.now()) {
|
|
467
|
+
const enrichedMetadata = {
|
|
468
|
+
...metadata,
|
|
469
|
+
projectionName: MEMORY_ATLAS_PROJECTION_NAME,
|
|
470
|
+
projectionSchemaVersion: MEMORY_ATLAS_PROJECTION_SCHEMA_VERSION,
|
|
471
|
+
};
|
|
472
|
+
this.db.prepare(`
|
|
473
|
+
INSERT INTO memory_atlas_projection_state(
|
|
474
|
+
project_id, projection_name, cursor_value, status, last_rebuild_at, last_error, metadata_json
|
|
475
|
+
) VALUES(?, ?, ?, 'clean', ?, NULL, ?)
|
|
476
|
+
ON CONFLICT(project_id, projection_name) DO UPDATE SET
|
|
477
|
+
cursor_value=excluded.cursor_value, status='clean', last_rebuild_at=excluded.last_rebuild_at,
|
|
478
|
+
last_error=NULL, metadata_json=excluded.metadata_json
|
|
479
|
+
`).run(projectId, MEMORY_ATLAS_PROJECTION_NAME, String(now), now, JSON.stringify(enrichedMetadata));
|
|
384
480
|
this.db.prepare(`
|
|
385
481
|
INSERT INTO memory_atlas_projection_state(
|
|
386
482
|
project_id, projection_name, cursor_value, status, last_rebuild_at, last_error, metadata_json
|
|
@@ -388,17 +484,61 @@ export class MemoryAtlasStore {
|
|
|
388
484
|
ON CONFLICT(project_id, projection_name) DO UPDATE SET
|
|
389
485
|
cursor_value=excluded.cursor_value, status='clean', last_rebuild_at=excluded.last_rebuild_at,
|
|
390
486
|
last_error=NULL, metadata_json=excluded.metadata_json
|
|
391
|
-
`).run(projectId, String(now), now, JSON.stringify(
|
|
487
|
+
`).run(projectId, String(now), now, JSON.stringify({ compatibilityAliasFor: MEMORY_ATLAS_PROJECTION_NAME }));
|
|
488
|
+
}
|
|
489
|
+
markProjectionDirty(projectId, metadata = {}, now = Date.now()) {
|
|
490
|
+
this.db.prepare(`
|
|
491
|
+
INSERT INTO memory_atlas_projection_state(project_id,projection_name,cursor_value,status,last_rebuild_at,last_error,metadata_json)
|
|
492
|
+
VALUES(?, ?, NULL, 'dirty', ?, NULL, ?)
|
|
493
|
+
ON CONFLICT(project_id,projection_name) DO UPDATE SET
|
|
494
|
+
status='dirty', cursor_value=NULL, last_error=NULL, metadata_json=excluded.metadata_json
|
|
495
|
+
`).run(projectId, MEMORY_ATLAS_PROJECTION_NAME, now, JSON.stringify({
|
|
496
|
+
...metadata,
|
|
497
|
+
projectionName: MEMORY_ATLAS_PROJECTION_NAME,
|
|
498
|
+
projectionSchemaVersion: MEMORY_ATLAS_PROJECTION_SCHEMA_VERSION,
|
|
499
|
+
}));
|
|
500
|
+
this.db.prepare(`
|
|
501
|
+
INSERT INTO memory_atlas_projection_state(project_id,projection_name,cursor_value,status,last_rebuild_at,last_error,metadata_json)
|
|
502
|
+
VALUES(?, 'memory_atlas.v1', NULL, 'dirty', ?, NULL, ?)
|
|
503
|
+
ON CONFLICT(project_id,projection_name) DO UPDATE SET status='dirty', cursor_value=NULL, last_error=NULL, metadata_json=excluded.metadata_json
|
|
504
|
+
`).run(projectId, now, JSON.stringify({ compatibilityAliasFor: MEMORY_ATLAS_PROJECTION_NAME, dirtyBecause: 'atlas_v2_targeted_reindex' }));
|
|
505
|
+
}
|
|
506
|
+
aggregateFacetNodeSupport(projectId, now = Date.now()) {
|
|
507
|
+
const rows = this.db.prepare(`
|
|
508
|
+
SELECT target_type,target_id,evidence_event_ids_json
|
|
509
|
+
FROM memory_edges
|
|
510
|
+
WHERE project_id=? AND source_type='episode'
|
|
511
|
+
AND target_type IN ('topic','time','issue','entity','session','thread','memoryKind','actionKind')
|
|
512
|
+
AND status IN ('active','weak')
|
|
513
|
+
ORDER BY target_type,target_id
|
|
514
|
+
`).all(projectId);
|
|
515
|
+
const buckets = new Map();
|
|
516
|
+
for (const row of rows) {
|
|
517
|
+
const id = nodeId(row.target_type, row.target_id, projectId);
|
|
518
|
+
const bucket = buckets.get(id) ?? { count: 0, evidence: new Set() };
|
|
519
|
+
bucket.count += 1;
|
|
520
|
+
for (const eventId of parseStringArray(row.evidence_event_ids_json).slice(0, 5))
|
|
521
|
+
bucket.evidence.add(eventId);
|
|
522
|
+
buckets.set(id, bucket);
|
|
523
|
+
}
|
|
524
|
+
const update = this.db.prepare(`UPDATE memory_atlas_documents SET support_count=?, evidence_event_ids_json=?, updated_at=? WHERE project_id=? AND node_id=?`);
|
|
525
|
+
for (const [nodeIdValue, bucket] of buckets) {
|
|
526
|
+
update.run(bucket.count, JSON.stringify(Array.from(bucket.evidence).slice(0, 100)), now, projectId, nodeIdValue);
|
|
527
|
+
this.refreshFtsNode(nodeIdValue);
|
|
528
|
+
}
|
|
392
529
|
}
|
|
393
530
|
markProjectionFailed(projectId, error, now = Date.now()) {
|
|
394
531
|
this.db.prepare(`
|
|
395
532
|
INSERT INTO memory_atlas_projection_state(project_id,projection_name,status,last_rebuild_at,last_error,metadata_json)
|
|
396
|
-
VALUES(
|
|
533
|
+
VALUES(?,?,'error',?,?,?)
|
|
397
534
|
ON CONFLICT(project_id,projection_name) DO UPDATE SET status='error',last_rebuild_at=excluded.last_rebuild_at,last_error=excluded.last_error
|
|
398
|
-
`).run(projectId, now, error.slice(0, 2000)
|
|
535
|
+
`).run(projectId, MEMORY_ATLAS_PROJECTION_NAME, now, error.slice(0, 2000), JSON.stringify({
|
|
536
|
+
projectionName: MEMORY_ATLAS_PROJECTION_NAME,
|
|
537
|
+
projectionSchemaVersion: MEMORY_ATLAS_PROJECTION_SCHEMA_VERSION,
|
|
538
|
+
}));
|
|
399
539
|
}
|
|
400
540
|
getProjectionState(projectId) {
|
|
401
|
-
const row = this.db.prepare(`SELECT status,last_rebuild_at,last_error FROM memory_atlas_projection_state WHERE project_id=? AND projection_name
|
|
541
|
+
const row = this.db.prepare(`SELECT status,last_rebuild_at,last_error FROM memory_atlas_projection_state WHERE project_id=? AND projection_name=?`).get(projectId, MEMORY_ATLAS_PROJECTION_NAME);
|
|
402
542
|
return row ? { status: row.status, lastRebuildAt: row.last_rebuild_at ?? undefined, lastError: row.last_error || undefined } : null;
|
|
403
543
|
}
|
|
404
544
|
listKnownProjectIds() {
|
|
@@ -436,6 +576,67 @@ export class MemoryAtlasStore {
|
|
|
436
576
|
target: nodeId('topic', row.target_path, projectId), confidence: Number(row.confidence),
|
|
437
577
|
evidenceEventIds: parseStringArray(row.evidence_event_ids_json) }));
|
|
438
578
|
}
|
|
579
|
+
episodeEdgesForFacet(projectId, facet) {
|
|
580
|
+
const relationFilter = facet.relation ? 'AND relation_type=?' : '';
|
|
581
|
+
const params = facet.relation
|
|
582
|
+
? [projectId, facet.type, facet.value, facet.relation]
|
|
583
|
+
: [projectId, facet.type, facet.value];
|
|
584
|
+
return this.db.prepare(`
|
|
585
|
+
SELECT source_id, relation_type, target_type, target_id, confidence, evidence_event_ids_json
|
|
586
|
+
FROM memory_edges
|
|
587
|
+
WHERE project_id=? AND source_type='episode' AND target_type=? AND target_id=? ${relationFilter}
|
|
588
|
+
AND status IN ('active','weak')
|
|
589
|
+
ORDER BY confidence DESC
|
|
590
|
+
LIMIT 5000
|
|
591
|
+
`).all(...params);
|
|
592
|
+
}
|
|
593
|
+
episodeRows(projectId, episodeIds) {
|
|
594
|
+
const ids = Array.from(new Set(episodeIds));
|
|
595
|
+
const rows = [];
|
|
596
|
+
for (let index = 0; index < ids.length; index += 400) {
|
|
597
|
+
const chunk = ids.slice(index, index + 400);
|
|
598
|
+
if (!chunk.length)
|
|
599
|
+
continue;
|
|
600
|
+
rows.push(...this.db.prepare(`
|
|
601
|
+
SELECT d.*, COALESCE(a.activation, 0) AS activation
|
|
602
|
+
FROM memory_atlas_documents d LEFT JOIN memory_atlas_activation a
|
|
603
|
+
ON a.project_id=d.project_id AND a.node_id=d.node_id
|
|
604
|
+
WHERE d.project_id=? AND d.node_id IN (${chunk.map(() => '?').join(',')})
|
|
605
|
+
AND d.node_type='episode' AND d.status NOT IN ('rejected','archived')
|
|
606
|
+
ORDER BY d.occurred_at DESC, d.support_count DESC, d.node_id ASC
|
|
607
|
+
`).all(projectId, ...chunk.map((id) => `episode:${id}`)));
|
|
608
|
+
}
|
|
609
|
+
return rows;
|
|
610
|
+
}
|
|
611
|
+
cardFromEpisodeRow(row, edges, projectId) {
|
|
612
|
+
const metadata = parseMetadata(row.metadata_json);
|
|
613
|
+
const evidenceEventIds = parseStringArray(row.evidence_event_ids_json);
|
|
614
|
+
const matchedFacets = edges.map((edge) => facetFromEdge(edge, projectId));
|
|
615
|
+
const matchedPaths = edges.map((edge) => {
|
|
616
|
+
const facet = facetFromEdge(edge, projectId);
|
|
617
|
+
return { facet, via: [facet.nodeId, row.node_id], relation: edge.relation_type, confidence: Number(edge.confidence || 0) };
|
|
618
|
+
});
|
|
619
|
+
const issueHints = stringArray(metadata.issueHints);
|
|
620
|
+
const topicHints = stringArray(metadata.topicHints);
|
|
621
|
+
const matchedTopicPaths = matchedFacets.filter((facet) => facet.type === 'topic').map((facet) => facet.value);
|
|
622
|
+
return {
|
|
623
|
+
canonicalId: row.node_id,
|
|
624
|
+
nodeType: 'episode',
|
|
625
|
+
displayTitle: row.label,
|
|
626
|
+
oneLineSummary: row.summary || undefined,
|
|
627
|
+
matchedFacets,
|
|
628
|
+
matchedPaths,
|
|
629
|
+
parentTopics: matchedTopicPaths.length ? matchedTopicPaths : topicHints.length ? topicHints : row.topic_path ? [row.topic_path] : [],
|
|
630
|
+
issueType: issueHints[0],
|
|
631
|
+
eventKind: optionalMetadataString(metadata.eventKind),
|
|
632
|
+
localDate: optionalMetadataString(metadata.localDate) ?? (row.occurred_at ? new Date(row.occurred_at).toISOString().slice(0, 10) : undefined),
|
|
633
|
+
whyMatched: matchedFacets.length ? `matched ${matchedFacets.map((facet) => `${facet.type}:${facet.value}`).join(', ')}` : 'matched canonical episode',
|
|
634
|
+
relatedButNotSelected: [],
|
|
635
|
+
evidenceEventIds,
|
|
636
|
+
evidenceTotal: evidenceEventIds.length,
|
|
637
|
+
evidenceReturned: 0,
|
|
638
|
+
};
|
|
639
|
+
}
|
|
439
640
|
}
|
|
440
641
|
function mapNode(row, score) {
|
|
441
642
|
return { id: row.node_id, projectId: row.project_id, nodeType: row.node_type, sourceId: row.source_id,
|
|
@@ -479,22 +680,159 @@ catch {
|
|
|
479
680
|
} }
|
|
480
681
|
function escapeLike(value) { return value.replace(/[\\%_]/g, '\\$&'); }
|
|
481
682
|
function nodeId(type, id, projectId) {
|
|
482
|
-
if (type === '
|
|
683
|
+
if (type === 'entity')
|
|
684
|
+
return id.startsWith('facet:') ? `entity:${projectId}:${id}` : `entity:${id}`;
|
|
685
|
+
if (['topic', 'time', 'issue', 'session', 'thread', 'memoryKind', 'actionKind'].includes(type))
|
|
483
686
|
return `${type}:${projectId}:${id}`;
|
|
484
687
|
return `${type}:${id}`;
|
|
485
688
|
}
|
|
486
689
|
function timeNodeId(projectId, occurredAt) {
|
|
487
690
|
return `time:${projectId}:${new Date(occurredAt).getUTCFullYear()}`;
|
|
488
691
|
}
|
|
692
|
+
function scopedEntityNodeId(db, value, projectId) {
|
|
693
|
+
if (!value.startsWith('entity:') || value.startsWith(`entity:${projectId}:`))
|
|
694
|
+
return value;
|
|
695
|
+
const id = value.slice('entity:'.length);
|
|
696
|
+
if (!id.startsWith('facet:'))
|
|
697
|
+
return value;
|
|
698
|
+
const scoped = `entity:${projectId}:${id}`;
|
|
699
|
+
const row = db.prepare(`SELECT 1 FROM memory_atlas_documents WHERE project_id=? AND node_id=?`).get(projectId, scoped);
|
|
700
|
+
return row ? scoped : value;
|
|
701
|
+
}
|
|
489
702
|
function parseNodeId(value, projectId) {
|
|
490
|
-
const
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
return { type: 'time', id: value.slice(timePrefix.length) };
|
|
703
|
+
for (const type of ['topic', 'time', 'issue', 'entity', 'session', 'thread', 'memoryKind', 'actionKind']) {
|
|
704
|
+
const prefix = `${type}:${projectId}:`;
|
|
705
|
+
if (value.startsWith(prefix))
|
|
706
|
+
return { type, id: value.slice(prefix.length) };
|
|
707
|
+
}
|
|
496
708
|
const separator = value.indexOf(':');
|
|
497
709
|
if (separator <= 0 || separator === value.length - 1)
|
|
498
710
|
return null;
|
|
499
711
|
return { type: value.slice(0, separator), id: value.slice(separator + 1) };
|
|
500
712
|
}
|
|
713
|
+
function parseMetadata(value) {
|
|
714
|
+
try {
|
|
715
|
+
const parsed = JSON.parse(value || '{}');
|
|
716
|
+
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
|
|
717
|
+
}
|
|
718
|
+
catch {
|
|
719
|
+
return {};
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
function stringArray(value) {
|
|
723
|
+
return Array.isArray(value) ? value.filter((item) => typeof item === 'string' && item.length > 0) : [];
|
|
724
|
+
}
|
|
725
|
+
function optionalMetadataString(value) {
|
|
726
|
+
return typeof value === 'string' && value ? value : undefined;
|
|
727
|
+
}
|
|
728
|
+
function facetFromEdge(edge, projectId) {
|
|
729
|
+
return {
|
|
730
|
+
type: edge.target_type,
|
|
731
|
+
value: edge.target_id,
|
|
732
|
+
label: facetLabel(edge.target_type, edge.target_id),
|
|
733
|
+
nodeId: nodeId(edge.target_type, edge.target_id, projectId),
|
|
734
|
+
relation: edge.relation_type,
|
|
735
|
+
};
|
|
736
|
+
}
|
|
737
|
+
function facetLabel(type, value) {
|
|
738
|
+
if (type === 'topic')
|
|
739
|
+
return value.split('/').pop() || value;
|
|
740
|
+
if (type === 'issue') {
|
|
741
|
+
if (value === 'memory-context-blackbox')
|
|
742
|
+
return 'Memory Context 黑盒';
|
|
743
|
+
if (value === 'graph-runtime-blackbox')
|
|
744
|
+
return 'Graph Runtime 黑盒';
|
|
745
|
+
if (value === 'atlas-readability')
|
|
746
|
+
return 'Atlas 可读性黑盒';
|
|
747
|
+
if (value === 'auto-injection-mismatch')
|
|
748
|
+
return '自动注入不一致';
|
|
749
|
+
}
|
|
750
|
+
return value;
|
|
751
|
+
}
|
|
752
|
+
function groupFacetMatches(matches) {
|
|
753
|
+
const groups = new Map();
|
|
754
|
+
for (const match of matches) {
|
|
755
|
+
const targetSlug = equivalentTargetSlug(match.facet);
|
|
756
|
+
const key = targetSlug
|
|
757
|
+
? `target:${targetSlug}`
|
|
758
|
+
: match.facet.type === 'actionKind' || match.facet.type === 'memoryKind'
|
|
759
|
+
? `${match.facet.type}:${match.facet.relation || ''}`
|
|
760
|
+
: `${match.facet.type}:${match.facet.value}:${match.facet.relation || ''}`;
|
|
761
|
+
const group = groups.get(key) ?? [];
|
|
762
|
+
group.push(match);
|
|
763
|
+
groups.set(key, group);
|
|
764
|
+
}
|
|
765
|
+
return Array.from(groups.values());
|
|
766
|
+
}
|
|
767
|
+
function equivalentTargetSlug(facet) {
|
|
768
|
+
if (facet.type === 'entity' && facet.value.startsWith('facet:'))
|
|
769
|
+
return facet.value.slice('facet:'.length);
|
|
770
|
+
if (facet.type !== 'topic')
|
|
771
|
+
return undefined;
|
|
772
|
+
const match = facet.value.match(/^PROJECT\/[^/]+\/([^/]+)$/u);
|
|
773
|
+
return match?.[1];
|
|
774
|
+
}
|
|
775
|
+
function intersectSets(sets) {
|
|
776
|
+
if (!sets.length)
|
|
777
|
+
return new Set();
|
|
778
|
+
const [first, ...rest] = sets;
|
|
779
|
+
const result = new Set();
|
|
780
|
+
for (const value of first ?? []) {
|
|
781
|
+
if (rest.every((set) => set.has(value)))
|
|
782
|
+
result.add(value);
|
|
783
|
+
}
|
|
784
|
+
return result;
|
|
785
|
+
}
|
|
786
|
+
function cardRank(card) {
|
|
787
|
+
return scoreCard(card) + Math.min(card.evidenceTotal || 0, 10) * 0.01;
|
|
788
|
+
}
|
|
789
|
+
function scoreCard(card) {
|
|
790
|
+
return card.matchedFacets.length * 10 + card.matchedPaths.reduce((sum, path) => sum + path.confidence, 0) + (card.localDate ? 0.1 : 0);
|
|
791
|
+
}
|
|
792
|
+
function dedupeCardsByEvidence(cards) {
|
|
793
|
+
const bySignature = new Map();
|
|
794
|
+
const out = [];
|
|
795
|
+
for (const card of cards) {
|
|
796
|
+
const signature = evidenceSignature(card);
|
|
797
|
+
if (!signature) {
|
|
798
|
+
out.push(card);
|
|
799
|
+
continue;
|
|
800
|
+
}
|
|
801
|
+
const kept = bySignature.get(signature);
|
|
802
|
+
if (!kept) {
|
|
803
|
+
bySignature.set(signature, card);
|
|
804
|
+
out.push(card);
|
|
805
|
+
continue;
|
|
806
|
+
}
|
|
807
|
+
kept.relatedButNotSelected = mergeRelatedCards(kept.relatedButNotSelected ?? [], [{
|
|
808
|
+
canonicalId: card.canonicalId,
|
|
809
|
+
displayTitle: card.displayTitle,
|
|
810
|
+
reason: 'same primary raw evidence',
|
|
811
|
+
}]);
|
|
812
|
+
}
|
|
813
|
+
return out;
|
|
814
|
+
}
|
|
815
|
+
function evidenceSignature(card) {
|
|
816
|
+
const primary = card.sourceLocator?.eventId || card.evidenceEventIds[0];
|
|
817
|
+
if (!primary)
|
|
818
|
+
return undefined;
|
|
819
|
+
const facets = (type) => card.matchedFacets
|
|
820
|
+
.filter((facet) => facet.type === type)
|
|
821
|
+
.map((facet) => facet.value)
|
|
822
|
+
.sort()
|
|
823
|
+
.join(',');
|
|
824
|
+
return [
|
|
825
|
+
primary,
|
|
826
|
+
card.localDate || '',
|
|
827
|
+
facets('entity'),
|
|
828
|
+
facets('actionKind'),
|
|
829
|
+
].join('|');
|
|
830
|
+
}
|
|
831
|
+
function mergeRelatedCards(...groups) {
|
|
832
|
+
const merged = new Map();
|
|
833
|
+
for (const group of groups)
|
|
834
|
+
for (const card of group)
|
|
835
|
+
if (!merged.has(card.canonicalId))
|
|
836
|
+
merged.set(card.canonicalId, card);
|
|
837
|
+
return Array.from(merged.values());
|
|
838
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export interface ActionKindRule {
|
|
2
|
+
kind: string;
|
|
3
|
+
label: string;
|
|
4
|
+
verb: string;
|
|
5
|
+
pattern: RegExp;
|
|
6
|
+
}
|
|
7
|
+
export declare const ACTION_KIND_RULES: ActionKindRule[];
|
|
8
|
+
export declare function inferActionKinds(text: string): string[];
|
|
9
|
+
export declare function inferFirstActionKind(text: string): ActionKindRule | undefined;
|
|
10
|
+
//# sourceMappingURL=ActionKindRegistry.d.ts.map
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export const ACTION_KIND_RULES = [
|
|
2
|
+
{ kind: 'started', label: '启动', verb: '启动', pattern: /启动|start|started|launch|launched|boot/i },
|
|
3
|
+
{ kind: 'installed', label: '安装', verb: '安装', pattern: /安装|install|installed|setup/i },
|
|
4
|
+
{ kind: 'configured', label: '配置', verb: '配置', pattern: /配置|config|configured|设置|修改配置|修改/i },
|
|
5
|
+
{ kind: 'restarted', label: '重启', verb: '重启', pattern: /重启|restart|restarted/i },
|
|
6
|
+
{ kind: 'stopped', label: '停止', verb: '停止', pattern: /停止|stop|stopped/i },
|
|
7
|
+
{ kind: 'operated', label: '操作', verb: '操作', pattern: /操作|处理|执行|运行|run|ran/i },
|
|
8
|
+
{ kind: 'implemented', label: '实现', verb: '实现', pattern: /修复|fixed|implemented|实现|提交|升级|upgrade|合并|发布/i },
|
|
9
|
+
{ kind: 'reviewed', label: '审查', verb: '审查', pattern: /review|审查|检查/i },
|
|
10
|
+
{ kind: 'decided', label: '决定', verb: '决定', pattern: /决定|decided|方案|策略|结论/i },
|
|
11
|
+
{ kind: 'debugged', label: '排查', verb: '排查', pattern: /debug|排查|卡死|locked|zombie|报错|错误/i },
|
|
12
|
+
];
|
|
13
|
+
export function inferActionKinds(text) {
|
|
14
|
+
return ACTION_KIND_RULES.filter((rule) => rule.pattern.test(text)).map((rule) => rule.kind);
|
|
15
|
+
}
|
|
16
|
+
export function inferFirstActionKind(text) {
|
|
17
|
+
return ACTION_KIND_RULES.find((rule) => rule.pattern.test(text));
|
|
18
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export interface EntityCue {
|
|
2
|
+
label: string;
|
|
3
|
+
id: string;
|
|
4
|
+
}
|
|
5
|
+
export interface OperationalActionCue {
|
|
6
|
+
kind: string;
|
|
7
|
+
label: string;
|
|
8
|
+
verb: string;
|
|
9
|
+
}
|
|
10
|
+
export declare function extractEntityCues(text: string, limit?: number): EntityCue[];
|
|
11
|
+
export declare function inferOperationalActionCue(text: string): OperationalActionCue | undefined;
|
|
12
|
+
export declare function normalizeEntityCueId(label: string): string;
|
|
13
|
+
//# sourceMappingURL=EntityCueExtractor.d.ts.map
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { inferFirstActionKind } from './ActionKindRegistry.js';
|
|
2
|
+
const ENTITY_STOP_WORDS = new Set([
|
|
3
|
+
'a',
|
|
4
|
+
'an',
|
|
5
|
+
'and',
|
|
6
|
+
'are',
|
|
7
|
+
'before',
|
|
8
|
+
'bug',
|
|
9
|
+
'context',
|
|
10
|
+
'database',
|
|
11
|
+
'debug',
|
|
12
|
+
'exact',
|
|
13
|
+
'graph',
|
|
14
|
+
'install',
|
|
15
|
+
'installed',
|
|
16
|
+
'launch',
|
|
17
|
+
'launched',
|
|
18
|
+
'memory',
|
|
19
|
+
'operation',
|
|
20
|
+
'operations',
|
|
21
|
+
'quote',
|
|
22
|
+
'recall',
|
|
23
|
+
'sourcecontext',
|
|
24
|
+
'start',
|
|
25
|
+
'started',
|
|
26
|
+
'the',
|
|
27
|
+
'what',
|
|
28
|
+
'you',
|
|
29
|
+
]);
|
|
30
|
+
export function extractEntityCues(text, limit = 8) {
|
|
31
|
+
const normalized = String(text || '').replace(/\s+/g, ' ').trim();
|
|
32
|
+
const labels = [];
|
|
33
|
+
collectEntityMatches(normalized, /(?:对|给|把|关于|有关|围绕)\s*([\p{L}][\p{L}\p{N}_-]{1,48})\s*(?:做过|做了|做|启动|安装|配置|修改|重启|停止|操作|处理|执行|有关|的|什么|哪些|吗)?/giu, labels);
|
|
34
|
+
collectEntityMatches(normalized, /(?:聊过|讨论过|记得|还记得|提到过)\s*([\p{L}][\p{L}\p{N}_-]{1,48})\s*(?:什么|哪些|吗|的)?/giu, labels);
|
|
35
|
+
collectEntityMatches(normalized, /(?:启动|安装|配置|修改|重启|停止|执行|运行|start|started|launch|launched|boot|install|installed|setup|configure|configured|restart|stop|run|ran)\s*(?:本机安装的|本地的|the\s+)?([\p{L}][\p{L}\p{N}_-]{1,48})/giu, labels);
|
|
36
|
+
collectEntityMatches(normalized, /\b([A-Z][A-Za-z0-9_-]{1,48})\b/g, labels);
|
|
37
|
+
const seen = new Set();
|
|
38
|
+
const cues = [];
|
|
39
|
+
for (const label of labels.map(cleanEntityLabel).filter(Boolean)) {
|
|
40
|
+
const id = normalizeEntityCueId(label);
|
|
41
|
+
if (!id || ENTITY_STOP_WORDS.has(id) || isLikelyConceptLabel(label) || seen.has(id))
|
|
42
|
+
continue;
|
|
43
|
+
seen.add(id);
|
|
44
|
+
cues.push({ label, id });
|
|
45
|
+
if (cues.length >= limit)
|
|
46
|
+
break;
|
|
47
|
+
}
|
|
48
|
+
return cues;
|
|
49
|
+
}
|
|
50
|
+
export function inferOperationalActionCue(text) {
|
|
51
|
+
const rule = inferFirstActionKind(text);
|
|
52
|
+
return rule ? { kind: rule.kind, label: rule.label, verb: rule.verb } : undefined;
|
|
53
|
+
}
|
|
54
|
+
export function normalizeEntityCueId(label) {
|
|
55
|
+
return String(label || '')
|
|
56
|
+
.trim()
|
|
57
|
+
.toLocaleLowerCase()
|
|
58
|
+
.replace(/['"“”‘’`]/g, '')
|
|
59
|
+
.replace(/[^\p{L}\p{N}]+/gu, '-')
|
|
60
|
+
.replace(/^-|-$/g, '');
|
|
61
|
+
}
|
|
62
|
+
function collectEntityMatches(text, regex, out) {
|
|
63
|
+
for (const match of text.matchAll(regex)) {
|
|
64
|
+
if (match[1])
|
|
65
|
+
out.push(match[1]);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
function cleanEntityLabel(label) {
|
|
69
|
+
return String(label || '')
|
|
70
|
+
.replace(/^(我|你|我们)?(?:之前)?让你(?:对|给|把)?/u, '')
|
|
71
|
+
.replace(/^(我们|你|我)?(?:之前)?聊过的?/u, '')
|
|
72
|
+
.replace(/(聊过|讨论过|记得|还记得|提到过).*$/u, '')
|
|
73
|
+
.replace(/^(本机安装的|本地的|the\s+)/i, '')
|
|
74
|
+
.replace(/(做过|做了|启动|安装|配置|修改|重启|停止|操作|处理|执行)(什么|哪些|吗|么|的)?$/u, '')
|
|
75
|
+
.replace(/(什么|哪些|吗|么|的)$/u, '')
|
|
76
|
+
.replace(/[,。?!、;:,.?!;:()[\]{}<>]/g, '')
|
|
77
|
+
.trim();
|
|
78
|
+
}
|
|
79
|
+
function isLikelyConceptLabel(label) {
|
|
80
|
+
return /(黑盒|问题|方案|策略|计划|原话|上下文|记忆)$/u.test(label);
|
|
81
|
+
}
|
|
@@ -122,7 +122,7 @@ cogmem normalize-transcript --input ./hermes-sessions.jsonl --output ./hermes.no
|
|
|
122
122
|
cogmem import-hermes --workspace . --project hermes --session ./hermes.normalized.md
|
|
123
123
|
```
|
|
124
124
|
|
|
125
|
-
Cogmem
|
|
125
|
+
Cogmem skips import batch sealing for empty episode boundaries and skips legacy empty Dream jobs so one bad imported episode cannot block the queue.
|
|
126
126
|
|
|
127
127
|
After import, use this order:
|
|
128
128
|
|
|
@@ -162,7 +162,7 @@ cogmem memory list --project hermes --since <globalSeq> --order asc --json
|
|
|
162
162
|
|
|
163
163
|
`vectors: 0` does not mean Cogmem has no memory. It means the dense vector index has no hot vectors yet. `memory recall` still falls back to governed raw ledger search and returns `sourceContext` locators. Broad inventory questions are expanded into structured cues such as `库存管理`, `在库`, `产品コード`, and `数量`; if compiled-memory candidates miss those cues, raw ledger evidence is preferred.
|
|
164
164
|
|
|
165
|
-
`sourceContext` and `memory show --json`
|
|
165
|
+
`sourceContext` and `memory show --json` share the same replay contract: each event has a `label`, optional `charRange` / `sourceRange`, and `sourceContext.window` / `window` metadata with requested counts, actual counts, `excludesAnchor`, `ordering`, `roleFilter`, and `overlapHandling`. Use those fields before quoting exact wording or explaining what happened before/after a recalled point. Raw Ledger list rows and Atlas search/explore evidence include `sourceLocator.command` and `sourceLocator.contextCommand`; run those commands before saying the source event is unavailable.
|
|
166
166
|
|
|
167
167
|
Check status with:
|
|
168
168
|
|
|
@@ -227,7 +227,7 @@ Hermes has no automatic observation path in this integration. Use `cogmem_episod
|
|
|
227
227
|
|
|
228
228
|
Use `cogmem_topic_operate` with actor `user_explicit` only for explicit user naming/organization instructions. Model suggestions use `model_candidate` and remain reviewable; alias collisions fail closed. Use `cogmem_episode_repair` for split/merge/move/reclassify/requeue work so receipts, stale candidates, cross-references, Dream jobs, and audit records stay synchronized. Do not hand-edit the database.
|
|
229
229
|
|
|
230
|
-
Use `cogmem memory map --project hermes --json` or MCP `cogmem_memory_map` to inspect Memory Binding and Graph Recall counters. Bindings attach high-value user raw events to stable topic/entity paths, fuse same-claim evidence into claim-key clusters, and create graph anchors for source drill-down only; they are not verified facts, user preferences, or instructions. Correction bindings expose review flags and correction edges. If `cogmem memory tick --project hermes --json` suggests `bind_raw_events`, run `cogmem memory bind --project hermes --json`;
|
|
230
|
+
Use `cogmem memory map --project hermes --json` or MCP `cogmem_memory_map` to inspect Memory Binding and Graph Recall counters. Bindings attach high-value user raw events to stable topic/entity paths, fuse same-claim evidence into claim-key clusters, and create graph anchors for source drill-down only; they are not verified facts, user preferences, or instructions. Correction bindings expose review flags and correction edges. If `cogmem memory tick --project hermes --json` suggests `bind_raw_events`, run `cogmem memory bind --project hermes --json`; it scans the historical ledger by cursor and can resume with `--since <globalSeq>`. For old-discussion questions, use `cogmem memory recall --intent historical_discussion ...` or `cogmem_graph_explore`; in 3.7.1 cards carry `canonicalId`, `matchedFacets`, `matchedPaths`, `relatedButNotSelected`, and `sourceLocator`. Answer from the selected canonical card, not from a side card. Re-run `cogmem connect hermes --workspace . --auto --force --json` after upgrades to patch existing MCP allow-lists with new Cogmem tools. Follow only `nextCommands` for unattended agent work; operator/host actions are in `nextSteps` with `safeForAutomation=false`.
|
|
231
231
|
|
|
232
232
|
Dream correction records require explicit user clarification; assistant self-correction and questions containing `是不是` are not user-owned contradictions. Invalid provider output is a rejected diagnostic. Maintenance ticks supersede `needs_confirmation` entries older than the default 30-day TTL and retain their evidence rows.
|
|
233
233
|
|
|
@@ -109,7 +109,7 @@ Dream stores explicit user clarification as organizational correction evidence r
|
|
|
109
109
|
|
|
110
110
|
After upgrades, reload MCP. Rerun `cogmem connect hermes --workspace . --auto --force --json` when MCP wiring, allow-listed tools, or the installed skill bundle changed. Follow only JSON `nextCommands` for unattended agent work; operator/host actions are in `nextSteps` with `safeForAutomation=false`.
|
|
111
111
|
|
|
112
|
-
Cogmem 3.
|
|
112
|
+
Cogmem 3.7.1 exposes read-only/idempotent Memory Atlas query tools plus explicit `cogmem_graph_touch`, installs from npm by default, prevents empty imported episodes from blocking Dream, and adds an agent-safe operations protocol. Use `memory plan` for the next safe command, grouped `memory candidates --json` for queue state, `historical_discussion` recall for old-discussion questions, and returned `sourceLocator` commands for exact evidence. Only run `dream_tick` when it appears in `memory plan.nextActions`; `raw_dream_ledger_lag` in `nonBlocking` has `resolvableByDreamTick:false` and is not fixed by looping `dream tick`. Use explore for broad memory inventory/history, search and node for a known concept, path/neighbors for relations, timeline for ordered reconstruction, and normal recall for a direct fact. Query facets combine the user's actual project, day/month/year, topic, issue, entity/target, session/thread, memory-kind, action-kind, and keyword conditions like table filters, so cold memory can be revived without requiring an entity-time-action tuple. Unicode entity cues are supported, but alias merges still require governed evidence. Prefer returned `cards[]` for historical discussion: `canonicalId` dedupes the episode, `matchedFacets` explains why it matched, `relatedButNotSelected` is only side context, `relaxationTrace` marks day/month/year or issue/topic fallback, and `sourceLocator` opens raw evidence. Touch only nodes actually used, and follow returned event IDs or `sourceLocator` commands before quoting exact wording.
|
|
113
113
|
|
|
114
114
|
## Runtime
|
|
115
115
|
|