@haaaiawd/second-nature 0.1.38 → 0.1.39

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.
Files changed (46) hide show
  1. package/agent-inner-guide.md +18 -0
  2. package/index.js +1270 -1262
  3. package/openclaw.plugin.json +29 -29
  4. package/package.json +55 -55
  5. package/runtime/cli/ops/heartbeat-surface.d.ts +75 -60
  6. package/runtime/cli/ops/heartbeat-surface.js +97 -83
  7. package/runtime/cli/ops/ops-router.js +1428 -1282
  8. package/runtime/cli/ops/workspace-heartbeat-runner.js +236 -191
  9. package/runtime/core/second-nature/guidance/apply-guidance.d.ts +12 -10
  10. package/runtime/core/second-nature/guidance/apply-guidance.js +15 -10
  11. package/runtime/core/second-nature/guidance/user-reply-continuity.d.ts +50 -50
  12. package/runtime/core/second-nature/guidance/user-reply-continuity.js +89 -80
  13. package/runtime/core/second-nature/runtime/service-entry.d.ts +39 -36
  14. package/runtime/core/second-nature/runtime/service-entry.js +44 -45
  15. package/runtime/dream/dream-engine.d.ts +14 -0
  16. package/runtime/dream/dream-engine.js +306 -0
  17. package/runtime/dream/dream-input-loader.d.ts +37 -0
  18. package/runtime/dream/dream-input-loader.js +155 -0
  19. package/runtime/dream/dream-scheduler.d.ts +75 -0
  20. package/runtime/dream/dream-scheduler.js +131 -0
  21. package/runtime/dream/index.d.ts +16 -0
  22. package/runtime/dream/index.js +14 -0
  23. package/runtime/dream/insight-extractor.d.ts +32 -0
  24. package/runtime/dream/insight-extractor.js +135 -0
  25. package/runtime/dream/memory-consolidator.d.ts +45 -0
  26. package/runtime/dream/memory-consolidator.js +140 -0
  27. package/runtime/dream/narrative-update-proposal.d.ts +34 -0
  28. package/runtime/dream/narrative-update-proposal.js +83 -0
  29. package/runtime/dream/output-validator.d.ts +20 -0
  30. package/runtime/dream/output-validator.js +110 -0
  31. package/runtime/dream/redaction-gate.d.ts +31 -0
  32. package/runtime/dream/redaction-gate.js +109 -0
  33. package/runtime/dream/relationship-update-proposal.d.ts +27 -0
  34. package/runtime/dream/relationship-update-proposal.js +119 -0
  35. package/runtime/dream/sampler.d.ts +30 -0
  36. package/runtime/dream/sampler.js +65 -0
  37. package/runtime/dream/types.d.ts +187 -0
  38. package/runtime/dream/types.js +11 -0
  39. package/runtime/guidance/fallback.js +20 -17
  40. package/runtime/guidance/guidance-assembler.js +76 -74
  41. package/runtime/guidance/output-guard.d.ts +13 -10
  42. package/runtime/guidance/output-guard.js +53 -29
  43. package/runtime/guidance/template-registry.d.ts +20 -16
  44. package/runtime/guidance/template-registry.js +123 -82
  45. package/runtime/guidance/types.d.ts +98 -84
  46. package/runtime/observability/projections/guidance-audit.js +38 -35
