opencarly 1.0.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.
Files changed (59) hide show
  1. package/README.md +78 -0
  2. package/bin/install.js +304 -0
  3. package/commands/carly-manager.md +69 -0
  4. package/dist/config/discovery.d.ts +22 -0
  5. package/dist/config/discovery.d.ts.map +1 -0
  6. package/dist/config/discovery.js +43 -0
  7. package/dist/config/discovery.js.map +1 -0
  8. package/dist/config/index.d.ts +7 -0
  9. package/dist/config/index.d.ts.map +1 -0
  10. package/dist/config/index.js +7 -0
  11. package/dist/config/index.js.map +1 -0
  12. package/dist/config/manifest.d.ts +39 -0
  13. package/dist/config/manifest.d.ts.map +1 -0
  14. package/dist/config/manifest.js +139 -0
  15. package/dist/config/manifest.js.map +1 -0
  16. package/dist/config/schema.d.ts +663 -0
  17. package/dist/config/schema.d.ts.map +1 -0
  18. package/dist/config/schema.js +208 -0
  19. package/dist/config/schema.js.map +1 -0
  20. package/dist/engine/brackets.d.ts +26 -0
  21. package/dist/engine/brackets.d.ts.map +1 -0
  22. package/dist/engine/brackets.js +49 -0
  23. package/dist/engine/brackets.js.map +1 -0
  24. package/dist/engine/index.d.ts +8 -0
  25. package/dist/engine/index.d.ts.map +1 -0
  26. package/dist/engine/index.js +8 -0
  27. package/dist/engine/index.js.map +1 -0
  28. package/dist/engine/loader.d.ts +82 -0
  29. package/dist/engine/loader.d.ts.map +1 -0
  30. package/dist/engine/loader.js +147 -0
  31. package/dist/engine/loader.js.map +1 -0
  32. package/dist/engine/matcher.d.ts +43 -0
  33. package/dist/engine/matcher.d.ts.map +1 -0
  34. package/dist/engine/matcher.js +174 -0
  35. package/dist/engine/matcher.js.map +1 -0
  36. package/dist/engine/trimmer.d.ts +91 -0
  37. package/dist/engine/trimmer.d.ts.map +1 -0
  38. package/dist/engine/trimmer.js +236 -0
  39. package/dist/engine/trimmer.js.map +1 -0
  40. package/dist/formatter/formatter.d.ts +23 -0
  41. package/dist/formatter/formatter.d.ts.map +1 -0
  42. package/dist/formatter/formatter.js +129 -0
  43. package/dist/formatter/formatter.js.map +1 -0
  44. package/dist/index.d.ts +15 -0
  45. package/dist/index.d.ts.map +1 -0
  46. package/dist/index.js +484 -0
  47. package/dist/index.js.map +1 -0
  48. package/dist/session/session.d.ts +60 -0
  49. package/dist/session/session.d.ts.map +1 -0
  50. package/dist/session/session.js +394 -0
  51. package/dist/session/session.js.map +1 -0
  52. package/package.json +59 -0
  53. package/templates/.opencarly/commands.json +96 -0
  54. package/templates/.opencarly/context.json +44 -0
  55. package/templates/.opencarly/domains/development.md +13 -0
  56. package/templates/.opencarly/domains/global.md +13 -0
  57. package/templates/.opencarly/domains/security.md +14 -0
  58. package/templates/.opencarly/domains/testing.md +12 -0
  59. package/templates/.opencarly/manifest.json +43 -0
