total-recall-brain 3.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 +173 -0
- package/bin/antigravity.mjs +212 -0
- package/bin/init.mjs +36 -0
- package/bin/oci-sniper.sh +69 -0
- package/bin/total-recall.mjs +229 -0
- package/docs/ARCHITECTURE.md +251 -0
- package/docs/IDE_INTEGRATION.md +112 -0
- package/package.json +71 -0
- package/scaffold/.agent/skills/total-recall/SKILL.md +195 -0
- package/scaffold/.agent/skills/total-recall/evals/evals.json +20 -0
- package/scaffold/.agent/skills/total-recall/memory-vault/concepts/cli-help-reference.md +145 -0
- package/scaffold/.agent/skills/total-recall/memory-vault/invariants/operating-instructions.md +75 -0
- package/scaffold/.agent/skills/total-recall/memory-vault/preferences/always-websearch-gap.md +16 -0
- package/scaffold/.agent/skills/total-recall/memory-vault/preferences/topic-research-sop.md +17 -0
- package/scaffold/.agent/skills/total-recall/references/api-reference.md +187 -0
- package/scaffold/.agent/skills/total-recall/references/architecture-reference.md +81 -0
- package/scaffold/.agent/skills/total-recall/references/cli-reference.md +103 -0
- package/scaffold/.agent/skills/total-recall/references/ssss-reference.md +88 -0
- package/scaffold/.agent/skills/total-recall/references/troubleshooting.md +68 -0
- package/scaffold/.agent/skills/total-recall/scripts/sync-repo.mjs +83 -0
- package/scaffold/.agent/skills/total-recall/skills/cli-agents/SKILL.md +122 -0
- package/scaffold/.agent/skills/total-recall/skills/cli-agents/agents.yml +27 -0
- package/scaffold/.agent/skills/total-recall/skills/cli-agents/references/antigravity.txt +20 -0
- package/scaffold/.agent/skills/total-recall/skills/cli-agents/references/claude.txt +58 -0
- package/scaffold/.agent/skills/total-recall/skills/cli-agents/references/codex.txt +81 -0
- package/scaffold/.agent/skills/total-recall/skills/cli-agents/references/gemini.txt +44 -0
- package/scaffold/.agent/skills/total-recall/skills/research/SKILL.md +337 -0
- package/scaffold/.agent/skills/total-recall/skills/skill/SKILL.md +281 -0
- package/scaffold/.agent/skills/total-recall/skills/skill/assets/skill-template/SKILL.md +20 -0
- package/scaffold/.agent/skills/total-recall/skills/skill/evals/evals.json +20 -0
- package/scaffold/.agent/skills/total-recall/skills/skill/hooks/on-skill-add.sh +14 -0
- package/scaffold/.agent/skills/total-recall/skills/skill/hooks/on-skill-delete.sh +14 -0
- package/scaffold/.agent/skills/total-recall/skills/skill/learnings/.gitkeep +0 -0
- package/scaffold/.agent/skills/total-recall/skills/skill/references/.gitkeep +0 -0
- package/scaffold/.agent/skills/total-recall/skills/skill/scripts/.gitkeep +0 -0
- package/scaffold/.agent/skills/total-recall/skills/skill/scripts/create-skill.sh +65 -0
- package/scaffold/.agent/skills/total-recall/skills/skill/scripts/enforce-skill-optimization.mjs +135 -0
- package/scaffold/.agent/skills/total-recall/skills/skill/scripts/find-skills.mjs +124 -0
- package/scaffold/.agent/skills/total-recall/skills/skill/scripts/scan-skill.mjs +207 -0
- package/scaffold/.agent/skills/total-recall/skills/skill/scripts/skill-watcher.mjs +64 -0
- package/scaffold/.agent/skills/total-recall/skills/skill/subagents/audit-all-skills.md +62 -0
- package/scaffold/.agent/skills/total-recall/skills/ssss/SKILL.md +27 -0
- package/scaffold/.agent/skills/total-recall/skills/ssss/evals/evals.json +17 -0
- package/scaffold/.agent/skills/total-recall/skills/ssss/references/ssss-spec.md +117 -0
- package/scaffold/.agent/skills/total-recall/skills/ssss/scripts/validate-schema.mjs +54 -0
- package/scaffold/.agent/skills/total-recall/skills/ssss/subagents/audit-ssss-node.md +9 -0
- package/scaffold/.agent/skills/total-recall/subagents/total-recall-diagnostician.md +13 -0
- package/src/cli/agent-dir.mjs +105 -0
- package/src/cli/backup.mjs +313 -0
- package/src/cli/brain.mjs +224 -0
- package/src/cli/chat.mjs +74 -0
- package/src/cli/config.mjs +247 -0
- package/src/cli/connect.mjs +872 -0
- package/src/cli/daemon.mjs +228 -0
- package/src/cli/deploy-ui.mjs +1793 -0
- package/src/cli/deploy.mjs +1726 -0
- package/src/cli/doctor.mjs +254 -0
- package/src/cli/dream.mjs +80 -0
- package/src/cli/forget.mjs +92 -0
- package/src/cli/friction.mjs +21 -0
- package/src/cli/generate-pat.mjs +65 -0
- package/src/cli/hash-password.mjs +22 -0
- package/src/cli/help.mjs +153 -0
- package/src/cli/import-rules.mjs +102 -0
- package/src/cli/ingest/index.mjs +318 -0
- package/src/cli/ingest/parsers/chrome-bookmarks.mjs +238 -0
- package/src/cli/ingest/parsers/google-keep.mjs +147 -0
- package/src/cli/ingest/parsers/search-history.mjs +142 -0
- package/src/cli/ingest/parsers/youtube-history.mjs +181 -0
- package/src/cli/ingest/utils/dedup.mjs +69 -0
- package/src/cli/ingest/utils/takeout-walker.mjs +101 -0
- package/src/cli/ingest.mjs +102 -0
- package/src/cli/init.mjs +736 -0
- package/src/cli/integration-dispatcher.mjs +128 -0
- package/src/cli/lint.mjs +166 -0
- package/src/cli/map.mjs +194 -0
- package/src/cli/migrate.mjs +87 -0
- package/src/cli/rebuild.mjs +88 -0
- package/src/cli/recall.mjs +219 -0
- package/src/cli/relay.mjs +518 -0
- package/src/cli/remember.mjs +306 -0
- package/src/cli/research.mjs +381 -0
- package/src/cli/reset-password.mjs +75 -0
- package/src/cli/restore.mjs +135 -0
- package/src/cli/setup.mjs +616 -0
- package/src/cli/share.mjs +180 -0
- package/src/cli/skill.mjs +313 -0
- package/src/cli/snapshot.mjs +108 -0
- package/src/cli/start.mjs +63 -0
- package/src/cli/status.mjs +296 -0
- package/src/cli/sync.mjs +383 -0
- package/src/cli/uninstall.mjs +613 -0
- package/src/cli/upgrade.mjs +141 -0
- package/src/cli/wizard.html +3682 -0
- package/src/core/blackboard.mjs +54 -0
- package/src/core/clarity-rewriter.mjs +770 -0
- package/src/core/conclusion-writer.mjs +259 -0
- package/src/core/config.mjs +277 -0
- package/src/core/conflict-detector.mjs +536 -0
- package/src/core/crypto.mjs +107 -0
- package/src/core/daemon-control.mjs +158 -0
- package/src/core/daemon-loop.mjs +364 -0
- package/src/core/dream.mjs +347 -0
- package/src/core/drift-detector.mjs +143 -0
- package/src/core/embeddings.mjs +632 -0
- package/src/core/emergency-alerts.mjs +137 -0
- package/src/core/evolution.mjs +107 -0
- package/src/core/fact-seeker.mjs +1602 -0
- package/src/core/friction.mjs +95 -0
- package/src/core/import-rules.mjs +308 -0
- package/src/core/inference-engine.mjs +367 -0
- package/src/core/logger.mjs +75 -0
- package/src/core/memory-layers.mjs +96 -0
- package/src/core/migrate.mjs +116 -0
- package/src/core/notifications.mjs +77 -0
- package/src/core/operation-validator.mjs +272 -0
- package/src/core/optimizer.mjs +330 -0
- package/src/core/pattern_detector.mjs +113 -0
- package/src/core/post-mortem.mjs +413 -0
- package/src/core/protect-instructions.mjs +148 -0
- package/src/core/quick-capture.mjs +145 -0
- package/src/core/research-queue.mjs +431 -0
- package/src/core/research.mjs +461 -0
- package/src/core/runtime.mjs +515 -0
- package/src/core/sandbox.mjs +163 -0
- package/src/core/scheduler.mjs +569 -0
- package/src/core/schema.mjs +333 -0
- package/src/core/search.mjs +219 -0
- package/src/core/semantic-index.mjs +276 -0
- package/src/core/session-watcher.mjs +850 -0
- package/src/core/snapshot.mjs +133 -0
- package/src/core/source-adapters.mjs +1038 -0
- package/src/core/steering.mjs +137 -0
- package/src/core/surface.mjs +444 -0
- package/src/core/task_runner.mjs +125 -0
- package/src/core/tts.mjs +142 -0
- package/src/core/usage-tracker.mjs +349 -0
- package/src/core/vault-cache.mjs +76 -0
- package/src/core/vault.mjs +273 -0
- package/src/core/watchdog.mjs +236 -0
- package/src/server/api.mjs +944 -0
- package/src/server/auth.mjs +410 -0
- package/src/server/index.mjs +868 -0
- package/src/server/keys.mjs +181 -0
- package/src/server/rest.mjs +1652 -0
- package/src/server/route-manifest.json +77 -0
- package/src/server/routes/_shared.mjs +93 -0
- package/src/server/routes/keys.mjs +55 -0
- package/src/server/routes/memory.mjs +249 -0
- package/src/server/routes/sessions.mjs +336 -0
- package/src/server/routes/share.mjs +138 -0
- package/src/server/tools.mjs +618 -0
- package/templates/Caddyfile +37 -0
- package/templates/com.totalrecall.backup.plist +42 -0
- package/templates/com.totalrecall.daemon.plist +40 -0
- package/templates/com.totalrecall.duckdns.plist +23 -0
- package/templates/com.totalrecall.server.plist +46 -0
- package/templates/default-config/budget.yml +6 -0
- package/templates/default-config/runtime.yml +11 -0
- package/templates/default-config/security.yml +53 -0
- package/templates/default-config/voice.yml +34 -0
- package/templates/obsidian-queries/conflicts.md +26 -0
- package/templates/obsidian-queries/daily.md +17 -0
- package/templates/obsidian-queries/index.md +38 -0
- package/templates/obsidian-queries/skills.md +14 -0
- package/templates/onboarding-interview.md +72 -0
- package/templates/skill_engineering.md +40 -0
- package/templates/total-recall-daemon.service +21 -0
- package/templates/total-recall-server.service +20 -0
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* google-keep.mjs — Parse Google Keep notes from Takeout export
|
|
3
|
+
*
|
|
4
|
+
* Reads JSON files from the "Keep/" directory and converts each note
|
|
5
|
+
* into an SSSS-compatible memory node. Notes are categorized as 'lore'
|
|
6
|
+
* since they represent the user's own words and thoughts.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import fs from 'node:fs';
|
|
10
|
+
import crypto from 'node:crypto';
|
|
11
|
+
import { collectFiles } from '../utils/takeout-walker.mjs';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Generate a slug-safe string from a title.
|
|
15
|
+
*
|
|
16
|
+
* @param {string} title
|
|
17
|
+
* @returns {string}
|
|
18
|
+
*/
|
|
19
|
+
function slugify(title) {
|
|
20
|
+
return title
|
|
21
|
+
.toLowerCase()
|
|
22
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
23
|
+
.replace(/^-|-$/g, '')
|
|
24
|
+
.slice(0, 50);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Convert a Google Keep microsecond timestamp to ISO 8601.
|
|
29
|
+
*
|
|
30
|
+
* @param {number} usec — Timestamp in microseconds since epoch.
|
|
31
|
+
* @returns {string} ISO 8601 datetime string.
|
|
32
|
+
*/
|
|
33
|
+
function usecToIso(usec) {
|
|
34
|
+
if (!usec || typeof usec !== 'number') return new Date().toISOString();
|
|
35
|
+
return new Date(usec / 1000).toISOString();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Parse Google Keep notes from the given directory path.
|
|
40
|
+
*
|
|
41
|
+
* Each Keep JSON file represents a single note with fields:
|
|
42
|
+
* - title: Note title
|
|
43
|
+
* - textContent: Note body text
|
|
44
|
+
* - labels: Array of { name: string } label objects
|
|
45
|
+
* - color: Note color
|
|
46
|
+
* - createdTimestampUsec: Creation time in microseconds
|
|
47
|
+
* - userEditedTimestampUsec: Last edit time in microseconds
|
|
48
|
+
* - isTrashed / isArchived: Status flags
|
|
49
|
+
* - listContent: Array of { text, isChecked } for list notes
|
|
50
|
+
*
|
|
51
|
+
* @param {string} dirPath — Path to "Keep/" directory.
|
|
52
|
+
* @param {object} [options]
|
|
53
|
+
* @param {Date} [options.maxAge] — Only include notes newer than this date.
|
|
54
|
+
* @returns {object[]} Array of SSSS-compatible memory node objects.
|
|
55
|
+
*/
|
|
56
|
+
export function parseGoogleKeep(dirPath, options = {}) {
|
|
57
|
+
const jsonFiles = collectFiles(dirPath, ['.json']);
|
|
58
|
+
if (jsonFiles.length === 0) return [];
|
|
59
|
+
|
|
60
|
+
const nodes = [];
|
|
61
|
+
|
|
62
|
+
for (const file of jsonFiles) {
|
|
63
|
+
let note;
|
|
64
|
+
try {
|
|
65
|
+
const raw = fs.readFileSync(file, 'utf8');
|
|
66
|
+
note = JSON.parse(raw);
|
|
67
|
+
} catch {
|
|
68
|
+
continue; // Skip unparseable files
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Skip trashed and archived notes
|
|
72
|
+
if (note.isTrashed || note.isArchived) continue;
|
|
73
|
+
|
|
74
|
+
// Build body text from either textContent or listContent
|
|
75
|
+
let bodyText = note.textContent || '';
|
|
76
|
+
if (!bodyText && note.listContent && Array.isArray(note.listContent)) {
|
|
77
|
+
bodyText = note.listContent
|
|
78
|
+
.map(item => `${item.isChecked ? '☑' : '☐'} ${item.text || ''}`)
|
|
79
|
+
.join('\n');
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Skip empty notes
|
|
83
|
+
if (!bodyText.trim() && !note.title) continue;
|
|
84
|
+
|
|
85
|
+
const createdTs = usecToIso(note.createdTimestampUsec);
|
|
86
|
+
const updatedTs = usecToIso(note.userEditedTimestampUsec || note.createdTimestampUsec);
|
|
87
|
+
|
|
88
|
+
// Filter by max age
|
|
89
|
+
if (options.maxAge && new Date(createdTs) < options.maxAge) continue;
|
|
90
|
+
|
|
91
|
+
const title = note.title || bodyText.slice(0, 80).replace(/\n/g, ' ');
|
|
92
|
+
const suffix = crypto.randomBytes(3).toString('hex');
|
|
93
|
+
const slugBase = slugify(title) || 'keep-note';
|
|
94
|
+
const slug = `keep-${slugBase}-${suffix}`;
|
|
95
|
+
|
|
96
|
+
// Extract labels as tags
|
|
97
|
+
const labels = (note.labels || [])
|
|
98
|
+
.map(l => l.name)
|
|
99
|
+
.filter(Boolean)
|
|
100
|
+
.map(l => l.toLowerCase().replace(/[^a-z0-9]+/g, '-'));
|
|
101
|
+
const tags = ['google-takeout', 'google-keep', ...labels];
|
|
102
|
+
|
|
103
|
+
nodes.push({
|
|
104
|
+
type: 'memory',
|
|
105
|
+
slug,
|
|
106
|
+
title,
|
|
107
|
+
category: 'lore',
|
|
108
|
+
body: bodyText.trim(),
|
|
109
|
+
status: 'active',
|
|
110
|
+
confidence: 0.9,
|
|
111
|
+
importance: 3,
|
|
112
|
+
created: createdTs,
|
|
113
|
+
updated: updatedTs,
|
|
114
|
+
last_accessed: updatedTs,
|
|
115
|
+
source: {
|
|
116
|
+
type: 'google-keep',
|
|
117
|
+
session_id: `takeout-keep-${suffix}`,
|
|
118
|
+
evidence_count: 1,
|
|
119
|
+
},
|
|
120
|
+
supersedes: [],
|
|
121
|
+
superseded_by: null,
|
|
122
|
+
contradicts: [],
|
|
123
|
+
tags,
|
|
124
|
+
related: [],
|
|
125
|
+
routes_to_skills: [],
|
|
126
|
+
sentiment_polarity: 'descriptive',
|
|
127
|
+
sentiment_target: title.slice(0, 40),
|
|
128
|
+
modality: 'should',
|
|
129
|
+
subject: 'user',
|
|
130
|
+
predicate: 'noted',
|
|
131
|
+
object: title.slice(0, 120),
|
|
132
|
+
decay: { half_life_days: 120, access_count: 0 },
|
|
133
|
+
schema_version: 2,
|
|
134
|
+
x_temporal_context: createdTs,
|
|
135
|
+
x_citations: [{
|
|
136
|
+
source: 'google-keep',
|
|
137
|
+
title,
|
|
138
|
+
url: 'keep://note',
|
|
139
|
+
published: createdTs,
|
|
140
|
+
relevance: 1.0,
|
|
141
|
+
accessed: updatedTs,
|
|
142
|
+
}],
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return nodes;
|
|
147
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* search-history.mjs — Parse Google Search history from Takeout export
|
|
3
|
+
*
|
|
4
|
+
* Reads JSON files from "My Activity/Search/" and extracts search queries,
|
|
5
|
+
* grouping related searches into SSSS-compatible memory nodes.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import fs from 'node:fs';
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
import crypto from 'node:crypto';
|
|
11
|
+
import { collectFiles } from '../utils/takeout-walker.mjs';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Extract the raw search query from a Takeout search entry title.
|
|
15
|
+
* Takeout entries typically use "Searched for <query>" format.
|
|
16
|
+
*
|
|
17
|
+
* @param {string} title — The raw title string from the JSON entry.
|
|
18
|
+
* @returns {string} The extracted search query.
|
|
19
|
+
*/
|
|
20
|
+
function extractQuery(title) {
|
|
21
|
+
if (!title) return '';
|
|
22
|
+
return title.replace(/^Searched for\s+/i, '').trim();
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Generate a slug-safe string from a query.
|
|
27
|
+
*
|
|
28
|
+
* @param {string} query
|
|
29
|
+
* @returns {string}
|
|
30
|
+
*/
|
|
31
|
+
function slugify(query) {
|
|
32
|
+
return query
|
|
33
|
+
.toLowerCase()
|
|
34
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
35
|
+
.replace(/^-|-$/g, '')
|
|
36
|
+
.slice(0, 50);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Parse Google Search history from the given directory path.
|
|
41
|
+
*
|
|
42
|
+
* Reads all JSON files under the search history directory. Each file
|
|
43
|
+
* may contain a top-level array of search entries or a single
|
|
44
|
+
* MyActivity.json with nested structure.
|
|
45
|
+
*
|
|
46
|
+
* Each entry typically has: { title, titleUrl, time }
|
|
47
|
+
*
|
|
48
|
+
* @param {string} dirPath — Path to "My Activity/Search/" directory.
|
|
49
|
+
* @param {object} [options]
|
|
50
|
+
* @param {Date} [options.maxAge] — Only include entries newer than this date.
|
|
51
|
+
* @returns {object[]} Array of SSSS-compatible memory node objects.
|
|
52
|
+
*/
|
|
53
|
+
export function parseSearchHistory(dirPath, options = {}) {
|
|
54
|
+
const jsonFiles = collectFiles(dirPath, ['.json']);
|
|
55
|
+
if (jsonFiles.length === 0) return [];
|
|
56
|
+
|
|
57
|
+
const allEntries = [];
|
|
58
|
+
|
|
59
|
+
for (const file of jsonFiles) {
|
|
60
|
+
try {
|
|
61
|
+
const raw = fs.readFileSync(file, 'utf8');
|
|
62
|
+
const data = JSON.parse(raw);
|
|
63
|
+
|
|
64
|
+
// Takeout exports search history as a top-level array
|
|
65
|
+
const entries = Array.isArray(data) ? data : [];
|
|
66
|
+
allEntries.push(...entries);
|
|
67
|
+
} catch {
|
|
68
|
+
// Skip unparseable files
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Filter by max age if specified
|
|
73
|
+
const filtered = options.maxAge
|
|
74
|
+
? allEntries.filter(e => e.time && new Date(e.time) >= options.maxAge)
|
|
75
|
+
: allEntries;
|
|
76
|
+
|
|
77
|
+
// Sort by time, most recent first
|
|
78
|
+
filtered.sort((a, b) => {
|
|
79
|
+
const ta = a.time ? new Date(a.time).getTime() : 0;
|
|
80
|
+
const tb = b.time ? new Date(b.time).getTime() : 0;
|
|
81
|
+
return tb - ta;
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
// Convert each search entry into an SSSS node
|
|
85
|
+
const nodes = [];
|
|
86
|
+
const seenQueries = new Set();
|
|
87
|
+
|
|
88
|
+
for (const entry of filtered) {
|
|
89
|
+
const query = extractQuery(entry.title);
|
|
90
|
+
if (!query || seenQueries.has(query.toLowerCase())) continue;
|
|
91
|
+
seenQueries.add(query.toLowerCase());
|
|
92
|
+
|
|
93
|
+
const ts = entry.time ? new Date(entry.time).toISOString() : new Date().toISOString();
|
|
94
|
+
const suffix = crypto.randomBytes(3).toString('hex');
|
|
95
|
+
const slugBase = slugify(query) || 'search';
|
|
96
|
+
const slug = `search-${slugBase}-${suffix}`;
|
|
97
|
+
|
|
98
|
+
nodes.push({
|
|
99
|
+
type: 'memory',
|
|
100
|
+
slug,
|
|
101
|
+
title: query,
|
|
102
|
+
category: 'facts',
|
|
103
|
+
body: `Search query: ${query}${entry.titleUrl ? `\nURL: ${entry.titleUrl}` : ''}`,
|
|
104
|
+
status: 'active',
|
|
105
|
+
confidence: 0.7,
|
|
106
|
+
importance: 2,
|
|
107
|
+
created: ts,
|
|
108
|
+
updated: ts,
|
|
109
|
+
last_accessed: ts,
|
|
110
|
+
source: {
|
|
111
|
+
type: 'google-search-history',
|
|
112
|
+
session_id: `takeout-search-${suffix}`,
|
|
113
|
+
evidence_count: 1,
|
|
114
|
+
},
|
|
115
|
+
supersedes: [],
|
|
116
|
+
superseded_by: null,
|
|
117
|
+
contradicts: [],
|
|
118
|
+
tags: ['google-takeout', 'search-history'],
|
|
119
|
+
related: [],
|
|
120
|
+
routes_to_skills: [],
|
|
121
|
+
sentiment_polarity: 'descriptive',
|
|
122
|
+
sentiment_target: query.slice(0, 40),
|
|
123
|
+
modality: 'should',
|
|
124
|
+
subject: 'user',
|
|
125
|
+
predicate: 'searched_for',
|
|
126
|
+
object: query.slice(0, 120),
|
|
127
|
+
decay: { half_life_days: 60, access_count: 0 },
|
|
128
|
+
schema_version: 2,
|
|
129
|
+
x_temporal_context: ts,
|
|
130
|
+
x_citations: entry.titleUrl ? [{
|
|
131
|
+
source: 'google-search',
|
|
132
|
+
title: query,
|
|
133
|
+
url: entry.titleUrl,
|
|
134
|
+
published: ts,
|
|
135
|
+
relevance: 0.8,
|
|
136
|
+
accessed: ts,
|
|
137
|
+
}] : [],
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return nodes;
|
|
142
|
+
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* youtube-history.mjs — Parse YouTube watch history from Takeout export
|
|
3
|
+
*
|
|
4
|
+
* Reads JSON files from "YouTube and YouTube Music/history/" and converts
|
|
5
|
+
* video watch entries into SSSS-compatible memory nodes, grouping by channel.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import fs from 'node:fs';
|
|
9
|
+
import crypto from 'node:crypto';
|
|
10
|
+
import { collectFiles } from '../utils/takeout-walker.mjs';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Generate a slug-safe string from text.
|
|
14
|
+
*
|
|
15
|
+
* @param {string} text
|
|
16
|
+
* @returns {string}
|
|
17
|
+
*/
|
|
18
|
+
function slugify(text) {
|
|
19
|
+
return text
|
|
20
|
+
.toLowerCase()
|
|
21
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
22
|
+
.replace(/^-|-$/g, '')
|
|
23
|
+
.slice(0, 50);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Extract the video title from a Takeout entry.
|
|
28
|
+
* YouTube entries use "Watched <title>" format.
|
|
29
|
+
*
|
|
30
|
+
* @param {string} title
|
|
31
|
+
* @returns {string}
|
|
32
|
+
*/
|
|
33
|
+
function extractVideoTitle(title) {
|
|
34
|
+
if (!title) return '';
|
|
35
|
+
return title.replace(/^Watched\s+/i, '').trim();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Extract channel name from a Takeout entry's subtitles array.
|
|
40
|
+
*
|
|
41
|
+
* @param {object} entry — A YouTube history entry.
|
|
42
|
+
* @returns {string}
|
|
43
|
+
*/
|
|
44
|
+
function extractChannel(entry) {
|
|
45
|
+
if (entry.subtitles && Array.isArray(entry.subtitles)) {
|
|
46
|
+
for (const sub of entry.subtitles) {
|
|
47
|
+
if (sub.name) return sub.name;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return 'Unknown Channel';
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Extract YouTube video URL from a Takeout entry.
|
|
55
|
+
*
|
|
56
|
+
* @param {object} entry
|
|
57
|
+
* @returns {string|null}
|
|
58
|
+
*/
|
|
59
|
+
function extractVideoUrl(entry) {
|
|
60
|
+
return entry.titleUrl || null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Parse YouTube watch history from the given directory path.
|
|
65
|
+
*
|
|
66
|
+
* Each entry in the JSON has:
|
|
67
|
+
* - title: "Watched <video title>"
|
|
68
|
+
* - titleUrl: YouTube video URL
|
|
69
|
+
* - subtitles: [{ name: "Channel Name", url: "..." }]
|
|
70
|
+
* - time: ISO 8601 timestamp
|
|
71
|
+
* - products: ["YouTube"]
|
|
72
|
+
* - activityControls: [...]
|
|
73
|
+
*
|
|
74
|
+
* @param {string} dirPath — Path to "YouTube and YouTube Music/history/" directory.
|
|
75
|
+
* @param {object} [options]
|
|
76
|
+
* @param {Date} [options.maxAge] — Only include entries newer than this date.
|
|
77
|
+
* @returns {object[]} Array of SSSS-compatible memory node objects.
|
|
78
|
+
*/
|
|
79
|
+
export function parseYoutubeHistory(dirPath, options = {}) {
|
|
80
|
+
const jsonFiles = collectFiles(dirPath, ['.json']);
|
|
81
|
+
if (jsonFiles.length === 0) return [];
|
|
82
|
+
|
|
83
|
+
const allEntries = [];
|
|
84
|
+
|
|
85
|
+
for (const file of jsonFiles) {
|
|
86
|
+
try {
|
|
87
|
+
const raw = fs.readFileSync(file, 'utf8');
|
|
88
|
+
const data = JSON.parse(raw);
|
|
89
|
+
const entries = Array.isArray(data) ? data : [];
|
|
90
|
+
allEntries.push(...entries);
|
|
91
|
+
} catch {
|
|
92
|
+
// Skip unparseable files
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Filter out entries without titles (ads, etc.) and apply max age filter
|
|
97
|
+
const filtered = allEntries.filter(e => {
|
|
98
|
+
if (!e.title) return false;
|
|
99
|
+
// Skip ad-related entries
|
|
100
|
+
if (e.title === 'Visited YouTube' || e.title.startsWith('Searched for')) return false;
|
|
101
|
+
if (options.maxAge && e.time && new Date(e.time) < options.maxAge) return false;
|
|
102
|
+
return true;
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
// Sort by time, most recent first
|
|
106
|
+
filtered.sort((a, b) => {
|
|
107
|
+
const ta = a.time ? new Date(a.time).getTime() : 0;
|
|
108
|
+
const tb = b.time ? new Date(b.time).getTime() : 0;
|
|
109
|
+
return tb - ta;
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
// Convert each watch entry to an SSSS node
|
|
113
|
+
const nodes = [];
|
|
114
|
+
const seenUrls = new Set();
|
|
115
|
+
|
|
116
|
+
for (const entry of filtered) {
|
|
117
|
+
const videoTitle = extractVideoTitle(entry.title);
|
|
118
|
+
if (!videoTitle) continue;
|
|
119
|
+
|
|
120
|
+
const url = extractVideoUrl(entry);
|
|
121
|
+
// Deduplicate by URL within this batch
|
|
122
|
+
if (url && seenUrls.has(url)) continue;
|
|
123
|
+
if (url) seenUrls.add(url);
|
|
124
|
+
|
|
125
|
+
const channel = extractChannel(entry);
|
|
126
|
+
const ts = entry.time ? new Date(entry.time).toISOString() : new Date().toISOString();
|
|
127
|
+
const suffix = crypto.randomBytes(3).toString('hex');
|
|
128
|
+
const slugBase = slugify(videoTitle) || 'youtube';
|
|
129
|
+
const slug = `youtube-${slugBase}-${suffix}`;
|
|
130
|
+
|
|
131
|
+
const channelTag = channel
|
|
132
|
+
? channel.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')
|
|
133
|
+
: '';
|
|
134
|
+
const tags = ['google-takeout', 'youtube-history'];
|
|
135
|
+
if (channelTag) tags.push(channelTag);
|
|
136
|
+
|
|
137
|
+
nodes.push({
|
|
138
|
+
type: 'memory',
|
|
139
|
+
slug,
|
|
140
|
+
title: videoTitle,
|
|
141
|
+
category: 'facts',
|
|
142
|
+
body: `Video: ${videoTitle}\nChannel: ${channel}${url ? `\nURL: ${url}` : ''}\nWatched: ${ts}`,
|
|
143
|
+
status: 'active',
|
|
144
|
+
confidence: 0.7,
|
|
145
|
+
importance: 2,
|
|
146
|
+
created: ts,
|
|
147
|
+
updated: ts,
|
|
148
|
+
last_accessed: ts,
|
|
149
|
+
source: {
|
|
150
|
+
type: 'youtube-history',
|
|
151
|
+
session_id: `takeout-youtube-${suffix}`,
|
|
152
|
+
evidence_count: 1,
|
|
153
|
+
},
|
|
154
|
+
supersedes: [],
|
|
155
|
+
superseded_by: null,
|
|
156
|
+
contradicts: [],
|
|
157
|
+
tags,
|
|
158
|
+
related: [],
|
|
159
|
+
routes_to_skills: [],
|
|
160
|
+
sentiment_polarity: 'descriptive',
|
|
161
|
+
sentiment_target: videoTitle.slice(0, 40),
|
|
162
|
+
modality: 'should',
|
|
163
|
+
subject: 'user',
|
|
164
|
+
predicate: 'watched',
|
|
165
|
+
object: videoTitle.slice(0, 120),
|
|
166
|
+
decay: { half_life_days: 60, access_count: 0 },
|
|
167
|
+
schema_version: 2,
|
|
168
|
+
x_temporal_context: ts,
|
|
169
|
+
x_citations: url ? [{
|
|
170
|
+
source: 'youtube',
|
|
171
|
+
title: videoTitle,
|
|
172
|
+
url,
|
|
173
|
+
published: ts,
|
|
174
|
+
relevance: 0.8,
|
|
175
|
+
accessed: ts,
|
|
176
|
+
}] : [],
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return nodes;
|
|
181
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dedup.mjs — SHA-256 based deduplication for memory nodes
|
|
3
|
+
*
|
|
4
|
+
* Hashes each node's body content and compares against existing vault
|
|
5
|
+
* nodes to filter out duplicates before writing.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import crypto from 'node:crypto';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Compute a SHA-256 hex digest of a string.
|
|
12
|
+
*
|
|
13
|
+
* @param {string} content — The content to hash.
|
|
14
|
+
* @returns {string} Hex-encoded SHA-256 hash.
|
|
15
|
+
*/
|
|
16
|
+
export function sha256(content) {
|
|
17
|
+
return crypto.createHash('sha256').update(content).digest('hex');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Build a content fingerprint for a memory node.
|
|
22
|
+
* Uses the body plus key metadata fields so that nodes with
|
|
23
|
+
* identical content but different slugs are still detected as dupes.
|
|
24
|
+
*
|
|
25
|
+
* @param {object} node — An SSSS-compatible memory node object.
|
|
26
|
+
* @returns {string} Hex-encoded SHA-256 hash.
|
|
27
|
+
*/
|
|
28
|
+
function nodeFingerprint(node) {
|
|
29
|
+
const parts = [
|
|
30
|
+
node.body || '',
|
|
31
|
+
node.title || '',
|
|
32
|
+
node.source?.type || '',
|
|
33
|
+
];
|
|
34
|
+
return sha256(parts.join('\n'));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Deduplicate new nodes against existing vault nodes.
|
|
39
|
+
*
|
|
40
|
+
* Hashes each new node's body + metadata and compares against hashes of
|
|
41
|
+
* existing nodes. Returns only nodes that have no matching fingerprint
|
|
42
|
+
* in the existing set.
|
|
43
|
+
*
|
|
44
|
+
* @param {object[]} newNodes — Candidate nodes to ingest.
|
|
45
|
+
* @param {object[]} existingNodes — Already-stored vault nodes.
|
|
46
|
+
* @returns {{ unique: object[], duplicateCount: number }}
|
|
47
|
+
*/
|
|
48
|
+
export function dedup(newNodes, existingNodes) {
|
|
49
|
+
// Build a set of existing fingerprints
|
|
50
|
+
const existingHashes = new Set();
|
|
51
|
+
for (const node of existingNodes) {
|
|
52
|
+
existingHashes.add(nodeFingerprint(node));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const unique = [];
|
|
56
|
+
let duplicateCount = 0;
|
|
57
|
+
|
|
58
|
+
for (const node of newNodes) {
|
|
59
|
+
const fp = nodeFingerprint(node);
|
|
60
|
+
if (existingHashes.has(fp)) {
|
|
61
|
+
duplicateCount++;
|
|
62
|
+
} else {
|
|
63
|
+
existingHashes.add(fp); // Prevent intra-batch dupes too
|
|
64
|
+
unique.push(node);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return { unique, duplicateCount };
|
|
69
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* takeout-walker.mjs — Walk a Google Takeout directory and detect available data types
|
|
3
|
+
*
|
|
4
|
+
* Recursively checks for known Takeout subdirectory signatures and returns
|
|
5
|
+
* which data sources are present along with their resolved paths.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import fs from 'node:fs';
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Known Takeout data type signatures.
|
|
13
|
+
* Each entry maps a type name to one or more directory path fragments
|
|
14
|
+
* (relative to the Takeout root) that indicate the data is present.
|
|
15
|
+
*/
|
|
16
|
+
const TAKEOUT_SIGNATURES = [
|
|
17
|
+
{
|
|
18
|
+
type: 'search-history',
|
|
19
|
+
/** Google Search history lives under "My Activity/Search/" */
|
|
20
|
+
paths: ['My Activity/Search'],
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
type: 'chrome-bookmarks',
|
|
24
|
+
/** Chrome bookmarks can be in "Chrome/Bookmarks.html" or "Chrome/Bookmarks.json" */
|
|
25
|
+
paths: ['Chrome'],
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
type: 'google-keep',
|
|
29
|
+
/** Google Keep notes are exported under "Keep/" */
|
|
30
|
+
paths: ['Keep'],
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
type: 'youtube-history',
|
|
34
|
+
/** YouTube watch history under "YouTube and YouTube Music/history/" */
|
|
35
|
+
paths: ['YouTube and YouTube Music/history'],
|
|
36
|
+
},
|
|
37
|
+
];
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Walk a Google Takeout root directory and return which data types are present.
|
|
41
|
+
*
|
|
42
|
+
* The function checks for the existence of known subdirectory signatures
|
|
43
|
+
* inside `rootPath`. Some Takeout exports nest everything under a top-level
|
|
44
|
+
* "Takeout/" directory — we handle that transparently.
|
|
45
|
+
*
|
|
46
|
+
* @param {string} rootPath — Absolute path to the Takeout export root.
|
|
47
|
+
* @returns {{ type: string, path: string }[]} Array of detected data sources.
|
|
48
|
+
*/
|
|
49
|
+
export function walkTakeoutDir(rootPath) {
|
|
50
|
+
if (!fs.existsSync(rootPath)) {
|
|
51
|
+
throw new Error(`Takeout directory does not exist: ${rootPath}`);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const stat = fs.statSync(rootPath);
|
|
55
|
+
if (!stat.isDirectory()) {
|
|
56
|
+
throw new Error(`Not a directory: ${rootPath}`);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Some exports nest everything under a "Takeout/" subdirectory.
|
|
60
|
+
const nestedTakeout = path.join(rootPath, 'Takeout');
|
|
61
|
+
const effectiveRoot = fs.existsSync(nestedTakeout) && fs.statSync(nestedTakeout).isDirectory()
|
|
62
|
+
? nestedTakeout
|
|
63
|
+
: rootPath;
|
|
64
|
+
|
|
65
|
+
const detected = [];
|
|
66
|
+
|
|
67
|
+
for (const sig of TAKEOUT_SIGNATURES) {
|
|
68
|
+
for (const relPath of sig.paths) {
|
|
69
|
+
const fullPath = path.join(effectiveRoot, relPath);
|
|
70
|
+
if (fs.existsSync(fullPath) && fs.statSync(fullPath).isDirectory()) {
|
|
71
|
+
detected.push({ type: sig.type, path: fullPath });
|
|
72
|
+
break; // Only add each type once
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return detected;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Recursively collect all files matching given extensions within a directory.
|
|
82
|
+
*
|
|
83
|
+
* @param {string} dir — Directory to search.
|
|
84
|
+
* @param {string[]} extensions — File extensions to include (e.g. ['.json', '.html']).
|
|
85
|
+
* @returns {string[]} Array of absolute file paths.
|
|
86
|
+
*/
|
|
87
|
+
export function collectFiles(dir, extensions) {
|
|
88
|
+
const results = [];
|
|
89
|
+
if (!fs.existsSync(dir)) return results;
|
|
90
|
+
|
|
91
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
92
|
+
for (const entry of entries) {
|
|
93
|
+
const fullPath = path.join(dir, entry.name);
|
|
94
|
+
if (entry.isDirectory()) {
|
|
95
|
+
results.push(...collectFiles(fullPath, extensions));
|
|
96
|
+
} else if (extensions.some(ext => entry.name.endsWith(ext))) {
|
|
97
|
+
results.push(fullPath);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return results;
|
|
101
|
+
}
|