@@ -0,0 +1,140 @@
1
+ /**
2
+ * Rules-based memory consolidation.
3
+ *
4
+ * Core logic: dedupe, merge, stale cleanup, and conflict marking on evidence,
5
+ * chronicle, and existing memory entries. No LLM required.
6
+ *
7
+ * - Deduplicate by sourceRef id + kind; keep the most recent.
8
+ * - Merge entries with same kind + similar summary (naive prefix match).
9
+ * - Mark entries older than 90 days as stale (retain but flag).
10
+ * - Mark entries with conflicting sourceRefs as conflict.
11
+ * Test coverage: tests/integration/dream/t7-1-1-dream-pipeline.test.ts
12
+ */
13
+ const STALE_DAYS = 90;
14
+ function isStale(createdAt) {
15
+ const then = new Date(createdAt).getTime();
16
+ const now = Date.now();
17
+ return now - then > STALE_DAYS * 24 * 60 * 60 * 1000;
18
+ }
19
+ function keyForSourceRefs(refs) {
20
+ return refs
21
+ .map((r) => `${r.sourceId}:${r.kind}`)
22
+ .sort()
23
+ .join("|");
24
+ }
25
+ function summariesSimilar(a, b) {
26
+ const norm = (s) => s
27
+ .toLowerCase()
28
+ .replace(/[^a-z0-9\s]/g, "")
29
+ .split(/\s+/)
30
+ .filter(Boolean)
31
+ .slice(0, 5)
32
+ .join(" ");
33
+ return norm(a) === norm(b);
34
+ }
35
+ export function consolidateMemory(input) {
36
+ const allRaw = [
37
+ ...input.evidenceSummaries.map((e) => ({
38
+ ...e,
39
+ origin: "evidence",
40
+ })),
41
+ ...input.chronicleSummaries.map((c) => ({
42
+ ...c,
43
+ origin: "chronicle",
44
+ })),
45
+ ...(input.toolExperienceSummaries ?? []).map((t) => ({
46
+ ...t,
47
+ origin: "tool_experience",
48
+ })),
49
+ ...input.existingEntries.map((e) => ({
50
+ id: e.entryId,
51
+ summary: e.summary,
52
+ sourceRefs: e.sourceRefs,
53
+ createdAt: e.createdAt,
54
+ origin: "memory",
55
+ })),
56
+ ];
57
+ // 1. Deduplicate by sourceRef key, keep most recent
58
+ const bySourceKey = new Map();
59
+ for (const item of allRaw) {
60
+ const key = keyForSourceRefs(item.sourceRefs);
61
+ const existing = bySourceKey.get(key);
62
+ if (!existing || item.createdAt > existing.createdAt) {
63
+ bySourceKey.set(key, item);
64
+ }
65
+ }
66
+ const deduped = Array.from(bySourceKey.values());
67
+ const dedupeCount = allRaw.length - deduped.length;
68
+ // 2. Merge similar summaries
69
+ const merged = [];
70
+ const used = new Set();
71
+ for (const item of deduped) {
72
+ if (used.has(item.id))
73
+ continue;
74
+ const group = [item];
75
+ for (const other of deduped) {
76
+ if (other.id === item.id || used.has(other.id))
77
+ continue;
78
+ if (summariesSimilar(item.summary, other.summary)) {
79
+ group.push(other);
80
+ used.add(other.id);
81
+ }
82
+ }
83
+ used.add(item.id);
84
+ const mergedRefs = [];
85
+ const seenRefKeys = new Set();
86
+ for (const g of group) {
87
+ for (const ref of g.sourceRefs) {
88
+ const rk = `${ref.sourceId}:${ref.kind}`;
89
+ if (!seenRefKeys.has(rk)) {
90
+ seenRefKeys.add(rk);
91
+ mergedRefs.push(ref);
92
+ }
93
+ }
94
+ }
95
+ const latestCreatedAt = group
96
+ .map((g) => g.createdAt)
97
+ .sort()
98
+ .at(-1);
99
+ merged.push({
100
+ entryId: `consolidated:${crypto.randomUUID()}`,
101
+ kind: group[0].origin,
102
+ summary: group[0].summary,
103
+ sourceRefs: mergedRefs,
104
+ createdAt: latestCreatedAt,
105
+ });
106
+ }
107
+ // 3. Stale marking
108
+ let staleCount = 0;
109
+ for (const entry of merged) {
110
+ if (isStale(entry.createdAt)) {
111
+ staleCount++;
112
+ }
113
+ }
114
+ // 4. Conflict detection: entries with overlapping sourceRefs but different summaries
115
+ const conflicts = [];
116
+ for (let i = 0; i < merged.length; i++) {
117
+ for (let j = i + 1; j < merged.length; j++) {
118
+ const a = merged[i];
119
+ const b = merged[j];
120
+ const aKeys = new Set(a.sourceRefs.map((r) => `${r.sourceId}:${r.kind}`));
121
+ const overlap = b.sourceRefs.some((r) => aKeys.has(`${r.sourceId}:${r.kind}`));
122
+ if (overlap && !summariesSimilar(a.summary, b.summary)) {
123
+ conflicts.push({
124
+ entryId: a.entryId,
125
+ reason: `conflict_with:${b.entryId}`,
126
+ });
127
+ conflicts.push({
128
+ entryId: b.entryId,
129
+ reason: `conflict_with:${a.entryId}`,
130
+ });
131
+ }
132
+ }
133
+ }
134
+ return {
135
+ entries: merged,
136
+ conflicts,
137
+ staleCount,
138
+ dedupeCount,
139
+ };
140
+ }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Narrative Update Proposal
3
+ *
4
+ * Core logic: generate a narrative update proposal based on evidence and
5
+ * extracted insights. Claims must be source-backed; unsupported claims are
6
+ * flagged and the proposal is degraded or blocked.
7
+ *
8
+ * - Focus: most prominent theme from insights or evidence.
9
+ * - Progress: learning and observation insights mapped to progress entries.
10
+ * - NextIntent: inferred from unresolved conflicts or high-priority patterns.
11
+ * Test coverage: tests/unit/dream/t7-1-4-narrative-update.test.ts
12
+ */
13
+ import type { DreamNarrativeUpdate } from "./types.js";
14
+ export interface NarrativeProposalInput {
15
+ evidenceSummaries: Array<{
16
+ id: string;
17
+ summary: string;
18
+ createdAt: string;
19
+ }>;
20
+ insights: Array<{
21
+ id: string;
22
+ type: "pattern" | "learning" | "observation" | "conflict";
23
+ summary: string;
24
+ sourceRefs: string[];
25
+ confidence: number;
26
+ }>;
27
+ priorFocus?: string;
28
+ }
29
+ export interface NarrativeProposalResult {
30
+ proposal?: DreamNarrativeUpdate;
31
+ unsupportedClaims: string[];
32
+ blocked: boolean;
33
+ }
34
+ export declare function draftNarrativeFromDream(input: NarrativeProposalInput): NarrativeProposalResult;
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Narrative Update Proposal
3
+ *
4
+ * Core logic: generate a narrative update proposal based on evidence and
5
+ * extracted insights. Claims must be source-backed; unsupported claims are
6
+ * flagged and the proposal is degraded or blocked.
7
+ *
8
+ * - Focus: most prominent theme from insights or evidence.
9
+ * - Progress: learning and observation insights mapped to progress entries.
10
+ * - NextIntent: inferred from unresolved conflicts or high-priority patterns.
11
+ * Test coverage: tests/unit/dream/t7-1-4-narrative-update.test.ts
12
+ */
13
+ export function draftNarrativeFromDream(input) {
14
+ const unsupportedClaims = [];
15
+ if (input.evidenceSummaries.length === 0 && input.insights.length === 0) {
16
+ return {
17
+ unsupportedClaims: ["no_evidence_for_narrative"],
18
+ blocked: true,
19
+ };
20
+ }
21
+ // Focus: highest-confidence insight summary, or first evidence if no insights
22
+ let focus = input.priorFocus ?? "continue exploration";
23
+ const highConfidenceInsights = input.insights.filter((i) => i.confidence >= 0.6);
24
+ if (highConfidenceInsights.length > 0) {
25
+ // Pick the insight with highest confidence
26
+ const top = highConfidenceInsights.sort((a, b) => b.confidence - a.confidence)[0];
27
+ focus = top.summary.slice(0, 120);
28
+ }
29
+ else if (input.evidenceSummaries.length > 0) {
30
+ focus = input.evidenceSummaries[0].summary.slice(0, 120);
31
+ }
32
+ // Progress: learning + observation insights become progress entries
33
+ const progressAdditions = [];
34
+ for (const insight of input.insights) {
35
+ if (insight.type === "learning" || insight.type === "observation") {
36
+ progressAdditions.push(insight.summary.slice(0, 200));
37
+ }
38
+ }
39
+ if (progressAdditions.length === 0 && input.evidenceSummaries.length > 0) {
40
+ // Fallback: use most recent evidence as progress
41
+ const recent = [...input.evidenceSummaries].sort((a, b) => b.createdAt.localeCompare(a.createdAt))[0];
42
+ if (recent) {
43
+ progressAdditions.push(recent.summary.slice(0, 200));
44
+ }
45
+ }
46
+ // NextIntent: if conflicts exist, intent is to resolve; otherwise continue
47
+ const hasConflict = input.insights.some((i) => i.type === "conflict");
48
+ const nextIntent = hasConflict
49
+ ? "resolve_conflicts_and_validate"
50
+ : "continue_current_focus";
51
+ // Source refs: collect all insight source refs + evidence ids
52
+ const sourceRefs = new Set();
53
+ for (const insight of input.insights) {
54
+ for (const ref of insight.sourceRefs) {
55
+ sourceRefs.add(ref);
56
+ }
57
+ }
58
+ for (const ev of input.evidenceSummaries) {
59
+ sourceRefs.add(ev.id);
60
+ }
61
+ // Confidence: average of insight confidences, or 0.5 fallback
62
+ const avgConfidence = input.insights.length > 0
63
+ ? input.insights.reduce((sum, i) => sum + i.confidence, 0) /
64
+ input.insights.length
65
+ : 0.5;
66
+ // Degrade if confidence too low
67
+ if (avgConfidence < 0.3) {
68
+ unsupportedClaims.push("low_average_confidence_for_narrative");
69
+ }
70
+ const blocked = unsupportedClaims.length > 0 && avgConfidence < 0.2;
71
+ return {
72
+ proposal: {
73
+ focus,
74
+ progressAdditions: progressAdditions.slice(0, 20),
75
+ nextIntent,
76
+ confidenceDelta: Number(avgConfidence.toFixed(2)),
77
+ sourceRefs: Array.from(sourceRefs).slice(0, 50),
78
+ unsupportedClaims,
79
+ },
80
+ unsupportedClaims,
81
+ blocked,
82
+ };
83
+ }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Dream output validator.
3
+ *
4
+ * Core logic: schema, source grounding, sensitivity, and unsupported claim
5
+ * checks on candidate DreamOutput. Decides accepted eligibility or archive reason.
6
+ * Test coverage: tests/integration/dream/t7-1-1-dream-pipeline.test.ts
7
+ */
8
+ import type { DreamOutput, DreamOutputValidation } from "./types.js";
9
+ export interface ValidationInput {
10
+ output: DreamOutput;
11
+ inputEvidenceIds: string[];
12
+ inputChronicleIds: string[];
13
+ inputToolExperienceIds?: string[];
14
+ }
15
+ export interface ValidationResult {
16
+ eligible: boolean;
17
+ validation: DreamOutputValidation;
18
+ archiveReasons: string[];
19
+ }
20
+ export declare function validateDreamOutput(input: ValidationInput): ValidationResult;
@@ -0,0 +1,110 @@
1
+ /**
2
+ * Dream output validator.
3
+ *
4
+ * Core logic: schema, source grounding, sensitivity, and unsupported claim
5
+ * checks on candidate DreamOutput. Decides accepted eligibility or archive reason.
6
+ * Test coverage: tests/integration/dream/t7-1-1-dream-pipeline.test.ts
7
+ */
8
+ function hasUnsupportedClaims(output) {
9
+ const claims = [];
10
+ for (const insight of output.insights) {
11
+ if (insight.confidence < 0.3) {
12
+ claims.push(`insight_low_confidence:${insight.id}`);
13
+ }
14
+ if (insight.sourceRefs.length === 0) {
15
+ claims.push(`insight_no_source:${insight.id}`);
16
+ }
17
+ }
18
+ if (output.narrativeUpdate) {
19
+ if (output.narrativeUpdate.unsupportedClaims.length > 0) {
20
+ claims.push(...output.narrativeUpdate.unsupportedClaims);
21
+ }
22
+ if (output.narrativeUpdate.sourceRefs.length === 0) {
23
+ claims.push("narrative_update_no_source");
24
+ }
25
+ }
26
+ if (output.relationshipUpdate) {
27
+ if (output.relationshipUpdate.sourceRefs.length === 0) {
28
+ claims.push("relationship_update_no_source");
29
+ }
30
+ }
31
+ return claims;
32
+ }
33
+ function isSourceGrounded(output, inputEvidenceIds, inputChronicleIds, inputToolExperienceIds) {
34
+ const validSourceIds = new Set([
35
+ ...inputEvidenceIds,
36
+ ...inputChronicleIds,
37
+ ...(inputToolExperienceIds ?? []),
38
+ ]);
39
+ for (const entry of output.canonicalEntries) {
40
+ for (const ref of entry.sourceRefs) {
41
+ if (!validSourceIds.has(ref.sourceId)) {
42
+ return false;
43
+ }
44
+ }
45
+ }
46
+ for (const insight of output.insights) {
47
+ for (const refId of insight.sourceRefs) {
48
+ if (!validSourceIds.has(refId)) {
49
+ return false;
50
+ }
51
+ }
52
+ }
53
+ return true;
54
+ }
55
+ function hasSensitivityIssues(output) {
56
+ const issues = [];
57
+ for (const entry of output.canonicalEntries) {
58
+ const text = JSON.stringify(entry).toLowerCase();
59
+ if (text.includes("password") || text.includes("token") || text.includes("secret")) {
60
+ issues.push(`sensitivity_in_entry:${entry.entryId}`);
61
+ }
62
+ }
63
+ return issues;
64
+ }
65
+ export function validateDreamOutput(input) {
66
+ const errors = [];
67
+ const archiveReasons = [];
68
+ // Schema: basic structural checks
69
+ const schemaValid = typeof input.output.outputId === "string" &&
70
+ input.output.outputId.length > 0 &&
71
+ typeof input.output.runId === "string" &&
72
+ Array.isArray(input.output.canonicalEntries) &&
73
+ Array.isArray(input.output.insights);
74
+ if (!schemaValid) {
75
+ errors.push("schema_invalid");
76
+ archiveReasons.push("schema_invalid");
77
+ }
78
+ // Source grounding
79
+ const sourceGrounded = isSourceGrounded(input.output, input.inputEvidenceIds, input.inputChronicleIds, input.inputToolExperienceIds);
80
+ if (!sourceGrounded) {
81
+ errors.push("source_not_grounded");
82
+ archiveReasons.push("source_not_grounded");
83
+ }
84
+ // Sensitivity
85
+ const sensitivityIssues = hasSensitivityIssues(input.output);
86
+ const sensitivityClean = sensitivityIssues.length === 0;
87
+ if (!sensitivityClean) {
88
+ errors.push(...sensitivityIssues);
89
+ archiveReasons.push(...sensitivityIssues);
90
+ }
91
+ // Unsupported claims
92
+ const unsupportedClaims = hasUnsupportedClaims(input.output);
93
+ if (unsupportedClaims.length > 0) {
94
+ errors.push(...unsupportedClaims);
95
+ archiveReasons.push("unsupported_claims_present");
96
+ }
97
+ const eligible = schemaValid && sourceGrounded && sensitivityClean && unsupportedClaims.length === 0;
98
+ return {
99
+ eligible,
100
+ validation: {
101
+ schemaValid,
102
+ sourceGrounded,
103
+ sensitivityClean,
104
+ unsupportedClaims,
105
+ errors,
106
+ checkedAt: new Date().toISOString(),
107
+ },
108
+ archiveReasons,
109
+ };
110
+ }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Dream redaction gate.
3
+ *
4
+ * Core logic: before sending evidence to LLM, strip credential-like fields,
5
+ * PII patterns, and sensitive platform payload. If redaction fails or
6
+ * sensitivity is too high, block the LLM stage and record reason.
7
+ * Test coverage: tests/integration/dream/t7-1-1-dream-pipeline.test.ts
8
+ */
9
+ import type { RedactedEvidenceBundle } from "./types.js";
10
+ export interface RedactionInput {
11
+ evidenceSummaries: string[];
12
+ chronicleSummaries: string[];
13
+ activeMemorySummaries?: string[];
14
+ sensitivityFlags?: string[];
15
+ }
16
+ export interface RedactionResult {
17
+ allowed: boolean;
18
+ redactedEvidence: string[];
19
+ redactedChronicle: string[];
20
+ redactedMemory: string[];
21
+ blockedReason?: string;
22
+ credentialHits: number;
23
+ piiHits: number;
24
+ }
25
+ /**
26
+ * Produce a RedactedEvidenceBundle brand type (DR-027).
27
+ * Must be called before passing evidence to ModelAssistPort.
28
+ * Returns null if redaction gate blocks the bundle.
29
+ */
30
+ export declare function redactBundle(evidence: string[], chronicle: string[], memory?: string[]): RedactedEvidenceBundle | null;
31
+ export declare function redactDreamInput(input: RedactionInput): RedactionResult;
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Dream redaction gate.
3
+ *
4
+ * Core logic: before sending evidence to LLM, strip credential-like fields,
5
+ * PII patterns, and sensitive platform payload. If redaction fails or
6
+ * sensitivity is too high, block the LLM stage and record reason.
7
+ * Test coverage: tests/integration/dream/t7-1-1-dream-pipeline.test.ts
8
+ */
9
+ const CREDENTIAL_PATTERNS = [
10
+ /password\s*[:=]\s*\S+/gi,
11
+ /token\s*[:=]\s*\S+/gi,
12
+ /api[_-]?key\s*[:=]\s*\S+/gi,
13
+ /secret\s*[:=]\s*\S+/gi,
14
+ /cookie\s*[:=]\s*\S+/gi,
15
+ /bearer\s+\S+/gi,
16
+ ];
17
+ const PII_PATTERNS = [
18
+ /\b\d{3}-\d{2}-\d{4}\b/g, // SSN-like
19
+ /\b\d{4}[\s-]?\d{4}[\s-]?\d{4}[\s-]?\d{4}\b/g, // Credit card-like
20
+ /[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/g, // Email
21
+ ];
22
+ /**
23
+ * Produce a RedactedEvidenceBundle brand type (DR-027).
24
+ * Must be called before passing evidence to ModelAssistPort.
25
+ * Returns null if redaction gate blocks the bundle.
26
+ */
27
+ export function redactBundle(evidence, chronicle, memory) {
28
+ const result = redactDreamInput({
29
+ evidenceSummaries: evidence,
30
+ chronicleSummaries: chronicle,
31
+ activeMemorySummaries: memory ?? [],
32
+ });
33
+ if (!result.allowed)
34
+ return null;
35
+ return {
36
+ _brand: "redacted",
37
+ evidence: Object.freeze(result.redactedEvidence),
38
+ chronicle: Object.freeze(result.redactedChronicle),
39
+ memory: result.redactedMemory.length > 0
40
+ ? Object.freeze(result.redactedMemory)
41
+ : undefined,
42
+ };
43
+ }
44
+ function redactText(text) {
45
+ let redacted = text;
46
+ let credentialHits = 0;
47
+ let piiHits = 0;
48
+ for (const pattern of CREDENTIAL_PATTERNS) {
49
+ const matches = redacted.match(pattern);
50
+ if (matches) {
51
+ credentialHits += matches.length;
52
+ redacted = redacted.replace(pattern, "[REDACTED_CREDENTIAL]");
53
+ }
54
+ }
55
+ for (const pattern of PII_PATTERNS) {
56
+ const matches = redacted.match(pattern);
57
+ if (matches) {
58
+ piiHits += matches.length;
59
+ redacted = redacted.replace(pattern, "[REDACTED_PII]");
60
+ }
61
+ }
62
+ return { redacted, credentialHits, piiHits };
63
+ }
64
+ export function redactDreamInput(input) {
65
+ let totalCredentialHits = 0;
66
+ let totalPiiHits = 0;
67
+ const redactList = (items) => items.map((item) => {
68
+ const result = redactText(item);
69
+ totalCredentialHits += result.credentialHits;
70
+ totalPiiHits += result.piiHits;
71
+ return result.redacted;
72
+ });
73
+ const redactedEvidence = redactList(input.evidenceSummaries);
74
+ const redactedChronicle = redactList(input.chronicleSummaries);
75
+ const redactedMemory = redactList(input.activeMemorySummaries ?? []);
76
+ // If any sensitivity flag is "credential" or "sensitive", block LLM stage
77
+ const hasHighSensitivity = (input.sensitivityFlags ?? []).some((f) => f === "credential" || f === "sensitive");
78
+ if (hasHighSensitivity) {
79
+ return {
80
+ allowed: false,
81
+ redactedEvidence,
82
+ redactedChronicle,
83
+ redactedMemory,
84
+ blockedReason: "sensitivity_flag_blocks_llm",
85
+ credentialHits: totalCredentialHits,
86
+ piiHits: totalPiiHits,
87
+ };
88
+ }
89
+ // If credential hits are excessive, also block
90
+ if (totalCredentialHits > 3) {
91
+ return {
92
+ allowed: false,
93
+ redactedEvidence,
94
+ redactedChronicle,
95
+ redactedMemory,
96
+ blockedReason: "excessive_credential_exposure",
97
+ credentialHits: totalCredentialHits,
98
+ piiHits: totalPiiHits,
99
+ };
100
+ }
101
+ return {
102
+ allowed: true,
103
+ redactedEvidence,
104
+ redactedChronicle,
105
+ redactedMemory,
106
+ credentialHits: totalCredentialHits,
107
+ piiHits: totalPiiHits,
108
+ };
109
+ }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Relationship Update Proposal
3
+ *
4
+ * Core logic: generate relationship update proposal based on chronicle entries.
5
+ * Tone/timing/topic deltas include sourceRefs and confidence.
6
+ * Owner no-reply signal is recorded as cooldown without inventing preference.
7
+ * Prevents over-inference from single samples.
8
+ * Test coverage: tests/unit/dream/t7-1-5-relationship-update.test.ts
9
+ */
10
+ import type { DreamRelationshipUpdate } from "./types.js";
11
+ export interface RelationshipProposalInput {
12
+ chronicleEntries: Array<{
13
+ id: string;
14
+ summary: string;
15
+ createdAt: string;
16
+ kind?: string;
17
+ }>;
18
+ priorTone?: string;
19
+ priorTiming?: string;
20
+ priorTopic?: string;
21
+ }
22
+ export interface RelationshipProposalResult {
23
+ proposal?: DreamRelationshipUpdate;
24
+ unsupportedClaims: string[];
25
+ cooldown?: boolean;
26
+ }
27
+ export declare function draftRelationshipFromDream(input: RelationshipProposalInput): RelationshipProposalResult;
@@ -0,0 +1,119 @@
1
+ /**
2
+ * Relationship Update Proposal
3
+ *
4
+ * Core logic: generate relationship update proposal based on chronicle entries.
5
+ * Tone/timing/topic deltas include sourceRefs and confidence.
6
+ * Owner no-reply signal is recorded as cooldown without inventing preference.
7
+ * Prevents over-inference from single samples.
8
+ * Test coverage: tests/unit/dream/t7-1-5-relationship-update.test.ts
9
+ */
10
+ // Keywords for tone inference
11
+ const POSITIVE_TONE = [
12
+ "agree", "thanks", "appreciate", "helpful", "good", "great",
13
+ "love", "like", "enjoy", "excited", "happy",
14
+ ];
15
+ const NEGATIVE_TONE = [
16
+ "disagree", "frustrated", "annoying", "bad", "hate", "dislike",
17
+ "angry", "upset", "disappointed", "concerned",
18
+ ];
19
+ const BUSY_TIMING = [
20
+ "busy", "swamped", "occupied", "tight schedule", "no time",
21
+ ];
22
+ // Keywords for topic inference
23
+ const TOPIC_PATTERNS = {
24
+ work: ["work", "project", "task", "job", "delivery", "deadline"],
25
+ personal: ["family", "life", "health", "weekend", "trip"],
26
+ tech: ["code", "system", "bug", "feature", "architecture", "design"],
27
+ social: ["friend", "community", "meetup", "event", "collaboration"],
28
+ };
29
+ function inferTone(text) {
30
+ const lower = text.toLowerCase();
31
+ const pos = POSITIVE_TONE.filter((w) => lower.includes(w)).length;
32
+ const neg = NEGATIVE_TONE.filter((w) => lower.includes(w)).length;
33
+ if (pos > neg && pos > 0)
34
+ return { tone: "positive", score: pos };
35
+ if (neg > pos && neg > 0)
36
+ return { tone: "negative", score: neg };
37
+ if (pos === neg && pos > 0)
38
+ return { tone: "mixed", score: pos };
39
+ return { tone: "neutral", score: 0 };
40
+ }
41
+ function inferTiming(text) {
42
+ const lower = text.toLowerCase();
43
+ const busy = BUSY_TIMING.filter((w) => lower.includes(w)).length;
44
+ if (busy > 0)
45
+ return { timing: "busy", score: busy };
46
+ // Check for quick replies (indicator in summary)
47
+ if (lower.includes("quick reply") || lower.includes("prompt response")) {
48
+ return { timing: "responsive", score: 1 };
49
+ }
50
+ return { timing: "normal", score: 0 };
51
+ }
52
+ function inferTopic(text) {
53
+ const lower = text.toLowerCase();
54
+ let bestTopic = "general";
55
+ let bestScore = 0;
56
+ for (const [topic, patterns] of Object.entries(TOPIC_PATTERNS)) {
57
+ const score = patterns.filter((p) => lower.includes(p)).length;
58
+ if (score > bestScore) {
59
+ bestScore = score;
60
+ bestTopic = topic;
61
+ }
62
+ }
63
+ return { topic: bestTopic, score: bestScore };
64
+ }
65
+ export function draftRelationshipFromDream(input) {
66
+ const unsupportedClaims = [];
67
+ const replyEntries = input.chronicleEntries.filter((e) => e.kind === "owner_reply" || e.kind === "user_interaction");
68
+ // No-reply signal: if no owner replies, record cooldown without inventing preference
69
+ if (replyEntries.length === 0) {
70
+ return {
71
+ unsupportedClaims: [],
72
+ cooldown: true,
73
+ };
74
+ }
75
+ // Prevent over-inference from single sample
76
+ if (replyEntries.length < 2) {
77
+ unsupportedClaims.push("single_sample_insufficient_for_relationship_inference");
78
+ }
79
+ // Aggregate tone/timing/topic across replies
80
+ const toneVotes = new Map();
81
+ const timingVotes = new Map();
82
+ const topicVotes = new Map();
83
+ const sourceRefs = [];
84
+ for (const entry of replyEntries) {
85
+ const tone = inferTone(entry.summary);
86
+ toneVotes.set(tone.tone, (toneVotes.get(tone.tone) ?? 0) + tone.score);
87
+ const timing = inferTiming(entry.summary);
88
+ timingVotes.set(timing.timing, (timingVotes.get(timing.timing) ?? 0) + timing.score);
89
+ const topic = inferTopic(entry.summary);
90
+ topicVotes.set(topic.topic, (topicVotes.get(topic.topic) ?? 0) + topic.score);
91
+ sourceRefs.push(entry.id);
92
+ }
93
+ const topTone = Array.from(toneVotes.entries()).sort((a, b) => b[1] - a[1])[0];
94
+ const topTiming = Array.from(timingVotes.entries()).sort((a, b) => b[1] - a[1])[0];
95
+ const topTopic = Array.from(topicVotes.entries()).sort((a, b) => b[1] - a[1])[0];
96
+ // Compute confidence based on sample size and signal strength
97
+ const sampleSize = replyEntries.length;
98
+ const signalStrength = Math.max(topTone?.[1] ?? 0, topTiming?.[1] ?? 0, topTopic?.[1] ?? 0);
99
+ const confidence = Math.min(0.9, 0.3 + sampleSize * 0.05 + signalStrength * 0.05);
100
+ const toneDelta = topTone && topTone[1] > 0
101
+ ? `tone_observed_${topTone[0]}`
102
+ : undefined;
103
+ const timingDelta = topTiming && topTiming[1] > 0
104
+ ? `timing_observed_${topTiming[0]}`
105
+ : undefined;
106
+ const topicDelta = topTopic && topTopic[1] > 0
107
+ ? `topic_observed_${topTopic[0]}`
108
+ : undefined;
109
+ return {
110
+ proposal: {
111
+ toneDelta,
112
+ timingDelta,
113
+ topicDelta,
114
+ sourceRefs: sourceRefs.slice(0, 20),
115
+ confidence: Number(confidence.toFixed(2)),
116
+ },
117
+ unsupportedClaims,
118
+ };
119
+ }