@@ -0,0 +1,129 @@
1
+ /**
2
+ * OpenCarly Rule Formatter
3
+ *
4
+ * Formats loaded rules into a text block for injection into the system prompt.
5
+ * Output is wrapped in <carly-rules> XML tags.
6
+ */
7
+ // ---------------------------------------------------------------------------
8
+ // Helpers
9
+ // ---------------------------------------------------------------------------
10
+ function formatRuleList(rules, indent = " ") {
11
+ return rules.map((rule, i) => `${indent}${i + 1}. ${rule}`).join("\n");
12
+ }
13
+ function domainLabel(name) {
14
+ return name.toUpperCase();
15
+ }
16
+ // ---------------------------------------------------------------------------
17
+ // Main formatter
18
+ // ---------------------------------------------------------------------------
19
+ /**
20
+ * Format loaded rules into an injectable text block.
21
+ *
22
+ * Output sections (in order):
23
+ * 1. Context bracket status
24
+ * 2. Bracket-specific rules
25
+ * 3. Active star-commands (prominent)
26
+ * 4. DEVMODE instruction
27
+ * 5. Loaded domains summary
28
+ * 6. Always-on domain rules
29
+ * 7. Keyword-matched domain rules
30
+ * 8. Exclusion notices
31
+ * 9. Available (not loaded) domains
32
+ */
33
+ export function formatRules(loaded) {
34
+ const sections = [];
35
+ // 1. Context bracket status
36
+ if (loaded.contextEnabled) {
37
+ let bracketLine = `CONTEXT BRACKET: [${loaded.bracket}] (prompt ${loaded.promptCount})`;
38
+ if (loaded.bracket === "CRITICAL") {
39
+ bracketLine +=
40
+ "\nCONTEXT CRITICAL: Session is long. Recommend: compact session OR spawn fresh agent.";
41
+ }
42
+ sections.push(bracketLine);
43
+ }
44
+ // 2. Bracket-specific rules
45
+ if (loaded.contextEnabled && loaded.bracketRules.length > 0) {
46
+ sections.push(`[${loaded.bracket}] CONTEXT RULES:\n${formatRuleList(loaded.bracketRules)}`);
47
+ }
48
+ // 3. Active star-commands
49
+ const commandNames = Object.keys(loaded.commands);
50
+ if (commandNames.length > 0) {
51
+ const cmdSections = [];
52
+ cmdSections.push("--- ACTIVE COMMANDS ---");
53
+ for (const cmdName of commandNames) {
54
+ const rules = loaded.commands[cmdName];
55
+ cmdSections.push(`[*${cmdName}]:\n${formatRuleList(rules)}`);
56
+ }
57
+ cmdSections.push("--- END COMMANDS ---");
58
+ sections.push(cmdSections.join("\n"));
59
+ }
60
+ // 4. DEVMODE instruction
61
+ if (loaded.devmode) {
62
+ const statsInfo = loaded.injectionStats
63
+ ? `\nToken Efficiency: ${loaded.injectionStats.rulesThisPrompt} rules this prompt | avg ${loaded.injectionStats.avgRulesPerPrompt}/prompt over ${loaded.injectionStats.totalPromptsSession} prompts`
64
+ : "";
65
+ const savingsInfo = loaded.tokenSavings
66
+ ? `\nToken Savings: ~${loaded.tokenSavings.totalSaved.toLocaleString()} tokens saved this session (selection: ~${loaded.tokenSavings.skippedBySelection.toLocaleString()}, trimming: ~${loaded.tokenSavings.trimmedFromHistory.toLocaleString()})`
67
+ : "";
68
+ sections.push(`DEVMODE: on
69
+ You MUST append the following debug block to EVERY response:
70
+
71
+ CARLY DEVMODE
72
+ Domains Loaded: [list all loaded domains]
73
+ Rules Applied: [specific rule numbers from each domain]
74
+ Star-Commands: [any active star-commands]
75
+ Bracket: [current context bracket]
76
+ Matched Keywords: [keywords that triggered domains]${statsInfo}${savingsInfo}`);
77
+ }
78
+ else {
79
+ sections.push("DEVMODE: off\nDo NOT append any debug blocks to your responses. Respond normally.");
80
+ }
81
+ // 5. Loaded domains summary
82
+ const summaryLines = [];
83
+ for (const [name, rules] of Object.entries(loaded.alwaysOn)) {
84
+ summaryLines.push(` [${domainLabel(name)}] always_on (${rules.length} rules)`);
85
+ }
86
+ for (const [name, rules] of Object.entries(loaded.matched)) {
87
+ const paths = loaded.matchedPaths[name] || [];
88
+ if (paths.length > 0) {
89
+ const pathStr = paths.map((p) => `"${p}"`).join(", ");
90
+ summaryLines.push(` [${domainLabel(name)}] matched path: ${pathStr} (${rules.length} rules)`);
91
+ }
92
+ else {
93
+ const keywords = loaded.matchedKeywords[name] || [];
94
+ const kwStr = keywords.map((k) => `"${k}"`).join(", ");
95
+ summaryLines.push(` [${domainLabel(name)}] matched: ${kwStr} (${rules.length} rules)`);
96
+ }
97
+ }
98
+ if (summaryLines.length > 0) {
99
+ sections.push(`LOADED DOMAINS:\n${summaryLines.join("\n")}`);
100
+ }
101
+ // 6. Always-on domain rules
102
+ for (const [name, rules] of Object.entries(loaded.alwaysOn)) {
103
+ sections.push(`[${domainLabel(name)}] RULES:\n${formatRuleList(rules)}`);
104
+ }
105
+ // 7. Keyword-matched domain rules
106
+ for (const [name, rules] of Object.entries(loaded.matched)) {
107
+ sections.push(`[${domainLabel(name)}] RULES:\n${formatRuleList(rules)}`);
108
+ }
109
+ // 8. Exclusion notices
110
+ if (loaded.globalExcluded.length > 0) {
111
+ const kwStr = loaded.globalExcluded.map((k) => `"${k}"`).join(", ");
112
+ sections.push(`GLOBAL EXCLUSION ACTIVE: ${kwStr}\nAll domain matching was skipped for this prompt.`);
113
+ }
114
+ for (const [name, keywords] of Object.entries(loaded.excludedDomains)) {
115
+ const kwStr = keywords.map((k) => `"${k}"`).join(", ");
116
+ sections.push(`[${domainLabel(name)}] EXCLUDED by: ${kwStr}`);
117
+ }
118
+ // 9. Available (not loaded) domains
119
+ if (loaded.availableDomains.length > 0) {
120
+ const available = loaded.availableDomains
121
+ .map((d) => ` ${domainLabel(d.name)} (recall: ${d.recall.join(", ")})`)
122
+ .join("\n");
123
+ sections.push(`AVAILABLE (not loaded):\n${available}`);
124
+ }
125
+ // Wrap in XML tags
126
+ const body = sections.join("\n\n");
127
+ return `<carly-rules>\n${body}\n</carly-rules>`;
128
+ }
129
+ //# sourceMappingURL=formatter.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"formatter.js","sourceRoot":"","sources":["../../src/formatter/formatter.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAIH,8EAA8E;AAC9E,UAAU;AACV,8EAA8E;AAE9E,SAAS,cAAc,CAAC,KAAe,EAAE,SAAiB,IAAI;IAC5D,OAAO,KAAK,CAAC,GAAG,CAAC,CAAC,IAAI,EAAE,CAAC,EAAE,EAAE,CAAC,GAAG,MAAM,GAAG,CAAC,GAAG,CAAC,KAAK,IAAI,EAAE,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;AACzE,CAAC;AAED,SAAS,WAAW,CAAC,IAAY;IAC/B,OAAO,IAAI,CAAC,WAAW,EAAE,CAAC;AAC5B,CAAC;AAED,8EAA8E;AAC9E,iBAAiB;AACjB,8EAA8E;AAE9E;;;;;;;;;;;;;GAaG;AACH,MAAM,UAAU,WAAW,CAAC,MAAmB;IAC7C,MAAM,QAAQ,GAAa,EAAE,CAAC;IAE9B,4BAA4B;IAC5B,IAAI,MAAM,CAAC,cAAc,EAAE,CAAC;QAC1B,IAAI,WAAW,GAAG,qBAAqB,MAAM,CAAC,OAAO,aAAa,MAAM,CAAC,WAAW,GAAG,CAAC;QACxF,IAAI,MAAM,CAAC,OAAO,KAAK,UAAU,EAAE,CAAC;YAClC,WAAW;gBACT,uFAAuF,CAAC;QAC5F,CAAC;QACD,QAAQ,CAAC,IAAI,CAAC,WAAW,CAAC,CAAC;IAC7B,CAAC;IAED,4BAA4B;IAC5B,IAAI,MAAM,CAAC,cAAc,IAAI,MAAM,CAAC,YAAY,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC;QAC5D,QAAQ,CAAC,IAAI,CACX,IAAI,MAAM,CAAC,OAAO,qBAAqB,cAAc,CAAC,MAAM,CAAC,YAAY,CAAC,EAAE,CAC7E,CAAC;IACJ,CAAC;IAED,0BAA0B;IAC1B,MAAM,YAAY,GAAG,MAAM,CAAC,IAAI,CAAC,MAAM,CAAC,QAAQ,CAAC,CAAC;IAClD,IAAI,YAAY,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC;QAC5B,MAAM,WAAW,GAAa,EAAE,CAAC;QACjC,WAAW,CAAC,IAAI,CAAC,yBAAyB,CAAC,CAAC;QAC5C,KAAK,MAAM,OAAO,IAAI,YAAY,EAAE,CAAC;YACnC,MAAM,KAAK,GAAG,MAAM,CAAC,QAAQ,CAAC,OAAO,CAAC,CAAC;YACvC,WAAW,CAAC,IAAI,CACd,KAAK,OAAO,OAAO,cAAc,CAAC,KAAK,CAAC,EAAE,CAC3C,CAAC;QACJ,CAAC;QACD,WAAW,CAAC,IAAI,CAAC,sBAAsB,CAAC,CAAC;QACzC,QAAQ,CAAC,IAAI,CAAC,WAAW,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC,CAAC;IACxC,CAAC;IAED,yBAAyB;IACzB,IAAI,MAAM,CAAC,OAAO,EAAE,CAAC;QACnB,MAAM,SAAS,GAAG,MAAM,CAAC,cAAc;YACrC,CAAC,CAAC,uBAAuB,MAAM,CAAC,cAAc,CAAC,eAAe,4BAA4B,MAAM,CAAC,cAAc,CAAC,iBAAiB,gBAAgB,MAAM,CAAC,cAAc,CAAC,mBAAmB,UAAU;YACpM,CAAC,CAAC,EAAE,CAAC;QACP,MAAM,WAAW,GAAG,MAAM,CAAC,YAAY;YACrC,CAAC,CAAC,qBAAqB,MAAM,CAAC,YAAY,CAAC,UAAU,CAAC,cAAc,EAAE,2CAA2C,MAAM,CAAC,YAAY,CAAC,kBAAkB,CAAC,cAAc,EAAE,gBAAgB,MAAM,CAAC,YAAY,CAAC,kBAAkB,CAAC,cAAc,EAAE,GAAG;YAClP,CAAC,CAAC,EAAE,CAAC;QACP,QAAQ,CAAC,IAAI,CACX;;;;;;;;qDAQ+C,SAAS,GAAG,WAAW,EAAE,CACzE,CAAC;IACJ,CAAC;SAAM,CAAC;QACN,QAAQ,CAAC,IAAI,CACX,mFAAmF,CACpF,CAAC;IACJ,CAAC;IAED,4BAA4B;IAC5B,MAAM,YAAY,GAAa,EAAE,CAAC;IAElC,KAAK,MAAM,CAAC,IAAI,EAAE,KAAK,CAAC,IAAI,MAAM,CAAC,OAAO,CAAC,MAAM,CAAC,QAAQ,CAAC,EAAE,CAAC;QAC5D,YAAY,CAAC,IAAI,CAAC,MAAM,WAAW,CAAC,IAAI,CAAC,gBAAgB,KAAK,CAAC,MAAM,SAAS,CAAC,CAAC;IAClF,CAAC;IACD,KAAK,MAAM,CAAC,IAAI,EAAE,KAAK,CAAC,IAAI,MAAM,CAAC,OAAO,CAAC,MAAM,CAAC,OAAO,CAAC,EAAE,CAAC;QAC3D,MAAM,KAAK,GAAG,MAAM,CAAC,YAAY,CAAC,IAAI,CAAC,IAAI,EAAE,CAAC;QAC9C,IAAI,KAAK,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC;YACrB,MAAM,OAAO,GAAG,KAAK,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;YACtD,YAAY,CAAC,IAAI,CACf,MAAM,WAAW,CAAC,IAAI,CAAC,mBAAmB,OAAO,KAAK,KAAK,CAAC,MAAM,SAAS,CAC5E,CAAC;QACJ,CAAC;aAAM,CAAC;YACN,MAAM,QAAQ,GAAG,MAAM,CAAC,eAAe,CAAC,IAAI,CAAC,IAAI,EAAE,CAAC;YACpD,MAAM,KAAK,GAAG,QAAQ,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;YACvD,YAAY,CAAC,IAAI,CACf,MAAM,WAAW,CAAC,IAAI,CAAC,cAAc,KAAK,KAAK,KAAK,CAAC,MAAM,SAAS,CACrE,CAAC;QACJ,CAAC;IACH,CAAC;IAED,IAAI,YAAY,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC;QAC5B,QAAQ,CAAC,IAAI,CAAC,oBAAoB,YAAY,CAAC,IAAI,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC;IAC/D,CAAC;IAED,4BAA4B;IAC5B,KAAK,MAAM,CAAC,IAAI,EAAE,KAAK,CAAC,IAAI,MAAM,CAAC,OAAO,CAAC,MAAM,CAAC,QAAQ,CAAC,EAAE,CAAC;QAC5D,QAAQ,CAAC,IAAI,CACX,IAAI,WAAW,CAAC,IAAI,CAAC,aAAa,cAAc,CAAC,KAAK,CAAC,EAAE,CAC1D,CAAC;IACJ,CAAC;IAED,kCAAkC;IAClC,KAAK,MAAM,CAAC,IAAI,EAAE,KAAK,CAAC,IAAI,MAAM,CAAC,OAAO,CAAC,MAAM,CAAC,OAAO,CAAC,EAAE,CAAC;QAC3D,QAAQ,CAAC,IAAI,CACX,IAAI,WAAW,CAAC,IAAI,CAAC,aAAa,cAAc,CAAC,KAAK,CAAC,EAAE,CAC1D,CAAC;IACJ,CAAC;IAED,uBAAuB;IACvB,IAAI,MAAM,CAAC,cAAc,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC;QACrC,MAAM,KAAK,GAAG,MAAM,CAAC,cAAc,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;QACpE,QAAQ,CAAC,IAAI,CACX,4BAA4B,KAAK,oDAAoD,CACtF,CAAC;IACJ,CAAC;IAED,KAAK,MAAM,CAAC,IAAI,EAAE,QAAQ,CAAC,IAAI,MAAM,CAAC,OAAO,CAAC,MAAM,CAAC,eAAe,CAAC,EAAE,CAAC;QACtE,MAAM,KAAK,GAAG,QAAQ,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;QACvD,QAAQ,CAAC,IAAI,CACX,IAAI,WAAW,CAAC,IAAI,CAAC,kBAAkB,KAAK,EAAE,CAC/C,CAAC;IACJ,CAAC;IAED,oCAAoC;IACpC,IAAI,MAAM,CAAC,gBAAgB,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC;QACvC,MAAM,SAAS,GAAG,MAAM,CAAC,gBAAgB;aACtC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,KAAK,WAAW,CAAC,CAAC,CAAC,IAAI,CAAC,aAAa,CAAC,CAAC,MAAM,CAAC,IAAI,CAAC,IAAI,CAAC,GAAG,CAAC;aACvE,IAAI,CAAC,IAAI,CAAC,CAAC;QACd,QAAQ,CAAC,IAAI,CAAC,4BAA4B,SAAS,EAAE,CAAC,CAAC;IACzD,CAAC;IAED,mBAAmB;IACnB,MAAM,IAAI,GAAG,QAAQ,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC;IACnC,OAAO,kBAAkB,IAAI,kBAAkB,CAAC;AAClD,CAAC"}
@@ -0,0 +1,15 @@
1
+ /**
2
+ * OpenCarly - Context Augmentation & Reinforcement Layer for OpenCode
3
+ *
4
+ * Dynamic rules that load when relevant, disappear when not.
5
+ * Replicates CARL (https://github.com/ChristopherKahler/carl) for OpenCode.
6
+ *
7
+ * Hook flow per user message:
8
+ * 1. chat.message -> scan prompt for keywords + star-commands, update session
9
+ * 2. experimental.chat.system.transform -> load rules, format, inject into system prompt
10
+ * 3. experimental.chat.messages.transform -> smart trim stale tool outputs + carly-rules
11
+ */
12
+ import type { Plugin } from "@opencode-ai/plugin";
13
+ export declare const OpenCarly: Plugin;
14
+ export default OpenCarly;
15
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAEH,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,qBAAqB,CAAC;AAiOlD,eAAO,MAAM,SAAS,EAAE,MAgXvB,CAAC;AAGF,eAAe,SAAS,CAAC"}
package/dist/index.js ADDED
@@ -0,0 +1,484 @@
1
+ /**
2
+ * OpenCarly - Context Augmentation & Reinforcement Layer for OpenCode
3
+ *
4
+ * Dynamic rules that load when relevant, disappear when not.
5
+ * Replicates CARL (https://github.com/ChristopherKahler/carl) for OpenCode.
6
+ *
7
+ * Hook flow per user message:
8
+ * 1. chat.message -> scan prompt for keywords + star-commands, update session
9
+ * 2. experimental.chat.system.transform -> load rules, format, inject into system prompt
10
+ * 3. experimental.chat.messages.transform -> smart trim stale tool outputs + carly-rules
11
+ */
12
+ import { tool } from "@opencode-ai/plugin";
13
+ import { discoverConfig } from "./config/discovery";
14
+ import { loadConfig } from "./config/manifest";
15
+ import { matchDomains } from "./engine/matcher";
16
+ import { loadRules, calculateBaseline } from "./engine/loader";
17
+ import { getBracket } from "./engine/brackets";
18
+ import { trimMessageHistory } from "./engine/trimmer";
19
+ import { formatRules } from "./formatter/formatter";
20
+ import { getOrCreateSession, updateSessionActivity, saveSession, applySessionOverrides, cleanStaleSessions, loadCumulativeStats, updateCumulativeStats, clearAllStats, } from "./session/session";
21
+ function createLogger(client) {
22
+ return async (level, message, extra) => {
23
+ try {
24
+ await client.app.log({
25
+ body: {
26
+ service: "opencarly",
27
+ level,
28
+ message,
29
+ extra,
30
+ },
31
+ });
32
+ }
33
+ catch {
34
+ // Logging failure should never crash the plugin
35
+ }
36
+ };
37
+ }
38
+ // ---------------------------------------------------------------------------
39
+ // Helpers
40
+ // ---------------------------------------------------------------------------
41
+ /**
42
+ * Extract plain text from message parts.
43
+ */
44
+ function extractPromptText(parts) {
45
+ const textParts = [];
46
+ for (const part of parts) {
47
+ if (part.type === "text" && typeof part.text === "string") {
48
+ textParts.push(part.text);
49
+ }
50
+ }
51
+ return textParts.join("\n");
52
+ }
53
+ /**
54
+ * Count total rules across a record of domain -> rules[].
55
+ */
56
+ function countRules(domains) {
57
+ let count = 0;
58
+ for (const rules of Object.values(domains)) {
59
+ count += rules.length;
60
+ }
61
+ return count;
62
+ }
63
+ /**
64
+ * Estimate tokens from a rule array (~4 chars per token).
65
+ */
66
+ function estimateRuleTokens(rules) {
67
+ let chars = 0;
68
+ for (const ruleList of Object.values(rules)) {
69
+ for (const rule of ruleList) {
70
+ chars += rule.length;
71
+ }
72
+ }
73
+ return Math.ceil(chars / 4);
74
+ }
75
+ /**
76
+ * Estimate tokens from a string.
77
+ */
78
+ function estimateTokens(text) {
79
+ return Math.ceil(text.length / 4);
80
+ }
81
+ async function generateStatsReport(configPath, activeSessionId, activeModel) {
82
+ const stats = loadCumulativeStats(configPath);
83
+ let currentSessionSummary = activeSessionId
84
+ ? stats.sessions.find(s => s.sessionId === activeSessionId)
85
+ : stats.sessions[stats.sessions.length - 1];
86
+ let currentTokenStats = {
87
+ tokensSkippedBySelection: 0,
88
+ tokensTrimmedFromHistory: 0,
89
+ tokensTrimmedCarlyBlocks: 0,
90
+ tokensInjected: 0,
91
+ };
92
+ const targetSessionId = activeSessionId || currentSessionSummary?.sessionId;
93
+ if (targetSessionId) {
94
+ const path = await import("path");
95
+ const sessionPath = path.join(configPath, "sessions", `${targetSessionId}.json`);
96
+ try {
97
+ const fs = await import("fs");
98
+ const sessionData = JSON.parse(await fs.promises.readFile(sessionPath, "utf-8"));
99
+ currentTokenStats = {
100
+ tokensSkippedBySelection: sessionData.tokenStats?.tokensSkippedBySelection || 0,
101
+ tokensTrimmedFromHistory: sessionData.tokenStats?.tokensTrimmedFromHistory || 0,
102
+ tokensTrimmedCarlyBlocks: sessionData.tokenStats?.tokensTrimmedCarlyBlocks || 0,
103
+ tokensInjected: sessionData.tokenStats?.tokensInjected || 0,
104
+ };
105
+ if (!currentSessionSummary) {
106
+ currentSessionSummary = {
107
+ sessionId: targetSessionId,
108
+ date: sessionData.started || new Date().toISOString(),
109
+ tokensSaved: (currentTokenStats.tokensSkippedBySelection + currentTokenStats.tokensTrimmedFromHistory + currentTokenStats.tokensTrimmedCarlyBlocks),
110
+ promptsProcessed: sessionData.promptCount || 0,
111
+ tokensSkippedBySelection: currentTokenStats.tokensSkippedBySelection,
112
+ tokensTrimmedFromHistory: currentTokenStats.tokensTrimmedFromHistory,
113
+ tokensTrimmedCarlyBlocks: currentTokenStats.tokensTrimmedCarlyBlocks,
114
+ tokensInjected: currentTokenStats.tokensInjected,
115
+ rulesInjected: sessionData.tokenStats?.rulesInjected || 0,
116
+ };
117
+ }
118
+ }
119
+ catch {
120
+ // Ignore file not found or invalid JSON
121
+ }
122
+ }
123
+ const formatNumber = (n) => {
124
+ if (n >= 1000000)
125
+ return `${(n / 1000000).toFixed(1)}M`;
126
+ if (n >= 1000)
127
+ return `${(n / 1000).toFixed(1)}K`;
128
+ return n.toString();
129
+ };
130
+ const historyTrimmed = currentTokenStats.tokensTrimmedFromHistory + currentTokenStats.tokensTrimmedCarlyBlocks;
131
+ let output = `# OPENCARLY TOKEN SAVINGS REPORT\n\n`;
132
+ output += `## 📊 CURRENT SESSION\n`;
133
+ output += `**Prompts Processed**: ${currentSessionSummary?.promptsProcessed || 0}\n`;
134
+ output += `**Total Tokens Saved**: ${formatNumber(currentSessionSummary?.tokensSaved || 0)}\n\n`;
135
+ output += `### Savings Breakdown\n`;
136
+ output += `| Category | Tokens Saved |\n|---|---|\n`;
137
+ output += `| **Selective Rule Injection** | ${formatNumber(currentTokenStats.tokensSkippedBySelection)} |\n`;
138
+ output += `| **History Trimming (Total)** | ${formatNumber(historyTrimmed)} |\n`;
139
+ output += `| ↳ *Tool Output Trimming* | ${formatNumber(currentTokenStats.tokensTrimmedFromHistory)} |\n`;
140
+ output += `| ↳ *Stale Carly-Blocks* | ${formatNumber(currentTokenStats.tokensTrimmedCarlyBlocks)} |\n\n`;
141
+ output += `## 🕒 RECENT SESSION HISTORY\n`;
142
+ output += `| Date | Session ID | Prompts | Tokens Saved |\n|---|---|---|---|\n`;
143
+ for (const session of stats.sessions.slice().reverse().slice(0, 10)) {
144
+ const shortId = session.sessionId.replace("ses_", "").slice(0, 8) + "...";
145
+ output += `| ${session.date?.split("T")[0] || "Unknown"} | \`${shortId}\` | ${session.promptsProcessed} | ${formatNumber(session.tokensSaved || 0)} |\n`;
146
+ }
147
+ if (stats.sessions.length > 10) {
148
+ output += `| ... | ... | ... | ... |\n`;
149
+ }
150
+ output += `\n`;
151
+ const totalPrompts = stats.sessions.reduce((sum, s) => sum + (s.promptsProcessed || 0), 0);
152
+ output += `## 🌎 ALL-TIME TOTALS\n`;
153
+ output += `| Metric | Value |\n|--------|-------|\n`;
154
+ output += `| **Total Tokens Saved** | **${formatNumber(stats.cumulative.totalTokensSaved)}** |\n`;
155
+ output += `| - From Selective Injection | ${formatNumber(stats.cumulative.tokensSkippedBySelection)} |\n`;
156
+ output += `| - From History Trimming | ${formatNumber(stats.cumulative.tokensTrimmedFromHistory + stats.cumulative.tokensTrimmedCarlyBlocks)} |\n`;
157
+ output += `| Total Tokens Injected | ${formatNumber(stats.cumulative.tokensInjected)} |\n`;
158
+ output += `| Total Prompts Processed | ${totalPrompts} |\n`;
159
+ output += `| Total Sessions | ${stats.sessions.length} |\n`;
160
+ output += `| Avg Savings / Session | ${formatNumber(Math.round(stats.cumulative.totalTokensSaved / Math.max(1, stats.sessions.length)))} |\n\n`;
161
+ if (activeModel) {
162
+ output += `> **System Instruction for AI Assistant:**\n`;
163
+ output += `> To finalize this report, please execute a web search to find both the "standard input token pricing" and "context caching input token pricing" (if applicable) for your current model: \`${activeModel}\`.\n`;
164
+ output += `> Calculate a savings range based on the "Total Tokens Saved":\n`;
165
+ output += `> - Minimum Savings: Assume 100% of the saved tokens would have been cached.\n`;
166
+ output += `> - Maximum Savings: Assume 0% of the saved tokens would have been cached.\n`;
167
+ output += `> Append an "Estimated Dollar Savings Range: $X.XX - $Y.YY" section to your response, and add a brief 1-sentence note explaining that the lower bound accounts for context caching.\n\n`;
168
+ }
169
+ return output;
170
+ }
171
+ // Plugin entry point
172
+ // ---------------------------------------------------------------------------
173
+ export const OpenCarly = async ({ directory, client }) => {
174
+ const log = createLogger(client);
175
+ // Discover config
176
+ const discovery = discoverConfig(directory);
177
+ if (!discovery) {
178
+ await log("info", "No .opencarly/ config found - plugin inactive", {
179
+ searchedFrom: directory,
180
+ });
181
+ return {};
182
+ }
183
+ await log("info", `Config found at ${discovery.configPath} (${discovery.scope})`, {
184
+ configPath: discovery.configPath,
185
+ scope: discovery.scope,
186
+ });
187
+ // Load config
188
+ let config;
189
+ try {
190
+ config = loadConfig(discovery.configPath);
191
+ }
192
+ catch (err) {
193
+ const message = err instanceof Error ? err.message : String(err);
194
+ await log("error", `Config loading failed: ${message}`, {
195
+ configPath: discovery.configPath,
196
+ error: message,
197
+ });
198
+ return {};
199
+ }
200
+ // Log warnings
201
+ for (const warning of config.warnings) {
202
+ await log("warn", warning, { configPath: discovery.configPath });
203
+ }
204
+ // Calculate baseline (all rules loaded every prompt)
205
+ const baselineTokensPerPrompt = calculateBaseline(config);
206
+ // Log startup summary
207
+ const domainNames = Object.keys(config.manifest.domains);
208
+ const commandNames = Object.keys(config.commands);
209
+ await log("info", "OpenCarly initialized", {
210
+ domains: domainNames,
211
+ domainCount: domainNames.length,
212
+ commands: commandNames,
213
+ commandCount: commandNames.length,
214
+ devmode: config.manifest.devmode,
215
+ contextBrackets: config.manifest.context.state,
216
+ commandsSystem: config.manifest.commands.state,
217
+ baselineTokensPerPrompt,
218
+ });
219
+ // Initialize state
220
+ const cumulativeStats = loadCumulativeStats(discovery.configPath);
221
+ const state = {
222
+ config,
223
+ sessions: new Map(),
224
+ lastMatch: new Map(),
225
+ lastPrompt: new Map(),
226
+ activeSessionID: null,
227
+ activeModel: null,
228
+ baselineTokensPerPrompt,
229
+ cumulativeStats,
230
+ };
231
+ // Clean stale sessions on startup
232
+ try {
233
+ const cleaned = cleanStaleSessions(discovery.configPath);
234
+ if (cleaned > 0) {
235
+ await log("debug", `Cleaned ${cleaned} stale session(s)`);
236
+ }
237
+ }
238
+ catch {
239
+ // Non-critical
240
+ }
241
+ return {
242
+ // -----------------------------------------------------------------
243
+ // Event hook: track session lifecycle and intercept commands
244
+ // -----------------------------------------------------------------
245
+ event: async ({ event }) => {
246
+ if (event.type === "session.created") {
247
+ try {
248
+ cleanStaleSessions(discovery.configPath);
249
+ }
250
+ catch {
251
+ // ignore
252
+ }
253
+ }
254
+ },
255
+ // -----------------------------------------------------------------
256
+ // chat.message: scan prompt, detect keywords + star-commands
257
+ // -----------------------------------------------------------------
258
+ "chat.message": async (input, output) => {
259
+ const { sessionID, model } = input;
260
+ // Capture the active model ID for stats reporting
261
+ if (model?.modelID) {
262
+ state.activeModel = model.modelID;
263
+ }
264
+ const promptText = extractPromptText(output.parts);
265
+ if (!promptText)
266
+ return;
267
+ // Get or create session
268
+ const { session, isNew } = getOrCreateSession(discovery.configPath, sessionID, directory);
269
+ if (isNew || !state.sessions.has(sessionID)) {
270
+ state.sessions.set(sessionID, session);
271
+ }
272
+ const currentSession = state.sessions.get(sessionID);
273
+ // Set baseline on session if not set
274
+ if (currentSession.tokenStats.baselineTokensPerPrompt === 0) {
275
+ currentSession.tokenStats.baselineTokensPerPrompt = state.baselineTokensPerPrompt;
276
+ }
277
+ // Update session activity
278
+ updateSessionActivity(currentSession, promptText);
279
+ // Apply session overrides
280
+ const effectiveManifest = applySessionOverrides(state.config.manifest, currentSession);
281
+ // Run domain matcher
282
+ const matchResult = matchDomains(promptText, effectiveManifest);
283
+ // Cache for system.transform hook
284
+ state.lastMatch.set(sessionID, matchResult);
285
+ state.lastPrompt.set(sessionID, promptText);
286
+ state.activeSessionID = sessionID;
287
+ // Log match results
288
+ await log("debug", "Prompt matched", {
289
+ sessionID,
290
+ promptCount: currentSession.promptCount,
291
+ alwaysOn: matchResult.alwaysOn,
292
+ matched: Object.keys(matchResult.matched),
293
+ excluded: Object.keys(matchResult.excluded),
294
+ globalExcluded: matchResult.globalExcluded,
295
+ starCommands: matchResult.starCommands,
296
+ });
297
+ // Persist session
298
+ try {
299
+ saveSession(discovery.configPath, currentSession);
300
+ }
301
+ catch {
302
+ // Non-critical
303
+ }
304
+ },
305
+ // -----------------------------------------------------------------
306
+ // experimental.chat.system.transform: inject rules into system prompt
307
+ // -----------------------------------------------------------------
308
+ "experimental.chat.system.transform": async (input, output) => {
309
+ const sessionID = input.sessionID;
310
+ if (!sessionID)
311
+ return;
312
+ const matchResult = state.lastMatch.get(sessionID);
313
+ if (!matchResult)
314
+ return;
315
+ const session = state.sessions.get(sessionID);
316
+ const promptCount = session?.promptCount ?? 1;
317
+ const tokenStats = session?.tokenStats ?? {
318
+ tokensSkippedBySelection: 0,
319
+ tokensInjected: 0,
320
+ tokensTrimmedFromHistory: 0,
321
+ tokensTrimmedCarlyBlocks: 0,
322
+ promptsProcessed: 0,
323
+ rulesInjected: 0,
324
+ baselineTokensPerPrompt: state.baselineTokensPerPrompt,
325
+ };
326
+ // Apply session overrides
327
+ const effectiveManifest = session
328
+ ? applySessionOverrides(state.config.manifest, session)
329
+ : state.config.manifest;
330
+ // Determine context bracket
331
+ const bracket = getBracket(promptCount, state.config.context);
332
+ // Build effective config
333
+ const effectiveConfig = {
334
+ ...state.config,
335
+ manifest: effectiveManifest,
336
+ };
337
+ // Load rules
338
+ const loaded = loadRules(matchResult, effectiveConfig, bracket, promptCount);
339
+ // Override devmode from effective manifest
340
+ loaded.devmode = effectiveManifest.devmode;
341
+ loaded.contextEnabled = effectiveManifest.context.state === "active";
342
+ loaded.commandsEnabled = effectiveManifest.commands.state === "active";
343
+ // --- Token stats calculation ---
344
+ const totalRulesThisPrompt = countRules(loaded.alwaysOn) +
345
+ countRules(loaded.matched) +
346
+ countRules(loaded.commands) +
347
+ loaded.bracketRules.length;
348
+ const tokensInjectedThisPrompt = estimateRuleTokens(loaded.alwaysOn) +
349
+ estimateRuleTokens(loaded.matched) +
350
+ estimateRuleTokens(loaded.commands) +
351
+ estimateTokens(loaded.bracketRules.join(""));
352
+ const tokensSkippedThisPrompt = Math.max(0, state.baselineTokensPerPrompt - tokensInjectedThisPrompt);
353
+ // Accumulate stats
354
+ tokenStats.tokensInjected += tokensInjectedThisPrompt;
355
+ tokenStats.tokensSkippedBySelection += tokensSkippedThisPrompt;
356
+ tokenStats.promptsProcessed += 1;
357
+ tokenStats.rulesInjected = (tokenStats.rulesInjected || 0) + totalRulesThisPrompt;
358
+ // Update session
359
+ if (session) {
360
+ session.tokenStats = tokenStats;
361
+ try {
362
+ saveSession(discovery.configPath, session);
363
+ state.cumulativeStats = updateCumulativeStats(discovery.configPath, session);
364
+ }
365
+ catch {
366
+ // Non-critical
367
+ }
368
+ }
369
+ // Attach injection stats for DEVMODE display
370
+ loaded.injectionStats = {
371
+ rulesThisPrompt: totalRulesThisPrompt,
372
+ totalRulesSession: tokenStats.rulesInjected || totalRulesThisPrompt,
373
+ totalPromptsSession: tokenStats.promptsProcessed,
374
+ avgRulesPerPrompt: tokenStats.promptsProcessed > 0
375
+ ? Math.round((tokenStats.rulesInjected || 0) / tokenStats.promptsProcessed)
376
+ : totalRulesThisPrompt,
377
+ };
378
+ // Format and inject
379
+ const formatted = formatRules(loaded);
380
+ output.system.push(formatted);
381
+ // Persist session with updated stats
382
+ if (session) {
383
+ try {
384
+ saveSession(discovery.configPath, session);
385
+ }
386
+ catch {
387
+ // Non-critical
388
+ }
389
+ }
390
+ // Cleanup cached match
391
+ state.lastMatch.delete(sessionID);
392
+ state.lastPrompt.delete(sessionID);
393
+ },
394
+ // -----------------------------------------------------------------
395
+ // experimental.chat.messages.transform: smart tool output trimming
396
+ // -----------------------------------------------------------------
397
+ "experimental.chat.messages.transform": async (_input, output) => {
398
+ const trimConfig = state.config.context.trimming;
399
+ const trimStats = trimMessageHistory(output.messages, trimConfig);
400
+ // Accumulate trim stats to the session
401
+ if (trimStats.tokensSaved > 0 || trimStats.carlyBlocksStripped > 0) {
402
+ const sessionID = state.activeSessionID;
403
+ const session = sessionID ? state.sessions.get(sessionID) : undefined;
404
+ if (session) {
405
+ session.tokenStats.tokensTrimmedFromHistory += trimStats.tokensSaved;
406
+ session.tokenStats.tokensTrimmedCarlyBlocks += trimStats.carlyTokensSaved;
407
+ try {
408
+ saveSession(discovery.configPath, session);
409
+ }
410
+ catch {
411
+ // Non-critical
412
+ }
413
+ }
414
+ await log("debug", "History trimmed", {
415
+ partsTrimmed: trimStats.partsTrimmed,
416
+ tokensSaved: trimStats.tokensSaved,
417
+ carlyBlocksStripped: trimStats.carlyBlocksStripped,
418
+ mode: trimConfig.mode,
419
+ });
420
+ }
421
+ // Always update cumulative stats after messages transform completes
422
+ const sessionID = state.activeSessionID;
423
+ const session = sessionID ? state.sessions.get(sessionID) : undefined;
424
+ if (session) {
425
+ try {
426
+ state.cumulativeStats = updateCumulativeStats(discovery.configPath, session);
427
+ }
428
+ catch {
429
+ // Non-critical
430
+ }
431
+ }
432
+ },
433
+ // -----------------------------------------------------------------
434
+ // Compaction hook: preserve CARLY context across compaction
435
+ // -----------------------------------------------------------------
436
+ "experimental.session.compacting": async (_input, output) => {
437
+ output.context.push("OpenCarly (dynamic rule injection) is active. " +
438
+ "Rules are injected per-prompt based on keyword matching. " +
439
+ "Preserve awareness that <carly-rules> blocks contain mandatory instructions.");
440
+ },
441
+ tool: {
442
+ stats: tool({
443
+ description: "Get OpenCarly token savings statistics",
444
+ args: {},
445
+ execute: async (_args, _context) => {
446
+ return generateStatsReport(discovery.configPath, state.activeSessionID || undefined, state.activeModel);
447
+ },
448
+ }),
449
+ clear_stats: tool({
450
+ description: "Clear all OpenCarly token savings statistics",
451
+ args: {},
452
+ execute: async (_args, _context) => {
453
+ clearAllStats(discovery.configPath);
454
+ state.cumulativeStats = {
455
+ version: 1,
456
+ cumulative: {
457
+ tokensSkippedBySelection: 0,
458
+ tokensInjected: 0,
459
+ tokensTrimmedFromHistory: 0,
460
+ tokensTrimmedCarlyBlocks: 0,
461
+ totalTokensSaved: 0,
462
+ },
463
+ sessions: [],
464
+ };
465
+ for (const session of state.sessions.values()) {
466
+ session.tokenStats = {
467
+ tokensSkippedBySelection: 0,
468
+ tokensInjected: 0,
469
+ tokensTrimmedFromHistory: 0,
470
+ tokensTrimmedCarlyBlocks: 0,
471
+ promptsProcessed: 0,
472
+ rulesInjected: 0,
473
+ baselineTokensPerPrompt: state.baselineTokensPerPrompt,
474
+ };
475
+ }
476
+ return "All OpenCarly token savings statistics have been successfully reset to zero.";
477
+ },
478
+ }),
479
+ },
480
+ };
481
+ };
482
+ // Default export for single-export plugin files
483
+ export default OpenCarly;
484
+ //# sourceMappingURL=index.js.map