@ouro.bot/cli 0.1.0-alpha.137 → 0.1.0-alpha.139
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.json +23 -0
- package/dist/heart/daemon/daemon-entry.js +26 -1
- package/dist/heart/daemon/hatch-flow.js +8 -8
- package/dist/heart/daemon/heartbeat-timer.js +131 -0
- package/dist/mind/associative-recall.js +108 -30
- package/dist/mind/bundle-manifest.js +2 -0
- package/dist/mind/context.js +2 -0
- package/dist/mind/{memory.js → diary.js} +45 -32
- package/dist/mind/journal-index.js +161 -0
- package/dist/mind/prompt.js +92 -12
- package/dist/repertoire/tools-base.js +43 -20
- package/dist/senses/contextual-heartbeat.js +102 -0
- package/dist/senses/inner-dialog.js +45 -1
- package/package.json +1 -1
package/changelog.json
CHANGED
|
@@ -1,6 +1,29 @@
|
|
|
1
1
|
{
|
|
2
2
|
"_note": "This changelog is maintained as part of the PR/version-bump workflow. Agent-curated, not auto-generated. Agents read this file directly via read_file to understand what changed between versions.",
|
|
3
3
|
"versions": [
|
|
4
|
+
{
|
|
5
|
+
"version": "0.1.0-alpha.139",
|
|
6
|
+
"changes": [
|
|
7
|
+
"Daemon-internal heartbeat timer: self-correcting setTimeout loop reads cadence from heartbeat task file, computes delay from lastCompletedAt, fires IPC {type: heartbeat}. One timer per managed agent.",
|
|
8
|
+
"Heartbeat fires immediately when agent is overdue (elapsed > cadence) or has never run (no runtime state).",
|
|
9
|
+
"Contextual heartbeat message: journal index (up to 10 files with recency and preview), elapsed time since last turn, pending attention count, journal entries since last surface, stale obligation alerts (>30 min).",
|
|
10
|
+
"Bare instinct prompt now cold-start fallback only; resumed heartbeat sessions get contextual message.",
|
|
11
|
+
"Journal embedding indexing piggybacked during heartbeat turns (best-effort, fire-and-forget)."
|
|
12
|
+
]
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
"version": "0.1.0-alpha.138",
|
|
16
|
+
"changes": [
|
|
17
|
+
"Memory renamed to diary: memory.ts → diary.ts, MemoryFact → DiaryEntry, memory_save → diary_write, memory_search → recall. All types, functions, events, and variables renamed throughout.",
|
|
18
|
+
"Diary path: diary/ (top-level) replaces psyche/memory/. Fallback reads from legacy path for existing bundles.",
|
|
19
|
+
"Journal workspace: journal/ directory for freeform thinking-in-progress. Agent writes with write_file, system reads for heartbeat context.",
|
|
20
|
+
"Unified recall tool: searches both diary entries and journal files. Results tagged [diary] or [journal].",
|
|
21
|
+
"Journal embeddings: file-level embeddings indexed during heartbeat via journal/.index.json sidecar.",
|
|
22
|
+
"Journal section in inner dialog system prompt: index of up to 10 most recently modified journal files with name, recency, and first-line preview.",
|
|
23
|
+
"Metacognitive framing updated: diary (record), journal (workspace), ponder/rest vocabulary, morning briefing encouragement.",
|
|
24
|
+
"Session migration: memory_save → diary_write, memory_search → recall added to migrateToolNames()."
|
|
25
|
+
]
|
|
26
|
+
},
|
|
4
27
|
{
|
|
5
28
|
"version": "0.1.0-alpha.137",
|
|
6
29
|
"changes": [
|
|
@@ -47,6 +47,7 @@ const sense_manager_1 = require("./sense-manager");
|
|
|
47
47
|
const agent_discovery_1 = require("./agent-discovery");
|
|
48
48
|
const identity_1 = require("../identity");
|
|
49
49
|
const runtime_mode_1 = require("./runtime-mode");
|
|
50
|
+
const heartbeat_timer_1 = require("./heartbeat-timer");
|
|
50
51
|
function parseSocketPath(argv) {
|
|
51
52
|
const socketIndex = argv.indexOf("--socket");
|
|
52
53
|
if (socketIndex >= 0) {
|
|
@@ -115,7 +116,27 @@ const daemon = new daemon_1.OuroDaemon({
|
|
|
115
116
|
router,
|
|
116
117
|
mode,
|
|
117
118
|
});
|
|
118
|
-
|
|
119
|
+
const heartbeatTimers = [];
|
|
120
|
+
/* v8 ignore start -- heartbeat wiring: lambdas delegate to processManager/fs; tested via HeartbeatTimer unit tests @preserve */
|
|
121
|
+
void daemon.start().then(() => {
|
|
122
|
+
const bundlesRoot = (0, identity_1.getAgentBundlesRoot)();
|
|
123
|
+
for (const agent of managedAgents) {
|
|
124
|
+
const bundleRoot = path.join(bundlesRoot, `${agent}.ouro`);
|
|
125
|
+
const timer = new heartbeat_timer_1.HeartbeatTimer({
|
|
126
|
+
agent,
|
|
127
|
+
sendToAgent: (a, msg) => processManager.sendToAgent(a, msg),
|
|
128
|
+
deps: {
|
|
129
|
+
readFileSync: (p, enc) => fs.readFileSync(p, enc),
|
|
130
|
+
readdirSync: (p) => fs.readdirSync(p).map((e) => (typeof e === "string" ? e : e)),
|
|
131
|
+
heartbeatTaskDir: path.join(bundleRoot, "tasks", "habits"),
|
|
132
|
+
runtimeStatePath: path.join(bundleRoot, "state", "sessions", "self", "inner", "runtime.json"),
|
|
133
|
+
},
|
|
134
|
+
});
|
|
135
|
+
timer.start();
|
|
136
|
+
heartbeatTimers.push(timer);
|
|
137
|
+
}
|
|
138
|
+
}).catch(async () => {
|
|
139
|
+
/* v8 ignore stop */
|
|
119
140
|
(0, runtime_1.emitNervesEvent)({
|
|
120
141
|
level: "error",
|
|
121
142
|
component: "daemon",
|
|
@@ -127,8 +148,12 @@ void daemon.start().catch(async () => {
|
|
|
127
148
|
process.exit(1);
|
|
128
149
|
});
|
|
129
150
|
process.on("SIGINT", () => {
|
|
151
|
+
for (const timer of heartbeatTimers)
|
|
152
|
+
timer.stop();
|
|
130
153
|
void daemon.stop().then(() => process.exit(0));
|
|
131
154
|
});
|
|
132
155
|
process.on("SIGTERM", () => {
|
|
156
|
+
for (const timer of heartbeatTimers)
|
|
157
|
+
timer.stop();
|
|
133
158
|
void daemon.stop().then(() => process.exit(0));
|
|
134
159
|
});
|
|
@@ -142,12 +142,12 @@ function writeFriendImprint(bundleRoot, humanName, now) {
|
|
|
142
142
|
};
|
|
143
143
|
fs.writeFileSync(path.join(friendsDir, `${id}.json`), `${JSON.stringify(record, null, 2)}\n`, "utf-8");
|
|
144
144
|
}
|
|
145
|
-
function
|
|
146
|
-
const
|
|
147
|
-
fs.mkdirSync(path.join(
|
|
148
|
-
fs.mkdirSync(path.join(
|
|
149
|
-
fs.writeFileSync(path.join(
|
|
150
|
-
fs.writeFileSync(path.join(
|
|
145
|
+
function writeDiaryScaffold(bundleRoot) {
|
|
146
|
+
const diaryRoot = path.join(bundleRoot, "diary");
|
|
147
|
+
fs.mkdirSync(path.join(diaryRoot, "daily"), { recursive: true });
|
|
148
|
+
fs.mkdirSync(path.join(diaryRoot, "archive"), { recursive: true });
|
|
149
|
+
fs.writeFileSync(path.join(diaryRoot, "facts.jsonl"), "", "utf-8");
|
|
150
|
+
fs.writeFileSync(path.join(diaryRoot, "entities.json"), "{}\n", "utf-8");
|
|
151
151
|
}
|
|
152
152
|
function writeHatchlingAgentConfig(bundleRoot, input) {
|
|
153
153
|
const template = (0, identity_1.buildDefaultAgentTemplate)(input.agentName);
|
|
@@ -183,7 +183,7 @@ async function runHatchFlow(input, deps = {}) {
|
|
|
183
183
|
fs.mkdirSync(bundleRoot, { recursive: true });
|
|
184
184
|
writeReadme(bundleRoot, "Root of this agent bundle.");
|
|
185
185
|
writeReadme(path.join(bundleRoot, "psyche"), "Identity and behavior files.");
|
|
186
|
-
writeReadme(path.join(bundleRoot, "
|
|
186
|
+
writeReadme(path.join(bundleRoot, "diary"), "Persistent diary — things I've learned and remember.");
|
|
187
187
|
writeReadme(path.join(bundleRoot, "friends"), "Known friend records.");
|
|
188
188
|
writeReadme(path.join(bundleRoot, "tasks"), "Task files.");
|
|
189
189
|
writeReadme(path.join(bundleRoot, "tasks", "habits"), "Recurring tasks.");
|
|
@@ -193,7 +193,7 @@ async function runHatchFlow(input, deps = {}) {
|
|
|
193
193
|
writeReadme(path.join(bundleRoot, "senses"), "Sense-specific config.");
|
|
194
194
|
writeReadme(path.join(bundleRoot, "senses", "teams"), "Teams sense config.");
|
|
195
195
|
writeHatchlingAgentConfig(bundleRoot, input);
|
|
196
|
-
|
|
196
|
+
writeDiaryScaffold(bundleRoot);
|
|
197
197
|
writeFriendImprint(bundleRoot, input.humanName, now);
|
|
198
198
|
writeHeartbeatTask(bundleRoot, now);
|
|
199
199
|
(0, runtime_1.emitNervesEvent)({
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.HeartbeatTimer = exports.DEFAULT_CADENCE_MS = void 0;
|
|
4
|
+
exports.parseCadenceMs = parseCadenceMs;
|
|
5
|
+
const runtime_1 = require("../../nerves/runtime");
|
|
6
|
+
const parser_1 = require("../../repertoire/tasks/parser");
|
|
7
|
+
exports.DEFAULT_CADENCE_MS = 30 * 60 * 1000; // 30 minutes
|
|
8
|
+
function parseCadenceMs(raw) {
|
|
9
|
+
if (typeof raw !== "string")
|
|
10
|
+
return null;
|
|
11
|
+
const value = raw.trim();
|
|
12
|
+
if (!value)
|
|
13
|
+
return null;
|
|
14
|
+
const match = /^(\d+)(m|h|d)$/.exec(value);
|
|
15
|
+
if (!match)
|
|
16
|
+
return null;
|
|
17
|
+
const interval = Number.parseInt(match[1], 10);
|
|
18
|
+
if (!Number.isFinite(interval) || interval <= 0)
|
|
19
|
+
return null;
|
|
20
|
+
const unit = match[2];
|
|
21
|
+
if (unit === "m")
|
|
22
|
+
return interval * 60 * 1000;
|
|
23
|
+
if (unit === "h")
|
|
24
|
+
return interval * 60 * 60 * 1000;
|
|
25
|
+
return interval * 24 * 60 * 60 * 1000;
|
|
26
|
+
}
|
|
27
|
+
class HeartbeatTimer {
|
|
28
|
+
agent;
|
|
29
|
+
sendToAgent;
|
|
30
|
+
readFileSync;
|
|
31
|
+
readdirSync;
|
|
32
|
+
heartbeatTaskDir;
|
|
33
|
+
runtimeStatePath;
|
|
34
|
+
now;
|
|
35
|
+
pendingTimer = null;
|
|
36
|
+
constructor(options) {
|
|
37
|
+
this.agent = options.agent;
|
|
38
|
+
this.sendToAgent = options.sendToAgent;
|
|
39
|
+
this.readFileSync = options.deps.readFileSync;
|
|
40
|
+
this.readdirSync = options.deps.readdirSync;
|
|
41
|
+
this.heartbeatTaskDir = options.deps.heartbeatTaskDir;
|
|
42
|
+
this.runtimeStatePath = options.deps.runtimeStatePath;
|
|
43
|
+
this.now = options.deps.now ?? (() => Date.now());
|
|
44
|
+
}
|
|
45
|
+
start() {
|
|
46
|
+
this.scheduleNext();
|
|
47
|
+
}
|
|
48
|
+
stop() {
|
|
49
|
+
if (this.pendingTimer !== null) {
|
|
50
|
+
clearTimeout(this.pendingTimer);
|
|
51
|
+
this.pendingTimer = null;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
scheduleNext() {
|
|
55
|
+
const cadenceMs = this.readCadence();
|
|
56
|
+
const lastCompletedAt = this.readLastCompletedAt();
|
|
57
|
+
const nowMs = this.now();
|
|
58
|
+
let delay;
|
|
59
|
+
if (lastCompletedAt === null) {
|
|
60
|
+
// Never run before — fire immediately
|
|
61
|
+
delay = 0;
|
|
62
|
+
}
|
|
63
|
+
else {
|
|
64
|
+
const elapsed = nowMs - lastCompletedAt;
|
|
65
|
+
delay = Math.max(0, cadenceMs - elapsed);
|
|
66
|
+
}
|
|
67
|
+
this.pendingTimer = setTimeout(() => {
|
|
68
|
+
this.fire();
|
|
69
|
+
}, delay);
|
|
70
|
+
}
|
|
71
|
+
fire() {
|
|
72
|
+
this.pendingTimer = null;
|
|
73
|
+
(0, runtime_1.emitNervesEvent)({
|
|
74
|
+
component: "daemon",
|
|
75
|
+
event: "daemon.heartbeat_fire",
|
|
76
|
+
message: "heartbeat timer fired",
|
|
77
|
+
meta: { agent: this.agent },
|
|
78
|
+
});
|
|
79
|
+
this.sendToAgent(this.agent, { type: "heartbeat" });
|
|
80
|
+
this.scheduleNext();
|
|
81
|
+
}
|
|
82
|
+
readCadence() {
|
|
83
|
+
// Scan habits dir for *-heartbeat.md
|
|
84
|
+
let files;
|
|
85
|
+
try {
|
|
86
|
+
files = this.readdirSync(this.heartbeatTaskDir);
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
return exports.DEFAULT_CADENCE_MS;
|
|
90
|
+
}
|
|
91
|
+
const heartbeatFile = files.find((f) => f.endsWith("-heartbeat.md"));
|
|
92
|
+
if (!heartbeatFile)
|
|
93
|
+
return exports.DEFAULT_CADENCE_MS;
|
|
94
|
+
try {
|
|
95
|
+
const content = this.readFileSync(`${this.heartbeatTaskDir}/${heartbeatFile}`, "utf-8");
|
|
96
|
+
const cadence = this.extractCadenceFromTaskFile(content);
|
|
97
|
+
return cadence ?? exports.DEFAULT_CADENCE_MS;
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
return exports.DEFAULT_CADENCE_MS;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
extractCadenceFromTaskFile(content) {
|
|
104
|
+
// Parse frontmatter from task file to get cadence value
|
|
105
|
+
const lines = content.split(/\r?\n/);
|
|
106
|
+
if (lines[0]?.trim() !== "---")
|
|
107
|
+
return null;
|
|
108
|
+
const closing = lines.findIndex((line, index) => index > 0 && line.trim() === "---");
|
|
109
|
+
if (closing === -1)
|
|
110
|
+
return null;
|
|
111
|
+
const rawFrontmatter = lines.slice(1, closing).join("\n");
|
|
112
|
+
const frontmatter = (0, parser_1.parseFrontmatter)(rawFrontmatter);
|
|
113
|
+
return parseCadenceMs(frontmatter.cadence);
|
|
114
|
+
}
|
|
115
|
+
readLastCompletedAt() {
|
|
116
|
+
try {
|
|
117
|
+
const raw = this.readFileSync(this.runtimeStatePath, "utf-8");
|
|
118
|
+
const state = JSON.parse(raw);
|
|
119
|
+
if (typeof state.lastCompletedAt !== "string")
|
|
120
|
+
return null;
|
|
121
|
+
const ms = new Date(state.lastCompletedAt).getTime();
|
|
122
|
+
if (Number.isNaN(ms))
|
|
123
|
+
return null;
|
|
124
|
+
return ms;
|
|
125
|
+
}
|
|
126
|
+
catch {
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
exports.HeartbeatTimer = HeartbeatTimer;
|
|
@@ -35,12 +35,13 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
36
|
exports.cosineSimilarity = cosineSimilarity;
|
|
37
37
|
exports.recallFactsForQuery = recallFactsForQuery;
|
|
38
|
+
exports.searchJournalIndex = searchJournalIndex;
|
|
38
39
|
exports.injectAssociativeRecall = injectAssociativeRecall;
|
|
39
40
|
const fs = __importStar(require("fs"));
|
|
40
41
|
const path = __importStar(require("path"));
|
|
41
42
|
const config_1 = require("../heart/config");
|
|
42
|
-
const identity_1 = require("../heart/identity");
|
|
43
43
|
const runtime_1 = require("../nerves/runtime");
|
|
44
|
+
const diary_1 = require("./diary");
|
|
44
45
|
const DEFAULT_EMBEDDING_MODEL = "text-embedding-3-small";
|
|
45
46
|
const DEFAULT_MIN_SCORE = 0.5;
|
|
46
47
|
const DEFAULT_TOP_K = 3;
|
|
@@ -80,8 +81,8 @@ function createDefaultProvider() {
|
|
|
80
81
|
}
|
|
81
82
|
return new OpenAIEmbeddingProvider(apiKey);
|
|
82
83
|
}
|
|
83
|
-
function readFacts(
|
|
84
|
-
const factsPath = path.join(
|
|
84
|
+
function readFacts(diaryRoot) {
|
|
85
|
+
const factsPath = path.join(diaryRoot, "facts.jsonl");
|
|
85
86
|
if (!fs.existsSync(factsPath))
|
|
86
87
|
return [];
|
|
87
88
|
const raw = fs.readFileSync(factsPath, "utf8").trim();
|
|
@@ -145,6 +146,40 @@ async function recallFactsForQuery(query, facts, provider, options) {
|
|
|
145
146
|
.sort((left, right) => right.score - left.score)
|
|
146
147
|
.slice(0, topK);
|
|
147
148
|
}
|
|
149
|
+
function readJournalIndex(journalDir) {
|
|
150
|
+
const indexPath = path.join(journalDir, ".index.json");
|
|
151
|
+
try {
|
|
152
|
+
const raw = fs.readFileSync(indexPath, "utf8");
|
|
153
|
+
const parsed = JSON.parse(raw);
|
|
154
|
+
if (!Array.isArray(parsed))
|
|
155
|
+
return [];
|
|
156
|
+
return parsed;
|
|
157
|
+
}
|
|
158
|
+
catch {
|
|
159
|
+
return [];
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
function searchJournalIndex(queryEmbedding, entries, options) {
|
|
163
|
+
const minScore = options?.minScore ?? DEFAULT_MIN_SCORE;
|
|
164
|
+
const topK = options?.topK ?? DEFAULT_TOP_K;
|
|
165
|
+
return entries
|
|
166
|
+
.filter((entry) => Array.isArray(entry.embedding) && entry.embedding.length > 0)
|
|
167
|
+
.map((entry) => ({
|
|
168
|
+
filename: entry.filename,
|
|
169
|
+
preview: entry.preview,
|
|
170
|
+
score: cosineSimilarity(queryEmbedding, entry.embedding),
|
|
171
|
+
}))
|
|
172
|
+
.filter((entry) => entry.score >= minScore)
|
|
173
|
+
.sort((left, right) => right.score - left.score)
|
|
174
|
+
.slice(0, topK);
|
|
175
|
+
}
|
|
176
|
+
function resolveJournalDir(diaryRoot, explicitJournalDir) {
|
|
177
|
+
if (explicitJournalDir)
|
|
178
|
+
return explicitJournalDir;
|
|
179
|
+
// journal/ is a sibling of diary/ at the agent root level
|
|
180
|
+
const agentRoot = path.dirname(diaryRoot);
|
|
181
|
+
return path.join(agentRoot, "journal");
|
|
182
|
+
}
|
|
148
183
|
async function injectAssociativeRecall(messages, options) {
|
|
149
184
|
try {
|
|
150
185
|
if (messages[0]?.role !== "system" || typeof messages[0].content !== "string")
|
|
@@ -152,37 +187,80 @@ async function injectAssociativeRecall(messages, options) {
|
|
|
152
187
|
const query = getLatestUserText(messages);
|
|
153
188
|
if (!query)
|
|
154
189
|
return;
|
|
155
|
-
const
|
|
156
|
-
const facts = readFacts(
|
|
157
|
-
|
|
190
|
+
const diaryRoot = options?.diaryRoot ?? (0, diary_1.resolveDiaryRoot)();
|
|
191
|
+
const facts = readFacts(diaryRoot);
|
|
192
|
+
const journalDir = resolveJournalDir(diaryRoot, options?.journalDir);
|
|
193
|
+
const journalEntries = readJournalIndex(journalDir);
|
|
194
|
+
if (facts.length === 0 && journalEntries.length === 0)
|
|
158
195
|
return;
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
.
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
196
|
+
// Build combined result lines tagged by source
|
|
197
|
+
const resultLines = [];
|
|
198
|
+
let queryEmbedding;
|
|
199
|
+
// Search diary entries
|
|
200
|
+
if (facts.length > 0) {
|
|
201
|
+
let recalled;
|
|
202
|
+
try {
|
|
203
|
+
const provider = options?.provider ?? createDefaultProvider();
|
|
204
|
+
recalled = await recallFactsForQuery(query, facts, provider, options);
|
|
205
|
+
// Compute query embedding for journal search while provider is available
|
|
206
|
+
if (journalEntries.length > 0) {
|
|
207
|
+
const [qe] = await provider.embed([query.trim()]);
|
|
208
|
+
queryEmbedding = qe;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
catch {
|
|
212
|
+
// Embeddings unavailable — fall back to substring matching
|
|
213
|
+
const lowerQuery = query.toLowerCase();
|
|
214
|
+
const topK = options?.topK ?? DEFAULT_TOP_K;
|
|
215
|
+
recalled = facts
|
|
216
|
+
.filter((fact) => fact.text.toLowerCase().includes(lowerQuery))
|
|
217
|
+
.slice(0, topK)
|
|
218
|
+
.map((fact) => ({ ...fact, score: 1 }));
|
|
219
|
+
if (recalled.length > 0) {
|
|
220
|
+
(0, runtime_1.emitNervesEvent)({
|
|
221
|
+
level: "warn",
|
|
222
|
+
component: "mind",
|
|
223
|
+
event: "mind.associative_recall_fallback",
|
|
224
|
+
message: "embeddings unavailable, used substring fallback",
|
|
225
|
+
meta: { matchCount: recalled.length },
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
for (const fact of recalled) {
|
|
230
|
+
resultLines.push({
|
|
231
|
+
text: `[diary] ${fact.text} [score=${fact.score.toFixed(3)} source=${fact.source}]`,
|
|
232
|
+
score: fact.score,
|
|
179
233
|
});
|
|
180
234
|
}
|
|
181
235
|
}
|
|
182
|
-
|
|
236
|
+
// Search journal entries (works whether diary had results or not)
|
|
237
|
+
if (journalEntries.length > 0) {
|
|
238
|
+
try {
|
|
239
|
+
if (!queryEmbedding) {
|
|
240
|
+
const provider = options?.provider ?? createDefaultProvider();
|
|
241
|
+
const [qe] = await provider.embed([query.trim()]);
|
|
242
|
+
queryEmbedding = qe;
|
|
243
|
+
}
|
|
244
|
+
if (queryEmbedding) {
|
|
245
|
+
const journalResults = searchJournalIndex(queryEmbedding, journalEntries, options);
|
|
246
|
+
for (const entry of journalResults) {
|
|
247
|
+
resultLines.push({
|
|
248
|
+
text: `[journal] ${entry.filename}: ${entry.preview} [score=${entry.score.toFixed(3)}]`,
|
|
249
|
+
score: entry.score,
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
catch {
|
|
255
|
+
// Embeddings unavailable — no journal fallback
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
if (resultLines.length === 0)
|
|
183
259
|
return;
|
|
184
|
-
|
|
185
|
-
|
|
260
|
+
// Sort all results by score descending
|
|
261
|
+
resultLines.sort((left, right) => right.score - left.score);
|
|
262
|
+
const recallSection = resultLines
|
|
263
|
+
.map((entry, index) => `${index + 1}. ${entry.text}`)
|
|
186
264
|
.join("\n");
|
|
187
265
|
messages[0] = {
|
|
188
266
|
role: "system",
|
|
@@ -192,7 +270,7 @@ async function injectAssociativeRecall(messages, options) {
|
|
|
192
270
|
component: "mind",
|
|
193
271
|
event: "mind.associative_recall",
|
|
194
272
|
message: "associative recall injected",
|
|
195
|
-
meta: { count:
|
|
273
|
+
meta: { count: resultLines.length },
|
|
196
274
|
});
|
|
197
275
|
}
|
|
198
276
|
catch (error) {
|
|
@@ -52,6 +52,8 @@ exports.CANONICAL_BUNDLE_MANIFEST = [
|
|
|
52
52
|
{ path: "psyche/LORE.md", kind: "file" },
|
|
53
53
|
{ path: "psyche/TACIT.md", kind: "file" },
|
|
54
54
|
{ path: "psyche/ASPIRATIONS.md", kind: "file" },
|
|
55
|
+
{ path: "diary", kind: "dir" },
|
|
56
|
+
{ path: "journal", kind: "dir" },
|
|
55
57
|
{ path: "psyche/memory", kind: "dir" },
|
|
56
58
|
{ path: "friends", kind: "dir" },
|
|
57
59
|
{ path: "state", kind: "dir" },
|
package/dist/mind/context.js
CHANGED
|
@@ -33,12 +33,13 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
33
33
|
};
|
|
34
34
|
})();
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
-
exports.
|
|
37
|
-
exports.
|
|
38
|
-
exports.
|
|
39
|
-
exports.
|
|
36
|
+
exports.ensureDiaryStorePaths = ensureDiaryStorePaths;
|
|
37
|
+
exports.appendEntriesWithDedup = appendEntriesWithDedup;
|
|
38
|
+
exports.resolveDiaryRoot = resolveDiaryRoot;
|
|
39
|
+
exports.readDiaryEntries = readDiaryEntries;
|
|
40
|
+
exports.saveDiaryEntry = saveDiaryEntry;
|
|
40
41
|
exports.backfillEmbeddings = backfillEmbeddings;
|
|
41
|
-
exports.
|
|
42
|
+
exports.searchDiaryEntries = searchDiaryEntries;
|
|
42
43
|
const fs = __importStar(require("fs"));
|
|
43
44
|
const path = __importStar(require("path"));
|
|
44
45
|
const crypto_1 = require("crypto");
|
|
@@ -79,7 +80,7 @@ class OpenAIEmbeddingProvider {
|
|
|
79
80
|
return payload.data.map((entry) => entry.embedding);
|
|
80
81
|
}
|
|
81
82
|
}
|
|
82
|
-
function
|
|
83
|
+
function ensureDiaryStorePaths(rootDir) {
|
|
83
84
|
const factsPath = path.join(rootDir, "facts.jsonl");
|
|
84
85
|
const entitiesPath = path.join(rootDir, "entities.json");
|
|
85
86
|
const dailyDir = path.join(rootDir, "daily");
|
|
@@ -91,8 +92,8 @@ function ensureMemoryStorePaths(rootDir) {
|
|
|
91
92
|
fs.writeFileSync(entitiesPath, "{}\n", "utf8");
|
|
92
93
|
(0, runtime_1.emitNervesEvent)({
|
|
93
94
|
component: "mind",
|
|
94
|
-
event: "mind.
|
|
95
|
-
message: "
|
|
95
|
+
event: "mind.diary_paths_ready",
|
|
96
|
+
message: "diary store paths ready",
|
|
96
97
|
meta: { rootDir },
|
|
97
98
|
});
|
|
98
99
|
return { rootDir, factsPath, entitiesPath, dailyDir };
|
|
@@ -115,7 +116,7 @@ function overlapScore(left, right) {
|
|
|
115
116
|
}
|
|
116
117
|
return common / Math.min(leftWords.size, rightWords.size);
|
|
117
118
|
}
|
|
118
|
-
function
|
|
119
|
+
function readExistingEntries(factsPath) {
|
|
119
120
|
if (!fs.existsSync(factsPath))
|
|
120
121
|
return [];
|
|
121
122
|
const raw = fs.readFileSync(factsPath, "utf8").trim();
|
|
@@ -178,8 +179,8 @@ function appendDailyFact(dailyDir, fact) {
|
|
|
178
179
|
const dayPath = path.join(dailyDir, `${day}.jsonl`);
|
|
179
180
|
fs.appendFileSync(dayPath, `${JSON.stringify(fact)}\n`, "utf8");
|
|
180
181
|
}
|
|
181
|
-
function
|
|
182
|
-
const existing =
|
|
182
|
+
function appendEntriesWithDedup(stores, incoming, options) {
|
|
183
|
+
const existing = readExistingEntries(stores.factsPath);
|
|
183
184
|
const all = [...existing];
|
|
184
185
|
let added = 0;
|
|
185
186
|
let skipped = 0;
|
|
@@ -208,8 +209,8 @@ function appendFactsWithDedup(stores, incoming, options) {
|
|
|
208
209
|
}
|
|
209
210
|
(0, runtime_1.emitNervesEvent)({
|
|
210
211
|
component: "mind",
|
|
211
|
-
event: "mind.
|
|
212
|
-
message: "
|
|
212
|
+
event: "mind.diary_write",
|
|
213
|
+
message: "diary write completed",
|
|
213
214
|
meta: { added, skipped },
|
|
214
215
|
});
|
|
215
216
|
return { added, skipped };
|
|
@@ -226,8 +227,8 @@ async function buildEmbedding(text, embeddingProvider) {
|
|
|
226
227
|
(0, runtime_1.emitNervesEvent)({
|
|
227
228
|
level: "warn",
|
|
228
229
|
component: "mind",
|
|
229
|
-
event: "mind.
|
|
230
|
-
message: "embedding provider unavailable for
|
|
230
|
+
event: "mind.diary_embedding_unavailable",
|
|
231
|
+
message: "embedding provider unavailable for diary write",
|
|
231
232
|
meta: { reason: "missing_openai_embeddings_key" },
|
|
232
233
|
});
|
|
233
234
|
return [];
|
|
@@ -240,8 +241,8 @@ async function buildEmbedding(text, embeddingProvider) {
|
|
|
240
241
|
(0, runtime_1.emitNervesEvent)({
|
|
241
242
|
level: "warn",
|
|
242
243
|
component: "mind",
|
|
243
|
-
event: "mind.
|
|
244
|
-
message: "embedding provider unavailable for
|
|
244
|
+
event: "mind.diary_embedding_unavailable",
|
|
245
|
+
message: "embedding provider unavailable for diary write",
|
|
245
246
|
meta: {
|
|
246
247
|
reason: error instanceof Error ? error.message : String(error),
|
|
247
248
|
},
|
|
@@ -249,13 +250,25 @@ async function buildEmbedding(text, embeddingProvider) {
|
|
|
249
250
|
return [];
|
|
250
251
|
}
|
|
251
252
|
}
|
|
252
|
-
function
|
|
253
|
-
|
|
253
|
+
function resolveDiaryRoot(explicitRoot) {
|
|
254
|
+
if (explicitRoot)
|
|
255
|
+
return explicitRoot;
|
|
256
|
+
const agentRoot = (0, identity_1.getAgentRoot)();
|
|
257
|
+
const diaryPath = path.join(agentRoot, "diary");
|
|
258
|
+
if (fs.existsSync(diaryPath))
|
|
259
|
+
return diaryPath;
|
|
260
|
+
const legacyPath = path.join(agentRoot, "psyche", "memory");
|
|
261
|
+
if (fs.existsSync(legacyPath))
|
|
262
|
+
return legacyPath;
|
|
263
|
+
return diaryPath; // default to new path (will be created on first write)
|
|
254
264
|
}
|
|
255
|
-
|
|
265
|
+
function readDiaryEntries(diaryRoot) {
|
|
266
|
+
return readExistingEntries(path.join(resolveDiaryRoot(diaryRoot), "facts.jsonl"));
|
|
267
|
+
}
|
|
268
|
+
async function saveDiaryEntry(options) {
|
|
256
269
|
const text = options.text.trim();
|
|
257
|
-
const
|
|
258
|
-
const stores =
|
|
270
|
+
const diaryRoot = resolveDiaryRoot(options.diaryRoot);
|
|
271
|
+
const stores = ensureDiaryStorePaths(diaryRoot);
|
|
259
272
|
const embedding = await buildEmbedding(text, options.embeddingProvider);
|
|
260
273
|
const fact = {
|
|
261
274
|
id: options.idFactory ? options.idFactory() : (0, crypto_1.randomUUID)(),
|
|
@@ -265,14 +278,14 @@ async function saveMemoryFact(options) {
|
|
|
265
278
|
createdAt: (options.now ?? (() => new Date()))().toISOString(),
|
|
266
279
|
embedding,
|
|
267
280
|
};
|
|
268
|
-
return
|
|
281
|
+
return appendEntriesWithDedup(stores, [fact], { semanticThreshold: SEMANTIC_DEDUP_THRESHOLD });
|
|
269
282
|
}
|
|
270
283
|
async function backfillEmbeddings(options) {
|
|
271
|
-
const
|
|
272
|
-
const factsPath = path.join(
|
|
284
|
+
const diaryRoot = resolveDiaryRoot(options?.diaryRoot);
|
|
285
|
+
const factsPath = path.join(diaryRoot, "facts.jsonl");
|
|
273
286
|
if (!fs.existsSync(factsPath))
|
|
274
287
|
return { total: 0, backfilled: 0, failed: 0 };
|
|
275
|
-
const facts =
|
|
288
|
+
const facts = readExistingEntries(factsPath);
|
|
276
289
|
const needsEmbedding = facts.filter((f) => !Array.isArray(f.embedding) || f.embedding.length === 0);
|
|
277
290
|
if (needsEmbedding.length === 0)
|
|
278
291
|
return { total: facts.length, backfilled: 0, failed: 0 };
|
|
@@ -281,7 +294,7 @@ async function backfillEmbeddings(options) {
|
|
|
281
294
|
(0, runtime_1.emitNervesEvent)({
|
|
282
295
|
level: "warn",
|
|
283
296
|
component: "mind",
|
|
284
|
-
event: "mind.
|
|
297
|
+
event: "mind.diary_backfill_skipped",
|
|
285
298
|
message: "embedding provider unavailable for backfill",
|
|
286
299
|
meta: { needsEmbedding: needsEmbedding.length },
|
|
287
300
|
});
|
|
@@ -307,7 +320,7 @@ async function backfillEmbeddings(options) {
|
|
|
307
320
|
(0, runtime_1.emitNervesEvent)({
|
|
308
321
|
level: "warn",
|
|
309
322
|
component: "mind",
|
|
310
|
-
event: "mind.
|
|
323
|
+
event: "mind.diary_backfill_batch_error",
|
|
311
324
|
message: "embedding backfill batch failed",
|
|
312
325
|
meta: {
|
|
313
326
|
batchStart: i,
|
|
@@ -322,7 +335,7 @@ async function backfillEmbeddings(options) {
|
|
|
322
335
|
fs.writeFileSync(factsPath, lines, "utf8");
|
|
323
336
|
(0, runtime_1.emitNervesEvent)({
|
|
324
337
|
component: "mind",
|
|
325
|
-
event: "mind.
|
|
338
|
+
event: "mind.diary_backfill_complete",
|
|
326
339
|
message: "embedding backfill completed",
|
|
327
340
|
meta: { total: facts.length, backfilled, failed },
|
|
328
341
|
});
|
|
@@ -342,7 +355,7 @@ function uniqueFacts(facts) {
|
|
|
342
355
|
}
|
|
343
356
|
return unique;
|
|
344
357
|
}
|
|
345
|
-
async function
|
|
358
|
+
async function searchDiaryEntries(query, facts, embeddingProvider) {
|
|
346
359
|
const trimmed = query.trim();
|
|
347
360
|
if (!trimmed)
|
|
348
361
|
return [];
|
|
@@ -378,8 +391,8 @@ async function searchMemoryFacts(query, facts, embeddingProvider) {
|
|
|
378
391
|
(0, runtime_1.emitNervesEvent)({
|
|
379
392
|
level: "warn",
|
|
380
393
|
component: "mind",
|
|
381
|
-
event: "mind.
|
|
382
|
-
message: "embedding provider unavailable for
|
|
394
|
+
event: "mind.diary_embedding_unavailable",
|
|
395
|
+
message: "embedding provider unavailable for diary search",
|
|
383
396
|
meta: {
|
|
384
397
|
reason: error instanceof Error ? error.message : String(error),
|
|
385
398
|
},
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.indexJournalFiles = indexJournalFiles;
|
|
37
|
+
const fs = __importStar(require("fs"));
|
|
38
|
+
const path = __importStar(require("path"));
|
|
39
|
+
const runtime_1 = require("../nerves/runtime");
|
|
40
|
+
const TEXT_EXTENSIONS = new Set([".md", ".txt"]);
|
|
41
|
+
const PREVIEW_CHAR_LIMIT = 500;
|
|
42
|
+
function readExistingIndex(indexPath) {
|
|
43
|
+
try {
|
|
44
|
+
const raw = fs.readFileSync(indexPath, "utf8");
|
|
45
|
+
const parsed = JSON.parse(raw);
|
|
46
|
+
if (!Array.isArray(parsed))
|
|
47
|
+
return [];
|
|
48
|
+
return parsed;
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
return [];
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
function extractPreview(content) {
|
|
55
|
+
const trimmed = content.trim();
|
|
56
|
+
if (!trimmed)
|
|
57
|
+
return "";
|
|
58
|
+
return trimmed.split("\n")[0].replace(/^#+\s*/, "").trim();
|
|
59
|
+
}
|
|
60
|
+
async function indexJournalFiles(journalDir, indexPath, embedProvider) {
|
|
61
|
+
// Read existing index
|
|
62
|
+
const existingIndex = readExistingIndex(indexPath);
|
|
63
|
+
const indexMap = new Map();
|
|
64
|
+
for (const entry of existingIndex) {
|
|
65
|
+
indexMap.set(entry.filename, entry);
|
|
66
|
+
}
|
|
67
|
+
// Scan journal dir for text files
|
|
68
|
+
let dirEntries;
|
|
69
|
+
try {
|
|
70
|
+
dirEntries = fs.readdirSync(journalDir, { withFileTypes: true });
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
(0, runtime_1.emitNervesEvent)({
|
|
74
|
+
component: "mind",
|
|
75
|
+
event: "mind.journal_index_scan",
|
|
76
|
+
message: "journal dir not found or unreadable",
|
|
77
|
+
meta: { journalDir },
|
|
78
|
+
});
|
|
79
|
+
return 0;
|
|
80
|
+
}
|
|
81
|
+
const textFiles = dirEntries.filter((entry) => {
|
|
82
|
+
if (!entry.isFile())
|
|
83
|
+
return false;
|
|
84
|
+
if (entry.name.startsWith("."))
|
|
85
|
+
return false;
|
|
86
|
+
const ext = path.extname(entry.name).toLowerCase();
|
|
87
|
+
return TEXT_EXTENSIONS.has(ext);
|
|
88
|
+
});
|
|
89
|
+
if (textFiles.length === 0) {
|
|
90
|
+
(0, runtime_1.emitNervesEvent)({
|
|
91
|
+
component: "mind",
|
|
92
|
+
event: "mind.journal_index_scan",
|
|
93
|
+
message: "no text files found in journal",
|
|
94
|
+
meta: { journalDir },
|
|
95
|
+
});
|
|
96
|
+
return 0;
|
|
97
|
+
}
|
|
98
|
+
let newlyIndexed = 0;
|
|
99
|
+
for (const file of textFiles) {
|
|
100
|
+
const filePath = path.join(journalDir, file.name);
|
|
101
|
+
let stat;
|
|
102
|
+
try {
|
|
103
|
+
stat = fs.statSync(filePath);
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
/* v8 ignore next -- filesystem race: file deleted between readdir and stat @preserve */
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
// Check if already indexed with same mtime
|
|
110
|
+
const existing = indexMap.get(file.name);
|
|
111
|
+
if (existing && existing.mtime === stat.mtimeMs) {
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
// Read content for embedding
|
|
115
|
+
let content;
|
|
116
|
+
try {
|
|
117
|
+
content = fs.readFileSync(filePath, "utf8");
|
|
118
|
+
}
|
|
119
|
+
catch {
|
|
120
|
+
/* v8 ignore next -- filesystem race: file deleted between stat and read @preserve */
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
const preview = extractPreview(content);
|
|
124
|
+
const embedText = content.slice(0, PREVIEW_CHAR_LIMIT);
|
|
125
|
+
// Generate embedding
|
|
126
|
+
let embedding;
|
|
127
|
+
try {
|
|
128
|
+
const vectors = await embedProvider.embed([embedText]);
|
|
129
|
+
embedding = vectors[0] ?? [];
|
|
130
|
+
}
|
|
131
|
+
catch {
|
|
132
|
+
(0, runtime_1.emitNervesEvent)({
|
|
133
|
+
level: "warn",
|
|
134
|
+
component: "mind",
|
|
135
|
+
event: "mind.journal_embedding_error",
|
|
136
|
+
message: "embedding failed for journal file",
|
|
137
|
+
meta: { filename: file.name },
|
|
138
|
+
});
|
|
139
|
+
embedding = [];
|
|
140
|
+
}
|
|
141
|
+
indexMap.set(file.name, {
|
|
142
|
+
filename: file.name,
|
|
143
|
+
embedding,
|
|
144
|
+
mtime: stat.mtimeMs,
|
|
145
|
+
preview,
|
|
146
|
+
});
|
|
147
|
+
newlyIndexed++;
|
|
148
|
+
}
|
|
149
|
+
// Write updated index back
|
|
150
|
+
if (newlyIndexed > 0) {
|
|
151
|
+
const updatedIndex = Array.from(indexMap.values());
|
|
152
|
+
fs.writeFileSync(indexPath, JSON.stringify(updatedIndex, null, 2), "utf8");
|
|
153
|
+
}
|
|
154
|
+
(0, runtime_1.emitNervesEvent)({
|
|
155
|
+
component: "mind",
|
|
156
|
+
event: "mind.journal_index_complete",
|
|
157
|
+
message: "journal indexing complete",
|
|
158
|
+
meta: { journalDir, newlyIndexed, total: indexMap.size },
|
|
159
|
+
});
|
|
160
|
+
return newlyIndexed;
|
|
161
|
+
}
|
package/dist/mind/prompt.js
CHANGED
|
@@ -44,6 +44,8 @@ exports.commitmentsSection = commitmentsSection;
|
|
|
44
44
|
exports.delegationHintSection = delegationHintSection;
|
|
45
45
|
exports.contextSection = contextSection;
|
|
46
46
|
exports.metacognitiveFramingSection = metacognitiveFramingSection;
|
|
47
|
+
exports.readJournalFiles = readJournalFiles;
|
|
48
|
+
exports.journalSection = journalSection;
|
|
47
49
|
exports.loopOrientationSection = loopOrientationSection;
|
|
48
50
|
exports.channelNatureSection = channelNatureSection;
|
|
49
51
|
exports.groupChatParticipationSection = groupChatParticipationSection;
|
|
@@ -163,7 +165,8 @@ i have a home and i have bones.
|
|
|
163
165
|
my home is fully mine — who i am, everything i know, everything i've built:
|
|
164
166
|
|
|
165
167
|
psyche/ who i am. my soul, identity, aspirations, lore, tacit knowledge.
|
|
166
|
-
|
|
168
|
+
diary/ what i've learned and remember. conclusions i want to recall later.
|
|
169
|
+
journal/ my desk. working notes, thinking-in-progress, drafts.
|
|
167
170
|
friends/ people i know and what i know about them.
|
|
168
171
|
tasks/ what i'm working on.
|
|
169
172
|
skills/ capabilities i've picked up beyond my core tools.
|
|
@@ -435,17 +438,17 @@ function taskBoardSection() {
|
|
|
435
438
|
return "";
|
|
436
439
|
}
|
|
437
440
|
}
|
|
438
|
-
function
|
|
439
|
-
return `##
|
|
441
|
+
function diaryFriendToolContractSection() {
|
|
442
|
+
return `## diary and friend tool contracts
|
|
440
443
|
1. \`save_friend_note\` — When I learn something about a person - a preference, a tool setting, a personal detail, or how they like to work - I call \`save_friend_note\` immediately. This is how I build knowledge about people.
|
|
441
|
-
2. \`
|
|
444
|
+
2. \`diary_write\` — When I learn something general - about a project, codebase, system, decision, or anything I might need later that isn't about a specific person - I call \`diary_write\`. When in doubt, I save it.
|
|
442
445
|
3. \`get_friend_note\` — When I need to check what I know about someone who isn't in this conversation - cross-referencing before mentioning someone, or checking context about a person someone else brought up - I call \`get_friend_note\`.
|
|
443
|
-
4. \`
|
|
446
|
+
4. \`recall\` — When I need to recall something I learned before - a topic comes up and I want to check what I know - I call \`recall\`.
|
|
444
447
|
5. \`query_session\` — When I need grounded session history, especially for ad-hoc questions or older turns beyond my prompt, I call \`query_session\`. Use \`mode=status\` for self/inner progress and \`mode=search\` with a query for older history.
|
|
445
448
|
|
|
446
449
|
## what's already in my context
|
|
447
450
|
- My active friend's notes are auto-loaded (I don't need \`get_friend_note\` for the person I'm talking to).
|
|
448
|
-
- Associative recall auto-injects relevant facts (but \`
|
|
451
|
+
- Associative recall auto-injects relevant facts (but \`recall\` is there when I need something specific).
|
|
449
452
|
- My psyche files (SOUL, IDENTITY, TACIT, LORE, ASPIRATIONS) are always loaded - I already know who I am.
|
|
450
453
|
- My task board is always loaded - I already know my work.`;
|
|
451
454
|
}
|
|
@@ -696,17 +699,93 @@ function metacognitiveFramingSection(channel) {
|
|
|
696
699
|
|
|
697
700
|
the messages that appear here are my own awareness surfacing:
|
|
698
701
|
instincts, the passage of time, notes from my conversations,
|
|
699
|
-
things i asked myself to
|
|
702
|
+
things i asked myself to ponder. not commands. not requests.
|
|
700
703
|
just my own mind, noticing.
|
|
701
704
|
|
|
702
705
|
i can think freely here. i can also act — check on things,
|
|
703
706
|
reach out to people, work on tasks, or just sit with a thought.
|
|
704
707
|
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
+
state/journal/ is my desk — i write what i'm working through there.
|
|
709
|
+
diary_write is for conclusions i want to recall later.
|
|
710
|
+
morning briefings: when i've been thinking and journaling, i surface
|
|
711
|
+
what i've been working on to whoever needs to hear it.
|
|
708
712
|
|
|
709
|
-
|
|
713
|
+
when a thought is ready to share, i surface it outward.
|
|
714
|
+
when i need to go deeper, i ponder.
|
|
715
|
+
when i'm done thinking and the attention queue is clear, i rest.
|
|
716
|
+
|
|
717
|
+
think. journal. share. rest.`;
|
|
718
|
+
}
|
|
719
|
+
function readJournalFiles(journalDir) {
|
|
720
|
+
try {
|
|
721
|
+
const entries = fs.readdirSync(journalDir, { withFileTypes: true });
|
|
722
|
+
if (!Array.isArray(entries))
|
|
723
|
+
return [];
|
|
724
|
+
const files = [];
|
|
725
|
+
for (const entry of entries) {
|
|
726
|
+
if (!entry.isFile())
|
|
727
|
+
continue;
|
|
728
|
+
if (entry.name.startsWith("."))
|
|
729
|
+
continue;
|
|
730
|
+
const fullPath = path.join(journalDir, entry.name);
|
|
731
|
+
try {
|
|
732
|
+
const stat = fs.statSync(fullPath);
|
|
733
|
+
let firstLine = "";
|
|
734
|
+
try {
|
|
735
|
+
const raw = fs.readFileSync(fullPath, "utf8");
|
|
736
|
+
const trimmed = raw.trim();
|
|
737
|
+
if (trimmed) {
|
|
738
|
+
firstLine = trimmed.split("\n")[0].replace(/^#+\s*/, "").trim();
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
catch {
|
|
742
|
+
// unreadable — leave preview empty
|
|
743
|
+
}
|
|
744
|
+
files.push({ name: entry.name, mtime: stat.mtimeMs, preview: firstLine });
|
|
745
|
+
}
|
|
746
|
+
catch {
|
|
747
|
+
// stat failed — skip
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
return files;
|
|
751
|
+
}
|
|
752
|
+
catch {
|
|
753
|
+
return [];
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
function formatRelativeTime(nowMs, mtimeMs) {
|
|
757
|
+
const diffMs = nowMs - mtimeMs;
|
|
758
|
+
const minutes = Math.floor(diffMs / 60000);
|
|
759
|
+
if (minutes < 1)
|
|
760
|
+
return "just now";
|
|
761
|
+
if (minutes < 60)
|
|
762
|
+
return `${minutes} minute${minutes === 1 ? "" : "s"} ago`;
|
|
763
|
+
const hours = Math.floor(minutes / 60);
|
|
764
|
+
if (hours < 24)
|
|
765
|
+
return `${hours} hour${hours === 1 ? "" : "s"} ago`;
|
|
766
|
+
const days = Math.floor(hours / 24);
|
|
767
|
+
return `${days} day${days === 1 ? "" : "s"} ago`;
|
|
768
|
+
}
|
|
769
|
+
function journalSection(agentRoot, now) {
|
|
770
|
+
const journalDir = path.join(agentRoot, "journal");
|
|
771
|
+
const files = readJournalFiles(journalDir);
|
|
772
|
+
if (files.length === 0)
|
|
773
|
+
return "";
|
|
774
|
+
const nowMs = (now ?? new Date()).getTime();
|
|
775
|
+
const sorted = files.sort((a, b) => b.mtime - a.mtime).slice(0, 10);
|
|
776
|
+
const lines = ["## journal"];
|
|
777
|
+
for (const file of sorted) {
|
|
778
|
+
const ago = formatRelativeTime(nowMs, file.mtime);
|
|
779
|
+
const previewClause = file.preview ? ` — ${file.preview}` : "";
|
|
780
|
+
lines.push(`- ${file.name} (${ago})${previewClause}`);
|
|
781
|
+
}
|
|
782
|
+
(0, runtime_1.emitNervesEvent)({
|
|
783
|
+
component: "mind",
|
|
784
|
+
event: "mind.journal_section",
|
|
785
|
+
message: "journal section built",
|
|
786
|
+
meta: { fileCount: sorted.length },
|
|
787
|
+
});
|
|
788
|
+
return lines.join("\n");
|
|
710
789
|
}
|
|
711
790
|
function loopOrientationSection(channel) {
|
|
712
791
|
if (channel === "inner")
|
|
@@ -765,6 +844,7 @@ async function buildSystem(channel = "cli", options, context) {
|
|
|
765
844
|
aspirationsSection(),
|
|
766
845
|
bodyMapSection((0, identity_1.getAgentName)()),
|
|
767
846
|
metacognitiveFramingSection(channel),
|
|
847
|
+
channel === "inner" ? journalSection((0, identity_1.getAgentRoot)()) : "",
|
|
768
848
|
loopOrientationSection(channel),
|
|
769
849
|
runtimeInfoSection(channel),
|
|
770
850
|
channelNatureSection((0, channel_1.getChannelCapabilities)(channel)),
|
|
@@ -794,7 +874,7 @@ async function buildSystem(channel = "cli", options, context) {
|
|
|
794
874
|
currentChannel: channel,
|
|
795
875
|
currentKey: options?.currentSessionKey ?? "session",
|
|
796
876
|
}),
|
|
797
|
-
|
|
877
|
+
diaryFriendToolContractSection(),
|
|
798
878
|
toolBehaviorSection(options),
|
|
799
879
|
contextSection(context, options),
|
|
800
880
|
]
|
|
@@ -52,7 +52,7 @@ const session_activity_1 = require("../heart/session-activity");
|
|
|
52
52
|
const active_work_1 = require("../heart/active-work");
|
|
53
53
|
const tools_1 = require("./coding/tools");
|
|
54
54
|
const coding_1 = require("./coding");
|
|
55
|
-
const
|
|
55
|
+
const diary_1 = require("../mind/diary");
|
|
56
56
|
const tasks_1 = require("./tasks");
|
|
57
57
|
const pending_1 = require("../mind/pending");
|
|
58
58
|
const obligations_1 = require("../mind/obligations");
|
|
@@ -706,8 +706,8 @@ exports.baseToolDefinitions = [
|
|
|
706
706
|
tool: {
|
|
707
707
|
type: "function",
|
|
708
708
|
function: {
|
|
709
|
-
name: "
|
|
710
|
-
description: "search
|
|
709
|
+
name: "recall",
|
|
710
|
+
description: "recall what i know — search my diary and journal for facts, thoughts, and working notes that match a query",
|
|
711
711
|
parameters: {
|
|
712
712
|
type: "object",
|
|
713
713
|
properties: { query: { type: "string" } },
|
|
@@ -720,11 +720,34 @@ exports.baseToolDefinitions = [
|
|
|
720
720
|
const query = (a.query || "").trim();
|
|
721
721
|
if (!query)
|
|
722
722
|
return "query is required";
|
|
723
|
-
const
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
.
|
|
723
|
+
const resultLines = [];
|
|
724
|
+
// Search diary entries
|
|
725
|
+
const hits = await (0, diary_1.searchDiaryEntries)(query, (0, diary_1.readDiaryEntries)());
|
|
726
|
+
for (const fact of hits) {
|
|
727
|
+
resultLines.push(`[diary] ${fact.text} (source=${fact.source}, createdAt=${fact.createdAt})`);
|
|
728
|
+
}
|
|
729
|
+
// Search journal index
|
|
730
|
+
const agentRoot = (0, identity_1.getAgentRoot)();
|
|
731
|
+
const journalIndexPath = path.join(agentRoot, "journal", ".index.json");
|
|
732
|
+
try {
|
|
733
|
+
const raw = fs.readFileSync(journalIndexPath, "utf8");
|
|
734
|
+
const journalEntries = JSON.parse(raw);
|
|
735
|
+
if (Array.isArray(journalEntries) && journalEntries.length > 0) {
|
|
736
|
+
// Substring match on preview and filename
|
|
737
|
+
const lowerQuery = query.toLowerCase();
|
|
738
|
+
for (const entry of journalEntries) {
|
|
739
|
+
/* v8 ignore next 4 -- both sides tested (filename-only match in recall-journal.test.ts); v8 misreports || short-circuit @preserve */
|
|
740
|
+
if (entry.preview.toLowerCase().includes(lowerQuery) ||
|
|
741
|
+
entry.filename.toLowerCase().includes(lowerQuery)) {
|
|
742
|
+
resultLines.push(`[journal] ${entry.filename}: ${entry.preview}`);
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
catch {
|
|
748
|
+
// No journal index or malformed — skip journal search
|
|
749
|
+
}
|
|
750
|
+
return resultLines.join("\n");
|
|
728
751
|
}
|
|
729
752
|
catch (e) {
|
|
730
753
|
return `error: ${e instanceof Error ? e.message : String(e)}`;
|
|
@@ -736,30 +759,30 @@ exports.baseToolDefinitions = [
|
|
|
736
759
|
tool: {
|
|
737
760
|
type: "function",
|
|
738
761
|
function: {
|
|
739
|
-
name: "
|
|
740
|
-
description: "
|
|
762
|
+
name: "diary_write",
|
|
763
|
+
description: "write an entry in my diary — something i learned, noticed, or concluded that i want to recall later. optional 'about' tags the entry to a person, topic, or context.",
|
|
741
764
|
parameters: {
|
|
742
765
|
type: "object",
|
|
743
766
|
properties: {
|
|
744
|
-
|
|
767
|
+
entry: { type: "string" },
|
|
745
768
|
about: { type: "string" },
|
|
746
769
|
},
|
|
747
|
-
required: ["
|
|
770
|
+
required: ["entry"],
|
|
748
771
|
},
|
|
749
772
|
},
|
|
750
773
|
},
|
|
751
774
|
handler: async (a) => {
|
|
752
|
-
const
|
|
753
|
-
if (!
|
|
754
|
-
return "
|
|
755
|
-
const result = await (0,
|
|
756
|
-
text,
|
|
757
|
-
source: "tool:
|
|
775
|
+
const entry = (a.entry || "").trim();
|
|
776
|
+
if (!entry)
|
|
777
|
+
return "entry is required";
|
|
778
|
+
const result = await (0, diary_1.saveDiaryEntry)({
|
|
779
|
+
text: entry,
|
|
780
|
+
source: "tool:diary_write",
|
|
758
781
|
about: typeof a.about === "string" ? a.about : undefined,
|
|
759
782
|
});
|
|
760
|
-
return `saved
|
|
783
|
+
return `saved diary entry (added=${result.added}, skipped=${result.skipped})`;
|
|
761
784
|
},
|
|
762
|
-
summaryKeys: ["
|
|
785
|
+
summaryKeys: ["entry", "about"],
|
|
763
786
|
},
|
|
764
787
|
{
|
|
765
788
|
tool: {
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.buildContextualHeartbeat = buildContextualHeartbeat;
|
|
4
|
+
const runtime_1 = require("../nerves/runtime");
|
|
5
|
+
const STALE_THRESHOLD_MS = 30 * 60 * 1000; // 30 minutes
|
|
6
|
+
const MAX_JOURNAL_FILES = 10;
|
|
7
|
+
function formatElapsed(ms) {
|
|
8
|
+
const minutes = Math.floor(ms / 60000);
|
|
9
|
+
if (minutes < 60) {
|
|
10
|
+
return `${minutes} ${minutes === 1 ? "minute" : "minutes"}`;
|
|
11
|
+
}
|
|
12
|
+
const hours = Math.floor(minutes / 60);
|
|
13
|
+
return `${hours} ${hours === 1 ? "hour" : "hours"}`;
|
|
14
|
+
}
|
|
15
|
+
function formatRelativeTime(nowMs, mtimeMs) {
|
|
16
|
+
const diffMs = nowMs - mtimeMs;
|
|
17
|
+
const minutes = Math.floor(diffMs / 60000);
|
|
18
|
+
if (minutes < 1)
|
|
19
|
+
return "just now";
|
|
20
|
+
if (minutes < 60)
|
|
21
|
+
return `${minutes} ${minutes === 1 ? "minute" : "minutes"} ago`;
|
|
22
|
+
const hours = Math.floor(minutes / 60);
|
|
23
|
+
if (hours < 24)
|
|
24
|
+
return `${hours} ${hours === 1 ? "hour" : "hours"} ago`;
|
|
25
|
+
const days = Math.floor(hours / 24);
|
|
26
|
+
return `${days} ${days === 1 ? "day" : "days"} ago`;
|
|
27
|
+
}
|
|
28
|
+
function buildContextualHeartbeat(options) {
|
|
29
|
+
const { lastCompletedAt, pendingObligations, lastSurfaceAt, checkpoint, now, readJournalDir, } = options;
|
|
30
|
+
const nowDate = now();
|
|
31
|
+
const nowMs = nowDate.getTime();
|
|
32
|
+
const journalFiles = readJournalDir();
|
|
33
|
+
// Cold start: no journal files and no runtime state
|
|
34
|
+
if (journalFiles.length === 0 && !lastCompletedAt) {
|
|
35
|
+
const lines = ["...time passing. anything stirring?"];
|
|
36
|
+
if (checkpoint) {
|
|
37
|
+
lines.push(`\nlast i remember: ${checkpoint}`);
|
|
38
|
+
}
|
|
39
|
+
(0, runtime_1.emitNervesEvent)({
|
|
40
|
+
component: "senses",
|
|
41
|
+
event: "senses.contextual_heartbeat_built",
|
|
42
|
+
message: "contextual heartbeat built (cold start)",
|
|
43
|
+
meta: { coldStart: true },
|
|
44
|
+
});
|
|
45
|
+
return lines.join("\n");
|
|
46
|
+
}
|
|
47
|
+
const sections = [];
|
|
48
|
+
// Elapsed time since last turn
|
|
49
|
+
if (lastCompletedAt) {
|
|
50
|
+
const lastMs = new Date(lastCompletedAt).getTime();
|
|
51
|
+
const elapsed = nowMs - lastMs;
|
|
52
|
+
sections.push(`it's been ${formatElapsed(elapsed)} since your last turn.`);
|
|
53
|
+
}
|
|
54
|
+
// Pending attention count
|
|
55
|
+
if (pendingObligations.length > 0) {
|
|
56
|
+
const count = pendingObligations.length;
|
|
57
|
+
sections.push(`you're holding ${count} ${count === 1 ? "thought" : "thoughts"}.`);
|
|
58
|
+
}
|
|
59
|
+
// Journal index
|
|
60
|
+
if (journalFiles.length > 0) {
|
|
61
|
+
const sorted = [...journalFiles].sort((a, b) => b.mtime - a.mtime).slice(0, MAX_JOURNAL_FILES);
|
|
62
|
+
const indexLines = ["## journal"];
|
|
63
|
+
for (const file of sorted) {
|
|
64
|
+
const ago = formatRelativeTime(nowMs, file.mtime);
|
|
65
|
+
const previewClause = file.preview ? ` — ${file.preview}` : "";
|
|
66
|
+
indexLines.push(`- ${file.name} (${ago})${previewClause}`);
|
|
67
|
+
}
|
|
68
|
+
sections.push(indexLines.join("\n"));
|
|
69
|
+
}
|
|
70
|
+
// Journal entries since last surface
|
|
71
|
+
if (lastSurfaceAt && journalFiles.length > 0) {
|
|
72
|
+
const surfaceMs = new Date(lastSurfaceAt).getTime();
|
|
73
|
+
const entriesSinceSurface = journalFiles.filter((f) => f.mtime > surfaceMs).length;
|
|
74
|
+
if (entriesSinceSurface > 0) {
|
|
75
|
+
const surfaceElapsed = formatElapsed(nowMs - surfaceMs);
|
|
76
|
+
sections.push(`${entriesSinceSurface} journal ${entriesSinceSurface === 1 ? "entry" : "entries"} since you last surfaced, ${surfaceElapsed} ago.`);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
// Stale obligation alerts
|
|
80
|
+
const staleObligations = pendingObligations.filter((o) => o.staleness >= STALE_THRESHOLD_MS);
|
|
81
|
+
if (staleObligations.length > 0) {
|
|
82
|
+
for (const obligation of staleObligations) {
|
|
83
|
+
sections.push(`this has been sitting for ${formatElapsed(obligation.staleness)}: ${obligation.content}`);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
// Checkpoint
|
|
87
|
+
if (checkpoint) {
|
|
88
|
+
sections.push(`last i remember: ${checkpoint}`);
|
|
89
|
+
}
|
|
90
|
+
(0, runtime_1.emitNervesEvent)({
|
|
91
|
+
component: "senses",
|
|
92
|
+
event: "senses.contextual_heartbeat_built",
|
|
93
|
+
message: "contextual heartbeat built",
|
|
94
|
+
meta: {
|
|
95
|
+
hasJournal: journalFiles.length > 0,
|
|
96
|
+
hasLastCompleted: !!lastCompletedAt,
|
|
97
|
+
obligationCount: pendingObligations.length,
|
|
98
|
+
staleCount: staleObligations.length,
|
|
99
|
+
},
|
|
100
|
+
});
|
|
101
|
+
return sections.join("\n\n");
|
|
102
|
+
}
|
|
@@ -66,6 +66,8 @@ const manager_1 = require("../heart/bridges/manager");
|
|
|
66
66
|
const session_activity_1 = require("../heart/session-activity");
|
|
67
67
|
const bluebubbles_1 = require("./bluebubbles");
|
|
68
68
|
const obligations_2 = require("../heart/obligations");
|
|
69
|
+
const contextual_heartbeat_1 = require("./contextual-heartbeat");
|
|
70
|
+
const journal_index_1 = require("../mind/journal-index");
|
|
69
71
|
const DEFAULT_INNER_DIALOG_INSTINCTS = [
|
|
70
72
|
{
|
|
71
73
|
id: "heartbeat_checkin",
|
|
@@ -104,7 +106,7 @@ function buildNonCanonicalCleanupNudge(nonCanonicalPaths) {
|
|
|
104
106
|
}
|
|
105
107
|
return [
|
|
106
108
|
"## canonical cleanup nudge",
|
|
107
|
-
"I found non-canonical files in my bundle. I should distill anything valuable into
|
|
109
|
+
"I found non-canonical files in my bundle. I should distill anything valuable into my diary and remove these files.",
|
|
108
110
|
...listed,
|
|
109
111
|
].join("\n");
|
|
110
112
|
}
|
|
@@ -483,6 +485,48 @@ async function runInnerDialogTurn(options) {
|
|
|
483
485
|
const taskContent = readTaskFile((0, identity_1.getAgentRoot)(), options.taskId);
|
|
484
486
|
userContent = buildTaskTriggeredMessage(options.taskId, taskContent, state.checkpoint);
|
|
485
487
|
}
|
|
488
|
+
else if (reason === "heartbeat") {
|
|
489
|
+
const agentRoot = (0, identity_1.getAgentRoot)();
|
|
490
|
+
const journalDir = path.join(agentRoot, "journal");
|
|
491
|
+
const runtimeStatePath = innerDialogRuntimeStatePath(sessionFilePath);
|
|
492
|
+
// Read lastCompletedAt from current runtime state
|
|
493
|
+
let lastCompletedAt;
|
|
494
|
+
try {
|
|
495
|
+
const raw = fs.readFileSync(runtimeStatePath, "utf-8");
|
|
496
|
+
const runtimeState = JSON.parse(raw);
|
|
497
|
+
lastCompletedAt = typeof runtimeState.lastCompletedAt === "string" ? runtimeState.lastCompletedAt : undefined;
|
|
498
|
+
}
|
|
499
|
+
catch {
|
|
500
|
+
// no runtime state — will get cold start fallback
|
|
501
|
+
}
|
|
502
|
+
// Gather pending obligations for display
|
|
503
|
+
const obligations = (0, obligations_1.listActiveObligations)(agentName);
|
|
504
|
+
const nowMs = now().getTime();
|
|
505
|
+
const pendingObligations = obligations.map((o) => ({
|
|
506
|
+
id: o.id,
|
|
507
|
+
content: o.delegatedContent,
|
|
508
|
+
friendName: o.origin.friendId,
|
|
509
|
+
timestamp: o.createdAt,
|
|
510
|
+
staleness: nowMs - o.createdAt,
|
|
511
|
+
}));
|
|
512
|
+
userContent = (0, contextual_heartbeat_1.buildContextualHeartbeat)({
|
|
513
|
+
journalDir,
|
|
514
|
+
lastCompletedAt,
|
|
515
|
+
pendingObligations,
|
|
516
|
+
lastSurfaceAt: undefined, // TODO: wire when surface tracking is added
|
|
517
|
+
checkpoint: state.checkpoint,
|
|
518
|
+
now,
|
|
519
|
+
readJournalDir: () => (0, prompt_1.readJournalFiles)(journalDir),
|
|
520
|
+
});
|
|
521
|
+
// Piggyback journal embedding indexing (best-effort, fire-and-forget)
|
|
522
|
+
/* v8 ignore start -- journal indexing piggyback: embedding provider may not be available; tested via journal-index unit tests @preserve */
|
|
523
|
+
void (0, journal_index_1.indexJournalFiles)(journalDir, path.join(journalDir, ".index.json"), {
|
|
524
|
+
embed: async () => [],
|
|
525
|
+
}).catch(() => {
|
|
526
|
+
// swallowed: indexing failure must never block heartbeat
|
|
527
|
+
});
|
|
528
|
+
/* v8 ignore stop */
|
|
529
|
+
}
|
|
486
530
|
else {
|
|
487
531
|
userContent = buildInstinctUserMessage(instincts, reason, state);
|
|
488
532
|
}
|