portable-agent-layer 0.91.8 → 0.93.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/README.md +1 -0
- package/assets/schema/pal-settings.schema.json +8 -0
- package/assets/templates/pal-settings.json +2 -0
- package/package.json +4 -3
- package/src/cli/infer.ts +142 -0
- package/src/cli/knowledge.ts +97 -0
- package/src/cli/tree.ts +2 -0
- package/src/hooks/handlers/entity-extraction.ts +85 -0
- package/src/hooks/handlers/inject-retrieval.ts +4 -0
- package/src/hooks/lib/daily-nudge.ts +29 -6
- package/src/hooks/lib/entity-cards.ts +112 -0
- package/src/hooks/lib/entity-extraction.ts +334 -0
- package/src/hooks/lib/entity-names.ts +242 -0
- package/src/hooks/lib/entity-review.ts +88 -0
- package/src/hooks/lib/inference.ts +38 -4
- package/src/hooks/lib/models.ts +2 -1
- package/src/hooks/lib/paths.ts +6 -2
- package/src/hooks/lib/stop.ts +19 -0
- package/src/hooks/lib/token-usage.ts +1 -0
- package/src/tools/knowledge/ingest.ts +25 -8
package/README.md
CHANGED
|
@@ -85,6 +85,7 @@ pal cli status # check your setup
|
|
|
85
85
|
| `pal cli migrate` | Run pending data migrations (non-destructive) |
|
|
86
86
|
| `pal cli analyze [--actionable]` | Learning analysis: rating trends, failure patterns, graduation candidates |
|
|
87
87
|
| `pal cli usage` | Summarize token usage and estimated cost |
|
|
88
|
+
| `pal cli infer` | One model call for scripts and bots, on the active agent's route: prompt on stdin, reply on stdout. `--tier small\|medium`, `--system <file>`, `--schema <file>` for a JSON reply checked against it, `--timeout <seconds>`. The call loads no PAL hooks or context; exits 1 with the reason when no route answers |
|
|
88
89
|
| `pal cli actor [label <name>]` | Show or rename the actor — who caused a record. Travels with an export, so a shared memory can tell two people apart |
|
|
89
90
|
| `pal cli machine [label <name>]` | Show or rename this install — where a record was written. Never leaves the machine |
|
|
90
91
|
| `pal cli telos` | Which TELOS topics are answered, in interview order, and which one comes next |
|
|
@@ -98,6 +98,14 @@
|
|
|
98
98
|
"type": "boolean",
|
|
99
99
|
"description": "Let the rules you approved from your past corrections steer the agent: a rule triggered by your prompt is injected before the agent answers, and a reply that matches a rule's trigger is sent back to the agent once at stop. Each firing is logged to memory/adaptation/rule-events.jsonl. Set false to switch every rule off. Reply-side rules do not run on Cursor, whose stop payload carries no reply text, or on opencode, whose plugin does not run the stop check."
|
|
100
100
|
},
|
|
101
|
+
"entityCards": {
|
|
102
|
+
"type": "boolean",
|
|
103
|
+
"description": "When a prompt names a person or company from the knowledge store, inject a short card about them before the agent answers. Reads files only; no model call."
|
|
104
|
+
},
|
|
105
|
+
"entityExtraction": {
|
|
106
|
+
"type": "boolean",
|
|
107
|
+
"description": "After a session stops, ask a background model which people and companies your own messages mentioned, and write clear matches and new full names to the knowledge store with the message as source. A name that could be more than one entry, or a first name alone, waits for `pal cli knowledge review` instead."
|
|
108
|
+
},
|
|
101
109
|
"steeringTestReport": { "type": "boolean", "description": "When true, the assistant notes to the user which steering self-check fired. For the dual-live test period; ships false." }
|
|
102
110
|
}
|
|
103
111
|
},
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "portable-agent-layer",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.93.0",
|
|
4
4
|
"description": "PAL — Portable Agent Layer: persistent personal context for AI coding assistants",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -37,7 +37,7 @@
|
|
|
37
37
|
},
|
|
38
38
|
"scripts": {
|
|
39
39
|
"type-check": "tsc --noEmit",
|
|
40
|
-
"test": "bun test --randomize",
|
|
40
|
+
"test": "bun test --randomize --isolate",
|
|
41
41
|
"test:mutate": "stryker run",
|
|
42
42
|
"test:mutate:diff": "bun .agents/scripts/mutate-diff.ts",
|
|
43
43
|
"format": "biome format",
|
|
@@ -107,6 +107,7 @@
|
|
|
107
107
|
"marked": "18.0.4",
|
|
108
108
|
"pdf-lib": "1.17.1",
|
|
109
109
|
"playwright": "^1.60.0",
|
|
110
|
-
"unpdf": "^0.12.0"
|
|
110
|
+
"unpdf": "^0.12.0",
|
|
111
|
+
"zod": "^4.4.3"
|
|
111
112
|
}
|
|
112
113
|
}
|
package/src/cli/infer.ts
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pal cli infer — one inference call through PAL's own route, for programs
|
|
3
|
+
* outside an agent session. The route, its model for each tier, and the
|
|
4
|
+
* isolation from PAL's hooks are the same ones PAL's background calls use.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { readFileSync } from "node:fs";
|
|
8
|
+
import { z } from "zod";
|
|
9
|
+
import { inference } from "../hooks/lib/inference";
|
|
10
|
+
import { INFERENCE_TIERS, type InferenceTier } from "../hooks/lib/models";
|
|
11
|
+
import { leaf } from "../tools/lib/command";
|
|
12
|
+
|
|
13
|
+
const DEFAULT_TIMEOUT_SECONDS = 60;
|
|
14
|
+
|
|
15
|
+
interface InferValues {
|
|
16
|
+
tier?: string;
|
|
17
|
+
system?: string;
|
|
18
|
+
schema?: string;
|
|
19
|
+
timeout?: string;
|
|
20
|
+
caller?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
type Infer = typeof inference;
|
|
24
|
+
|
|
25
|
+
class InferInputError extends Error {}
|
|
26
|
+
|
|
27
|
+
function tierOf(name = "small"): InferenceTier {
|
|
28
|
+
const tier = INFERENCE_TIERS.find((known) => known === name);
|
|
29
|
+
if (!tier) {
|
|
30
|
+
throw new InferInputError(
|
|
31
|
+
`Unknown tier "${name}". Use ${INFERENCE_TIERS.join(", ")}.`
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
return tier;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function timeoutMs(seconds = String(DEFAULT_TIMEOUT_SECONDS)): number {
|
|
38
|
+
const value = Number(seconds);
|
|
39
|
+
if (!Number.isFinite(value) || value <= 0) {
|
|
40
|
+
throw new InferInputError(`--timeout takes a number of seconds, not "${seconds}".`);
|
|
41
|
+
}
|
|
42
|
+
return value * 1000;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function promptOf(stdin: string): string {
|
|
46
|
+
const prompt = stdin.trim();
|
|
47
|
+
if (!prompt) throw new InferInputError("No prompt: pipe it in on stdin.");
|
|
48
|
+
return prompt;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function readSchema(path: string): Record<string, unknown> {
|
|
52
|
+
try {
|
|
53
|
+
return JSON.parse(readFileSync(path, "utf-8"));
|
|
54
|
+
} catch {
|
|
55
|
+
throw new InferInputError(`--schema ${path} is not a readable JSON file.`);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function assertCheckable(path: string, schema: Record<string, unknown>): void {
|
|
60
|
+
try {
|
|
61
|
+
z.fromJSONSchema(schema);
|
|
62
|
+
} catch (err) {
|
|
63
|
+
throw new InferInputError(
|
|
64
|
+
`--schema ${path} cannot be checked: ${err instanceof Error ? err.message : String(err)}`
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function schemaFrom(path: string | undefined): Record<string, unknown> | undefined {
|
|
70
|
+
if (!path) return undefined;
|
|
71
|
+
const schema = readSchema(path);
|
|
72
|
+
assertCheckable(path, schema);
|
|
73
|
+
return schema;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function requestFrom(values: InferValues, stdin: string): Parameters<Infer>[0] {
|
|
77
|
+
return {
|
|
78
|
+
user: promptOf(stdin),
|
|
79
|
+
system: values.system ? readFileSync(values.system, "utf-8") : undefined,
|
|
80
|
+
jsonSchema: schemaFrom(values.schema),
|
|
81
|
+
tier: tierOf(values.tier),
|
|
82
|
+
timeout: timeoutMs(values.timeout),
|
|
83
|
+
caller: values.caller ?? "cli-infer",
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function failureReason(result: Awaited<ReturnType<Infer>>, wantedJson: boolean): string {
|
|
88
|
+
if (result.error) return result.error;
|
|
89
|
+
if (wantedJson && result.output) return "the reply was not JSON";
|
|
90
|
+
return "no inference route answered (run `pal cli doctor`)";
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export async function inferFromCli(
|
|
94
|
+
values: InferValues,
|
|
95
|
+
stdin: string,
|
|
96
|
+
infer: Infer = inference
|
|
97
|
+
): Promise<number> {
|
|
98
|
+
let request: Parameters<Infer>[0];
|
|
99
|
+
try {
|
|
100
|
+
request = requestFrom(values, stdin);
|
|
101
|
+
} catch (err) {
|
|
102
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
103
|
+
return 1;
|
|
104
|
+
}
|
|
105
|
+
const result = await infer(request);
|
|
106
|
+
if (!result.success || !result.output) {
|
|
107
|
+
console.error(
|
|
108
|
+
`Inference failed: ${failureReason(result, Boolean(request.jsonSchema))}`
|
|
109
|
+
);
|
|
110
|
+
return 1;
|
|
111
|
+
}
|
|
112
|
+
console.log(result.output);
|
|
113
|
+
return 0;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export const inferCommand = leaf({
|
|
117
|
+
summary: "Run one inference call on the active agent's model; prompt on stdin",
|
|
118
|
+
options: {
|
|
119
|
+
tier: {
|
|
120
|
+
type: "string",
|
|
121
|
+
value: "<tier>",
|
|
122
|
+
description: `Model size: ${INFERENCE_TIERS.join(" or ")} (default small)`,
|
|
123
|
+
},
|
|
124
|
+
system: { type: "string", value: "<file>", description: "System prompt file" },
|
|
125
|
+
schema: {
|
|
126
|
+
type: "string",
|
|
127
|
+
value: "<file>",
|
|
128
|
+
description: "JSON schema file; a reply that doesn't match it fails",
|
|
129
|
+
},
|
|
130
|
+
timeout: {
|
|
131
|
+
type: "string",
|
|
132
|
+
value: "<seconds>",
|
|
133
|
+
description: `Give up after this long (default ${DEFAULT_TIMEOUT_SECONDS})`,
|
|
134
|
+
},
|
|
135
|
+
caller: {
|
|
136
|
+
type: "string",
|
|
137
|
+
value: "<label>",
|
|
138
|
+
description: "Label for PAL's debug log",
|
|
139
|
+
},
|
|
140
|
+
},
|
|
141
|
+
run: async ({ values }) => inferFromCli(values, await Bun.stdin.text()),
|
|
142
|
+
});
|
package/src/cli/knowledge.ts
CHANGED
|
@@ -6,6 +6,13 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { readFileSync } from "node:fs";
|
|
9
|
+
import type { ReviewItem } from "../hooks/lib/entity-extraction";
|
|
10
|
+
import {
|
|
11
|
+
type AcceptChoice,
|
|
12
|
+
acceptReview,
|
|
13
|
+
pendingReviews,
|
|
14
|
+
rejectReview,
|
|
15
|
+
} from "../hooks/lib/entity-review";
|
|
9
16
|
import { toPath } from "../hooks/lib/paths";
|
|
10
17
|
import { buildGraph, resolveSlug, stats, traverse } from "../tools/knowledge/graph";
|
|
11
18
|
import {
|
|
@@ -113,6 +120,36 @@ export const knowledgeCommand = group({
|
|
|
113
120
|
},
|
|
114
121
|
run: ({ values }) => cmdIngest(values.source ?? "manual", values.file),
|
|
115
122
|
}),
|
|
123
|
+
review: group({
|
|
124
|
+
summary: "People and companies the background extractor would not guess",
|
|
125
|
+
fallback: "list",
|
|
126
|
+
commands: {
|
|
127
|
+
list: leaf({ summary: "Show what waits for an answer", run: cmdReviewList }),
|
|
128
|
+
accept: leaf({
|
|
129
|
+
summary: "Write an item, to an existing entry (--as) or a new one",
|
|
130
|
+
args: "<id>",
|
|
131
|
+
options: {
|
|
132
|
+
as: {
|
|
133
|
+
type: "string",
|
|
134
|
+
value: "<slug>",
|
|
135
|
+
description: "Existing entry to update",
|
|
136
|
+
},
|
|
137
|
+
name: {
|
|
138
|
+
type: "string",
|
|
139
|
+
value: "<name>",
|
|
140
|
+
description: "Name for a new entry (default: as extracted)",
|
|
141
|
+
},
|
|
142
|
+
},
|
|
143
|
+
run: ({ positionals, values }) =>
|
|
144
|
+
cmdReviewAccept(positionals[0], { as: values.as, name: values.name }),
|
|
145
|
+
}),
|
|
146
|
+
reject: leaf({
|
|
147
|
+
summary: "Drop an item without writing it",
|
|
148
|
+
args: "<id>",
|
|
149
|
+
run: ({ positionals }) => cmdReviewReject(positionals[0]),
|
|
150
|
+
}),
|
|
151
|
+
},
|
|
152
|
+
}),
|
|
116
153
|
},
|
|
117
154
|
details: VOCABULARY,
|
|
118
155
|
});
|
|
@@ -578,3 +615,63 @@ async function cmdIngest(sourceId: string, file: string | undefined): Promise<nu
|
|
|
578
615
|
console.log(JSON.stringify(summary, null, 2));
|
|
579
616
|
return 0;
|
|
580
617
|
}
|
|
618
|
+
|
|
619
|
+
// ── review ─────────────────────────────────────────────────────────
|
|
620
|
+
|
|
621
|
+
function reviewReason(reason: ReviewItem["reason"]): string {
|
|
622
|
+
const reasons: Record<ReviewItem["reason"], string> = {
|
|
623
|
+
ambiguous: "could be more than one known entry",
|
|
624
|
+
"unknown-existing": "named an entry the store does not have",
|
|
625
|
+
"first-name-only": "first name only",
|
|
626
|
+
};
|
|
627
|
+
return reasons[reason];
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
function reviewLines(item: ReviewItem): string[] {
|
|
631
|
+
const { kind, name, fact } = item.entity;
|
|
632
|
+
const candidates =
|
|
633
|
+
item.candidates.length > 0 ? `could be: ${item.candidates.join(", ")}` : "";
|
|
634
|
+
return [
|
|
635
|
+
` ${item.id} ${kind} "${name}" — ${reviewReason(item.reason)}`,
|
|
636
|
+
...[fact, candidates, `from ${item.source}`]
|
|
637
|
+
.filter(Boolean)
|
|
638
|
+
.map((line) => ` ${line}`),
|
|
639
|
+
];
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
function cmdReviewList(): number {
|
|
643
|
+
const items = pendingReviews();
|
|
644
|
+
if (items.length === 0) {
|
|
645
|
+
console.log("Nothing waits for review.");
|
|
646
|
+
return 0;
|
|
647
|
+
}
|
|
648
|
+
console.log(`\n${items.length} waiting for review\n`);
|
|
649
|
+
for (const item of items) console.log(reviewLines(item).join("\n"));
|
|
650
|
+
console.log(
|
|
651
|
+
"\nAccept: pal cli knowledge review accept <id> [--as <slug>] [--name <name>]\nReject: pal cli knowledge review reject <id>\n"
|
|
652
|
+
);
|
|
653
|
+
return 0;
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
function acceptOrExplain(id: string, choice: AcceptChoice) {
|
|
657
|
+
try {
|
|
658
|
+
return acceptReview(id, choice);
|
|
659
|
+
} catch (err) {
|
|
660
|
+
throw new UsageError(err instanceof Error ? err.message : String(err));
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
function cmdReviewAccept(id: string, choice: AcceptChoice): number {
|
|
665
|
+
const accepted = acceptOrExplain(id, choice);
|
|
666
|
+
if (!accepted) throw new UsageError(`no review item "${id}"`);
|
|
667
|
+
const verb = accepted.created ? "Created" : "Updated";
|
|
668
|
+
console.log(`${verb} ${accepted.slug} from "${accepted.item.entity.name}".`);
|
|
669
|
+
return 0;
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
function cmdReviewReject(id: string): number {
|
|
673
|
+
const rejected = rejectReview(id);
|
|
674
|
+
if (!rejected) throw new UsageError(`no review item "${id}"`);
|
|
675
|
+
console.log(`Dropped "${rejected.entity.name}".`);
|
|
676
|
+
return 0;
|
|
677
|
+
}
|
package/src/cli/tree.ts
CHANGED
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
} from "../tools/lib/command";
|
|
16
16
|
import { builtinTools, builtinToolVerbs } from "./builtin-tools";
|
|
17
17
|
import { identityCommand } from "./identity";
|
|
18
|
+
import { inferCommand } from "./infer";
|
|
18
19
|
import { knowledgeCommand } from "./knowledge";
|
|
19
20
|
import { ledgerCommand } from "./ledger-command";
|
|
20
21
|
import { runMigrate } from "./migrate";
|
|
@@ -150,6 +151,7 @@ export function cliTree(admin: AdminHandlers): Group {
|
|
|
150
151
|
commands: {
|
|
151
152
|
...adminCommands(admin),
|
|
152
153
|
usage: usageCommand,
|
|
154
|
+
infer: inferCommand,
|
|
153
155
|
actor: identityCommand("actor"),
|
|
154
156
|
machine: identityCommand("machine"),
|
|
155
157
|
telos: telosCommand,
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stop handler: learn the people and companies the user mentioned since the last
|
|
3
|
+
* stop, so the prompt-time cards know them next time. Runs detached; one model
|
|
4
|
+
* call at most, and only when the new text names someone.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { ingestEntities } from "../../tools/knowledge/ingest";
|
|
8
|
+
import { excludedNames, loadKnownEntities, loadNameIndex } from "../lib/entity-cards";
|
|
9
|
+
import {
|
|
10
|
+
extractionSchema,
|
|
11
|
+
extractionSystem,
|
|
12
|
+
extractionUser,
|
|
13
|
+
markSeen,
|
|
14
|
+
parseExtraction,
|
|
15
|
+
planExtraction,
|
|
16
|
+
queueForReview,
|
|
17
|
+
seenCount,
|
|
18
|
+
unseenUserText,
|
|
19
|
+
worthExtracting,
|
|
20
|
+
} from "../lib/entity-extraction";
|
|
21
|
+
import { canInfer, inference } from "../lib/inference";
|
|
22
|
+
import { logDebug, logError } from "../lib/log";
|
|
23
|
+
import { identity, isEnabled } from "../lib/settings";
|
|
24
|
+
import { logTokenUsage } from "../lib/token-usage";
|
|
25
|
+
import { parseMessages } from "../lib/transcript";
|
|
26
|
+
|
|
27
|
+
function sourceId(sessionId: string, count: number): string {
|
|
28
|
+
return `chat ${new Date().toISOString().slice(0, 10)} ${sessionId.slice(0, 8)} #${count}`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** @lintignore exercised directly by test/entity-extraction.test.ts */
|
|
32
|
+
export async function extractSessionEntities(
|
|
33
|
+
transcript: string,
|
|
34
|
+
sessionId?: string
|
|
35
|
+
): Promise<void> {
|
|
36
|
+
if (!sessionId || !isEnabled("entityExtraction")) return;
|
|
37
|
+
const seen = seenCount(sessionId);
|
|
38
|
+
const { text, count } = unseenUserText(parseMessages(transcript), seen);
|
|
39
|
+
if (count <= seen) return;
|
|
40
|
+
|
|
41
|
+
const index = loadNameIndex(loadKnownEntities());
|
|
42
|
+
const excluded = excludedNames();
|
|
43
|
+
markSeen(sessionId, count);
|
|
44
|
+
if (!worthExtracting(text, index, excluded) || !canInfer()) return;
|
|
45
|
+
|
|
46
|
+
const id = identity();
|
|
47
|
+
const result = await inference({
|
|
48
|
+
system: extractionSystem(id.principal.name, id.ai.name),
|
|
49
|
+
user: extractionUser(text, index),
|
|
50
|
+
tier: "medium",
|
|
51
|
+
maxTokens: 900,
|
|
52
|
+
timeout: 90000,
|
|
53
|
+
jsonSchema: extractionSchema(),
|
|
54
|
+
caller: "entity-extraction",
|
|
55
|
+
sessionId,
|
|
56
|
+
});
|
|
57
|
+
if (result.usage) logTokenUsage("entity-extraction", result.usage);
|
|
58
|
+
if (!result.success || !result.output) return;
|
|
59
|
+
|
|
60
|
+
const source = sourceId(sessionId, count);
|
|
61
|
+
const plan = planExtraction(parseExtraction(result.output), index, excluded, source);
|
|
62
|
+
const written = ingestEntities(plan.ingest, source);
|
|
63
|
+
queueForReview(plan.review);
|
|
64
|
+
logDebug(
|
|
65
|
+
"entity-extraction",
|
|
66
|
+
`${written.people.length} people, ${written.companies.length} companies, ${plan.review.length} for review`
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (process.argv[2] === "--run") {
|
|
71
|
+
const sid = process.argv[3];
|
|
72
|
+
const transcriptPath = process.argv[4];
|
|
73
|
+
if (transcriptPath) {
|
|
74
|
+
const { readFile, unlink } = await import("node:fs/promises");
|
|
75
|
+
try {
|
|
76
|
+
const transcript = await readFile(transcriptPath, "utf-8");
|
|
77
|
+
await extractSessionEntities(transcript, sid === "" ? undefined : sid);
|
|
78
|
+
} catch (err) {
|
|
79
|
+
logError("entity-extraction:run", err);
|
|
80
|
+
} finally {
|
|
81
|
+
await unlink(transcriptPath).catch(() => {});
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
process.exit(0);
|
|
85
|
+
}
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
import { promptRulesReminder } from "../lib/adaptation-steering";
|
|
11
11
|
import { promptContextResponse } from "../lib/agent";
|
|
12
12
|
import { dueNudgeReminder } from "../lib/daily-nudge";
|
|
13
|
+
import { getEntityReminder } from "../lib/entity-cards";
|
|
13
14
|
import { observeTurn } from "../lib/interaction";
|
|
14
15
|
import { logDebug, logError } from "../lib/log";
|
|
15
16
|
import { parkPromptContext } from "../lib/parked-context";
|
|
@@ -91,6 +92,9 @@ export async function getPromptContext(
|
|
|
91
92
|
withinBudget(() => dueNudgeReminder(), BUDGET_MS),
|
|
92
93
|
userText && getSteeringReminder(userText),
|
|
93
94
|
userText && getSkillReminder(userText),
|
|
95
|
+
userText &&
|
|
96
|
+
isEnabled("entityCards") &&
|
|
97
|
+
withinBudget(() => getEntityReminder(userText), BUDGET_MS),
|
|
94
98
|
userText && (await getRetrievalReminder(userText)),
|
|
95
99
|
].filter((p): p is string => Boolean(p));
|
|
96
100
|
|
|
@@ -3,9 +3,10 @@ import { resolve } from "node:path";
|
|
|
3
3
|
import { loadReflectNudge } from "../handlers/reflect-trigger";
|
|
4
4
|
import { loadAlgorithmReviewNudge } from "./algorithm-review";
|
|
5
5
|
import { loadAnalyzeNudge } from "./analyze-nudge";
|
|
6
|
+
import { loadEntityReviewNudge } from "./entity-review";
|
|
6
7
|
import { ensureDir, paths } from "./paths";
|
|
7
8
|
import { loadReactionAuditNudge } from "./reaction-audit";
|
|
8
|
-
import { isOptedIn } from "./settings";
|
|
9
|
+
import { isEnabled, isOptedIn } from "./settings";
|
|
9
10
|
import { localDay } from "./wall-clock";
|
|
10
11
|
|
|
11
12
|
type ShownOn = Record<string, string>;
|
|
@@ -14,6 +15,7 @@ interface DueNudge {
|
|
|
14
15
|
key: string;
|
|
15
16
|
command: string;
|
|
16
17
|
load: () => string;
|
|
18
|
+
enabled?: () => boolean;
|
|
17
19
|
}
|
|
18
20
|
|
|
19
21
|
const DUE_NUDGES: DueNudge[] = [
|
|
@@ -27,6 +29,27 @@ const DUE_NUDGES: DueNudge[] = [
|
|
|
27
29
|
{ key: "analyze", command: "/pal-analyze", load: loadAnalyzeNudge },
|
|
28
30
|
];
|
|
29
31
|
|
|
32
|
+
function entityReviewNudge(): DueNudge {
|
|
33
|
+
return {
|
|
34
|
+
key: "entity-review",
|
|
35
|
+
command: "pal cli knowledge review",
|
|
36
|
+
load: loadEntityReviewNudge,
|
|
37
|
+
enabled: () => isEnabled("entityExtraction"),
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function dueNudges(): DueNudge[] {
|
|
42
|
+
return [...DUE_NUDGES, entityReviewNudge()];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function dueRemindersOn(): boolean {
|
|
46
|
+
return isOptedIn("dueReminders");
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function isSwitchedOn(nudge: DueNudge): boolean {
|
|
50
|
+
return (nudge.enabled ?? dueRemindersOn)();
|
|
51
|
+
}
|
|
52
|
+
|
|
30
53
|
function shownPath(): string {
|
|
31
54
|
return resolve(ensureDir(paths.state()), "nudges-shown.json");
|
|
32
55
|
}
|
|
@@ -53,7 +76,7 @@ export function pendingToday(
|
|
|
53
76
|
}
|
|
54
77
|
|
|
55
78
|
export function acknowledgeMentioned(reply: string, now: Date = new Date()): void {
|
|
56
|
-
const mentioned =
|
|
79
|
+
const mentioned = dueNudges().filter((nudge) => reply.includes(nudge.command));
|
|
57
80
|
if (!mentioned.length) return;
|
|
58
81
|
const today = localDay(now);
|
|
59
82
|
const shownOn = readShownOn();
|
|
@@ -62,10 +85,10 @@ export function acknowledgeMentioned(reply: string, now: Date = new Date()): voi
|
|
|
62
85
|
}
|
|
63
86
|
|
|
64
87
|
export function dueNudgeSections(now: Date = new Date()): string[] {
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
88
|
+
return dueNudges()
|
|
89
|
+
.filter(isSwitchedOn)
|
|
90
|
+
.map((nudge) => pendingToday(nudge.key, nudge.load(), now))
|
|
91
|
+
.filter(Boolean);
|
|
69
92
|
}
|
|
70
93
|
|
|
71
94
|
export function dueNudgeReminder(now: Date = new Date()): string | null {
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Prompt-time cards for the people and companies a prompt names, so the user
|
|
3
|
+
* never has to re-explain who someone is. Reads files only; no model call.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { type Entity, list } from "../../tools/knowledge/lib";
|
|
7
|
+
import {
|
|
8
|
+
buildNameIndex,
|
|
9
|
+
entityKey,
|
|
10
|
+
findMentions,
|
|
11
|
+
type Mention,
|
|
12
|
+
type NamedEntity,
|
|
13
|
+
type NameIndex,
|
|
14
|
+
} from "./entity-names";
|
|
15
|
+
import { identity } from "./settings";
|
|
16
|
+
|
|
17
|
+
const MAX_CARDS = 3;
|
|
18
|
+
const MAX_LATEST = 160;
|
|
19
|
+
|
|
20
|
+
function stringList(value: unknown): string[] {
|
|
21
|
+
return Array.isArray(value)
|
|
22
|
+
? value.filter((v): v is string => typeof v === "string")
|
|
23
|
+
: [];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function textField(entity: Entity, key: string): string {
|
|
27
|
+
const value = entity.frontmatter[key];
|
|
28
|
+
return typeof value === "string" ? value.trim() : "";
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function namedEntity(entity: Entity): NamedEntity {
|
|
32
|
+
return {
|
|
33
|
+
slug: entity.slug,
|
|
34
|
+
domain: entity.domain === "Companies" ? "Companies" : "People",
|
|
35
|
+
title: entity.frontmatter.title,
|
|
36
|
+
aliases: stringList(entity.frontmatter.aliases),
|
|
37
|
+
domainName: textField(entity, "domain_name") || undefined,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function loadKnownEntities(rootDir?: string): Entity[] {
|
|
42
|
+
return [...list("People", rootDir), ...list("Companies", rootDir)];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function excludedNames(): string[] {
|
|
46
|
+
const id = identity();
|
|
47
|
+
return [id.principal.name, id.ai.name];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function loadNameIndex(entities: Entity[]): NameIndex {
|
|
51
|
+
return buildNameIndex(entities.map(namedEntity), excludedNames());
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function isSourceLine(line: string): boolean {
|
|
55
|
+
return (
|
|
56
|
+
line.startsWith("### ") ||
|
|
57
|
+
line.startsWith("<!--") ||
|
|
58
|
+
/^(?:role|importance|mentioned_as|sentiment): /.test(line)
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** The newest thing the store learned about this entity, in a sentence or two. */
|
|
63
|
+
export function latestNote(body: string): string {
|
|
64
|
+
const sections = body.split(/\n(?=### )/);
|
|
65
|
+
const text = (sections.at(-1) ?? "")
|
|
66
|
+
.split("\n")
|
|
67
|
+
.map((l) => l.trim())
|
|
68
|
+
.filter((l) => l && !isSourceLine(l))
|
|
69
|
+
.join(" ")
|
|
70
|
+
.replace(/\*\*/g, "");
|
|
71
|
+
return text.length > MAX_LATEST ? `${text.slice(0, MAX_LATEST - 1)}…` : text;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function describe(entity: Entity): string {
|
|
75
|
+
const fields =
|
|
76
|
+
entity.domain === "People"
|
|
77
|
+
? [
|
|
78
|
+
textField(entity, "position") || textField(entity, "role"),
|
|
79
|
+
textField(entity, "company"),
|
|
80
|
+
]
|
|
81
|
+
: [textField(entity, "industry")];
|
|
82
|
+
const facts = [...fields, textField(entity, "relation")].filter(
|
|
83
|
+
(f) => f && f !== "subject"
|
|
84
|
+
);
|
|
85
|
+
const latest = latestNote(entity.body);
|
|
86
|
+
return [facts.join(", "), latest].filter(Boolean).join(". ");
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function card(mention: Mention, bySlug: Map<string, Entity>): string {
|
|
90
|
+
if (mention.entities.length > 1) {
|
|
91
|
+
const names = mention.entities.map((e) => e.title).join(", ");
|
|
92
|
+
return `- "${mention.said}" could be: ${names}. Ask which one if it matters.`;
|
|
93
|
+
}
|
|
94
|
+
const entity = bySlug.get(entityKey(mention.entities[0]));
|
|
95
|
+
const summary = entity ? describe(entity) : "";
|
|
96
|
+
const name = `- **${mention.entities[0].title}**`;
|
|
97
|
+
return summary ? `${name}: ${summary}` : name;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function entityReminder(prompt: string, entities: Entity[]): string | null {
|
|
101
|
+
const mentions = findMentions(prompt, loadNameIndex(entities)).slice(0, MAX_CARDS);
|
|
102
|
+
if (mentions.length === 0) return null;
|
|
103
|
+
const bySlug = new Map(entities.map((e) => [entityKey(e), e]));
|
|
104
|
+
return [
|
|
105
|
+
"**Known people and companies in this message** (PAL knowledge store):",
|
|
106
|
+
...mentions.map((m) => card(m, bySlug)),
|
|
107
|
+
].join("\n");
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function getEntityReminder(prompt: string): string | null {
|
|
111
|
+
return entityReminder(prompt, loadKnownEntities());
|
|
112
|
+
}
|