dsh-tacit 0.2.2 → 0.2.3

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/lib/analyze.js CHANGED
@@ -40,6 +40,25 @@ export function clipSafe(value, max) {
40
40
  return text.slice(0, end)
41
41
  }
42
42
 
43
+ /**
44
+ * Clip a directive to `max` characters without ever cutting mid-word: prefer
45
+ * the last sentence end at or after `min`, else the last space, and mark a
46
+ * mid-sentence cut with an ellipsis. Text within the limit is returned as is.
47
+ */
48
+ export function clipDirective(value, max = DIRECTIVE_MAX_CHARS, min = 60) {
49
+ const text = (typeof value === 'string' ? value : '').trim()
50
+ if (text.length <= max) return text
51
+ const head = clipSafe(text, max)
52
+ const sentenceEnd = Math.max(...['. ', '! ', '? ', '; ', '。', '!', '?', ';'].map((mark) => {
53
+ const at = head.lastIndexOf(mark)
54
+ return at >= min ? at + mark.trim().length : -1
55
+ }))
56
+ if (sentenceEnd > 0) return head.slice(0, sentenceEnd).trim()
57
+ const space = head.lastIndexOf(' ')
58
+ const cut = space >= min ? head.slice(0, space) : clipSafe(head, max - 1)
59
+ return cut.replace(/[\s,;:–—-]+$/u, '') + '…'
60
+ }
61
+
43
62
  // ── Structured output: the model answers by CALLING one tool whose arguments
44
63
  // are the payload (the harness has no JSON mode; tool arguments are the
45
64
  // reliable structured channel). Text JSON is still accepted as a fallback.
@@ -88,11 +107,31 @@ export const DIRECTIVE_TOOL = {
88
107
  parameters: {
89
108
  type: 'object',
90
109
  properties: {
91
- directives: { type: 'array', items: { type: 'string' }, minItems: 1, maxItems: 4 },
110
+ directives: {
111
+ type: 'array',
112
+ minItems: 1,
113
+ maxItems: 4,
114
+ items: {
115
+ type: 'object',
116
+ properties: {
117
+ text: { type: 'string', maxLength: 220, description: 'one sentence, at most 25 words' },
118
+ workspace: { type: 'string', description: 'only when the habit shows up in exactly one workspace: that workspace name as written in the evidence' },
119
+ },
120
+ required: ['text'],
121
+ },
122
+ },
92
123
  },
93
124
  required: ['directives'],
94
125
  },
95
126
  }
127
+ export const MAX_WORKSPACE_DIRECTIVES = 4
128
+
129
+ /** The last path segment of a workspace directory — what a person calls the project. */
130
+ export function workspaceLabel(cwd) {
131
+ if (typeof cwd !== 'string' || cwd.length === 0) return ''
132
+ const parts = cwd.split(/[\\/]+/).filter((part) => part.length > 0)
133
+ return parts.length > 0 ? parts[parts.length - 1] : ''
134
+ }
96
135
  export const DIRECTIVE_MAX_TOKENS = 1500
97
136
  export const DIRECTIVE_TIMEOUT_MS = 30000
98
137
  export const MAX_DIRECTIVES = 8
@@ -137,7 +176,7 @@ export function buildEnrichUserText({ draft, profile, recentContext }) {
137
176
  .filter((entry) => entry !== null && typeof entry === 'object' && entry.enabled !== false && typeof entry.text === 'string')
138
177
  if (directives.length > 0) {
139
178
  lines.push('=== WHAT THE COACH KNOWS ABOUT THIS USER ===')
140
- for (const entry of directives.slice(0, MAX_DIRECTIVES)) lines.push('- ' + clipSafe(entry.text, DIRECTIVE_MAX_CHARS))
179
+ for (const entry of directives.slice(0, MAX_DIRECTIVES)) lines.push('- ' + clipDirective(entry.text))
141
180
  lines.push('')
142
181
  }
143
182
  const patterns = Array.isArray(profile?.patterns) ? profile.patterns.slice(0, 6) : []
@@ -226,6 +265,47 @@ const improveShape = z.object({
226
265
  rationale: z.string().default(''),
227
266
  })
228
267
 
268
+ /**
269
+ * Learning from a recovery: the turn went cleanly right after a messy one in
270
+ * the same conversation. One small call names what the user supplied this
271
+ * time, in the same problem taxonomy, so the distiller knows what this user
272
+ * CAN state when reminded.
273
+ */
274
+ export const GOOD_TOOL = {
275
+ name: 'report',
276
+ description: 'What the clean prompt supplied that the previous, messy one lacked.',
277
+ parameters: {
278
+ type: 'object',
279
+ properties: {
280
+ strengths: {
281
+ type: 'array',
282
+ maxItems: 4,
283
+ items: {
284
+ type: 'object',
285
+ properties: {
286
+ kind: { type: 'string', description: 'same categories as problems: missing-constraints|ambiguous-goal|missing-context|wrong-scope|...' },
287
+ what: { type: 'string', description: 'one sentence: what the prompt included this time' },
288
+ },
289
+ required: ['kind', 'what'],
290
+ },
291
+ },
292
+ lesson: { type: 'string', description: 'one sentence, about the user: what they included the second time that made the difference' },
293
+ },
294
+ required: ['strengths', 'lesson'],
295
+ },
296
+ }
297
+ export const GOOD_SYSTEM_PROMPT = [
298
+ 'You coach prompt writing inside DeepSeek Harness. The previous turn of this',
299
+ 'conversation went badly (retries, tool errors, a correction).',
300
+ 'This turn went well. Compare the two prompts and the trajectory: what did the user',
301
+ 'INCLUDE this time that the earlier prompt lacked — a file path, a constraint,',
302
+ 'a scope, an acceptance criterion, an example? Report 1-4 strengths in the same',
303
+ 'categories used for problems, and ONE sentence about the user ("They fix',
304
+ 'wandering by naming the target file up front."). If the clean turn was',
305
+ 'trivially easy or merely a continuation, return an empty strengths list and',
306
+ 'an empty lesson. Never praise; state facts. Reply in the language of the prompt.',
307
+ ].join('\n')
308
+
229
309
  export const ANALYSIS_SYSTEM_PROMPT = [
230
310
  'You are a strict but friendly prompt-engineering coach inside DeepSeek Harness.',
231
311
  'You are given ONE past user prompt plus a digest of everything that happened',
@@ -354,13 +434,23 @@ export const DIRECTIVE_SYSTEM_PROMPT = [
354
434
  '"continue" — that is fine; the conversation is the context).',
355
435
  'Directives must GENERALIZE across future tasks: never mention a specific',
356
436
  'task, file, feature, number, or test from one past prompt — describe the',
357
- 'habit and the compensation. Prefer the most frequent habits. 2-4 directives,',
358
- 'one sentence each, imperative mood, addressed to the agent. You are writing',
437
+ 'habit and the compensation. One exception: when every piece of evidence for',
438
+ 'a habit carries the same [workspace: name] tag, return that directive with',
439
+ '"workspace" set to exactly that name; it may then refer to that project\'s',
440
+ 'layout ("check apps/web first"). Leave "workspace" out for everything else.',
441
+ 'Prefer the most frequent habits. 2-4 directives,',
442
+ 'ONE sentence each of at most 25 words (under 180 characters), imperative',
443
+ 'mood, addressed to the agent. A directive that needs a second sentence is two',
444
+ 'directives or too specific. You are writing',
359
445
  'the COMPLETE new set: keep existing directives that still hold (reworded if',
360
446
  'sharper), drop ones that were one-off, add what is missing. No preamble.',
361
447
  ].join('\n')
362
448
 
363
- export function buildDirectiveUserText(profile, recentReports = []) {
449
+ export function buildDirectiveUserText(profile, recentReports = [], { labelOf = workspaceLabel } = {}) {
450
+ const tagOf = (cwd) => {
451
+ const label = typeof cwd === 'string' && cwd.length > 0 ? labelOf(cwd) : ''
452
+ return label.length > 0 ? '[workspace: ' + label + '] ' : ''
453
+ }
364
454
  const lines = ['=== RECURRING PROMPT HABITS (kind, times seen, latest example) ===']
365
455
  const patterns = Array.isArray(profile?.patterns) ? profile.patterns.slice(0, 12) : []
366
456
  if (patterns.length === 0) lines.push('(none yet)')
@@ -373,9 +463,27 @@ export function buildDirectiveUserText(profile, recentReports = []) {
373
463
  if (corrections.length > 0) {
374
464
  lines.push('', '=== RECENT CORRECTIONS (prompt → what the user said next) ===')
375
465
  for (const report of corrections) {
376
- lines.push('- "' + clipSafe(String(report.promptExcerpt ?? ''), 120) + '" → "' + clipSafe(report.followUp, 200) + '"')
466
+ lines.push('- ' + tagOf(report.cwd) + '"' + clipSafe(String(report.promptExcerpt ?? ''), 120) + '" → "' + clipSafe(report.followUp, 200) + '"')
377
467
  }
378
468
  }
469
+ const lessons = (Array.isArray(recentReports) ? recentReports : [])
470
+ .filter((report) => typeof report?.lesson === 'string' && report.lesson.length > 0)
471
+ .slice(-5)
472
+ if (lessons.length > 0) {
473
+ lines.push('', '=== WHAT WORKED (a clean prompt right after a messy turn — what the user included this time) ===')
474
+ for (const report of lessons) {
475
+ lines.push('- ' + tagOf(report.cwd) + '"' + clipSafe(String(report.promptExcerpt ?? ''), 120) + '": ' + clipSafe(report.lesson, 300))
476
+ }
477
+ }
478
+ const byWorkspace = new Map()
479
+ for (const report of Array.isArray(recentReports) ? recentReports : []) {
480
+ const label = typeof report?.cwd === 'string' && report.cwd.length > 0 ? labelOf(report.cwd) : ''
481
+ if (label.length > 0) byWorkspace.set(label, (byWorkspace.get(label) ?? 0) + 1)
482
+ }
483
+ if (byWorkspace.size > 0) {
484
+ lines.push('', '=== WORKSPACES IN THE RECENT ANALYSES (name, analyses) ===')
485
+ for (const [label, count] of byWorkspace) lines.push('- ' + label + ' (' + count + ')')
486
+ }
379
487
  const rules = Array.isArray(profile?.styleRules) ? profile.styleRules.filter((rule) => typeof rule?.rule === 'string' && rule.rule.length > 0) : []
380
488
  if (rules.length > 0) {
381
489
  lines.push('', '=== STYLE RULES THE USER CONFIRMED ===')
@@ -384,9 +492,9 @@ export function buildDirectiveUserText(profile, recentReports = []) {
384
492
  const existing = Array.isArray(profile?.directives) ? profile.directives.filter((entry) => typeof entry?.text === 'string' && entry.text.length > 0) : []
385
493
  if (existing.length > 0) {
386
494
  lines.push('', '=== CURRENT DIRECTIVES (keep the ones that still hold) ===')
387
- for (const entry of existing) lines.push('- ' + clipSafe(entry.text, DIRECTIVE_MAX_CHARS))
495
+ for (const entry of existing) lines.push('- ' + clipDirective(entry.text) + (typeof entry.workspace === 'string' && entry.workspace.length > 0 ? ' [workspace: ' + labelOf(entry.workspace) + ']' : ''))
388
496
  }
389
- lines.push('', 'Write 2-4 directives for the agent about this user.')
497
+ lines.push('', 'Write 2-4 directives for the agent about this user. A habit the user has', 'shown they can fix themselves is still worth a directive: the agent should', 'compensate for it when it is missing, not ask.')
390
498
  return lines.join('\n')
391
499
  }
392
500
 
@@ -405,30 +513,42 @@ export function classifyDirectives(text) {
405
513
  const kept = []
406
514
  const rejected = []
407
515
  for (const item of raw) {
408
- if (typeof item !== 'string') continue
409
- const value = clipSafe(item.trim(), DIRECTIVE_MAX_CHARS)
410
- const key = value.toLowerCase()
516
+ // Both shapes are accepted: a bare sentence, or { text, workspace? }.
517
+ const source = typeof item === 'string' ? item : (item !== null && typeof item === 'object' && typeof item.text === 'string' ? item.text : null)
518
+ if (source === null) continue
519
+ const value = clipDirective(source)
520
+ const workspace = typeof item === 'object' && item !== null && typeof item.workspace === 'string' && item.workspace.trim().length > 0
521
+ ? clipSafe(item.workspace.trim(), 200)
522
+ : undefined
523
+ const key = (workspace ?? '') + '\n' + value.toLowerCase()
411
524
  if (value.length === 0 || seen.has(key)) continue
412
525
  seen.add(key)
413
526
  if (ASKS_USER_RE.test(value)) {
414
527
  rejected.push(value)
415
528
  continue
416
529
  }
417
- kept.push(value)
530
+ kept.push(workspace === undefined ? { text: value } : { text: value, workspace })
418
531
  if (kept.length >= 4) break
419
532
  }
420
533
  return { kept, rejected }
421
534
  }
422
535
 
423
536
  /**
424
- * The system-prompt section: what the agent is told about this user.
425
- * '' when nothing is enabled (an empty section contributes nothing).
537
+ * The system-prompt section: what the agent is told about this user, plus the
538
+ * ids of the directives that actually made it into the text (the directive
539
+ * cap and the character budget can drop enabled ones). `text` is '' when
540
+ * nothing is enabled (an empty section contributes nothing).
426
541
  */
427
- export function renderSteeringSection(profile) {
428
- const enabled = (Array.isArray(profile?.directives) ? profile.directives : [])
542
+ export function buildSteeringSection(profile, { cwd } = {}) {
543
+ const here = typeof cwd === 'string' && cwd.length > 0 ? cwd : null
544
+ const scopeOf = (entry) => (typeof entry.workspace === 'string' && entry.workspace.length > 0 ? entry.workspace : null)
545
+ const candidates = (Array.isArray(profile?.directives) ? profile.directives : [])
429
546
  .filter((entry) => entry !== null && typeof entry === 'object' && entry.enabled !== false && entry.status !== 'retired'
430
547
  && typeof entry.text === 'string' && entry.text.trim().length > 0)
431
- if (enabled.length === 0) return ''
548
+ .filter((entry) => scopeOf(entry) === null || scopeOf(entry) === here)
549
+ // This workspace's own directives first: they are the more specific ones.
550
+ const enabled = [...candidates.filter((entry) => scopeOf(entry) !== null), ...candidates.filter((entry) => scopeOf(entry) === null)]
551
+ if (enabled.length === 0) return { text: '', ids: [] }
432
552
  const header = [
433
553
  '## About this user (learned by Tacit from their past prompts)',
434
554
  'This user tends to leave the following unsaid. Compensate silently when the',
@@ -436,14 +556,21 @@ export function renderSteeringSection(profile) {
436
556
  'the prompt always win over these notes.',
437
557
  ]
438
558
  const lines = [...header]
559
+ const ids = []
439
560
  let length = lines.join('\n').length
440
561
  for (const entry of enabled.slice(0, MAX_DIRECTIVES)) {
441
- const line = '- ' + clipSafe(entry.text.trim(), DIRECTIVE_MAX_CHARS)
562
+ const line = '- ' + clipDirective(entry.text)
442
563
  if (length + line.length + 1 > STEERING_MAX_CHARS) break
443
564
  lines.push(line)
565
+ if (typeof entry.id === 'string') ids.push(entry.id)
444
566
  length += line.length + 1
445
567
  }
446
- return lines.length > header.length ? lines.join('\n') : ''
568
+ return lines.length > header.length ? { text: lines.join('\n'), ids } : { text: '', ids: [] }
569
+ }
570
+
571
+ /** The steering section text alone. */
572
+ export function renderSteeringSection(profile, options = {}) {
573
+ return buildSteeringSection(profile, options).text
447
574
  }
448
575
 
449
576
  /** Build the distillation user text from verbatim rejected-improvement reasons. */
@@ -653,6 +780,27 @@ const clipText = (value, max) => {
653
780
  }
654
781
 
655
782
  /** Shape a parsed analysis object into a report (falls back gracefully). */
783
+ /** Shape a good-prompt call into a report: no problems, the original prompt kept, the lesson as explanation. */
784
+ export function normalizeGoodReport(parsed, { turn, time, model, prompt }) {
785
+ const raw = parsed !== null && typeof parsed === 'object' ? parsed : {}
786
+ const strengths = (Array.isArray(raw.strengths) ? raw.strengths : [])
787
+ .filter((item) => item !== null && typeof item === 'object' && typeof item.what === 'string' && item.what.trim().length > 0)
788
+ .slice(0, 4)
789
+ .map((item) => ({ kind: clipText(typeof item.kind === 'string' ? item.kind : 'general', 60) || 'general', what: clipText(item.what, 600) }))
790
+ const lesson = typeof raw.lesson === 'string' ? clipText(raw.lesson.trim(), 300) : ''
791
+ return reportSchema.parse({
792
+ ok: true,
793
+ turn,
794
+ time,
795
+ model,
796
+ problems: [],
797
+ improvedPrompt: typeof prompt === 'string' ? clipText(prompt, 4000) : '',
798
+ explanation: lesson,
799
+ strengths,
800
+ lesson,
801
+ })
802
+ }
803
+
656
804
  export function normalizeReport(parsed, { turn, time, model, rawText }) {
657
805
  if (parsed === null) {
658
806
  return {
@@ -729,9 +877,16 @@ export function aggregateProfile(prev, report, maxPatterns, options = {}) {
729
877
  rejected: counterOf(pattern, 'rejected'),
730
878
  verified: counterOf(pattern, 'verified'),
731
879
  unverified: counterOf(pattern, 'unverified'),
880
+ resolved: counterOf(pattern, 'resolved'),
732
881
  })
733
882
  }
734
883
  }
884
+ // A good-prompt report says which habits the user overcame on their own this time.
885
+ for (const strength of Array.isArray(report?.strengths) ? report.strengths : []) {
886
+ if (strength === null || typeof strength !== 'object') continue
887
+ const current = patterns.get(normalizeKind(strength.kind))
888
+ if (current !== undefined) current.resolved += 1
889
+ }
735
890
  const problems = Array.isArray(report?.problems) ? report.problems : []
736
891
  for (const problem of problems) {
737
892
  if (problem === null || typeof problem !== 'object') continue
@@ -745,6 +900,7 @@ export function aggregateProfile(prev, report, maxPatterns, options = {}) {
745
900
  rejected: 0,
746
901
  verified: 0,
747
902
  unverified: 0,
903
+ resolved: 0,
748
904
  }
749
905
  current.count += 1
750
906
  if (typeof problem.what === 'string' && problem.what.length > 0) current.lastExample = problem.what.slice(0, 200)
package/lib/index.js CHANGED
@@ -80,6 +80,19 @@ export function apply(ctx, config) {
80
80
  if (typeof off === 'function') ctx.effect(() => off, 'tacit: pre-step enrichment')
81
81
  }
82
82
 
83
+ // One line so an audit can tell from the logs alone that Tacit is loaded
84
+ // and what it is currently injecting.
85
+ try {
86
+ const cfg = effectiveConfig()
87
+ const directives = store.profile().directives
88
+ const count = (status) => directives.filter((entry) => entry.status === status).length
89
+ console.info('[tacit] loaded — directives: ' + count('active') + ' active, ' + count('candidate') + ' candidates, '
90
+ + count('retired') + ' retired; steering ' + (cfg.steerAgent ? 'on' : 'off') + '; auto-analysis '
91
+ + (cfg.autoAnalyze ? 'on (cap ' + cfg.autoDailyBudget + '/day)' : 'off'))
92
+ } catch {
93
+ // Logging must never keep the plugin from loading.
94
+ }
95
+
83
96
  ctx.effect(() => () => {
84
97
  // Nothing to flush: all writes are atomic and complete at call time.
85
98
  }, 'tacit: dispose')
package/lib/routes.js CHANGED
@@ -126,7 +126,7 @@ export function registerWebRoutes(ctx, service) {
126
126
  }))
127
127
  }
128
128
 
129
- route('POST', '/api/tacit/state', () => service.getState())
129
+ route('POST', '/api/tacit/state', (body) => service.getState(body))
130
130
  route('POST', '/api/tacit/reports', (body) => service.getReports(body))
131
131
  route('POST', '/api/tacit/history', (body) => service.listHistory(body))
132
132
  route('POST', '/api/tacit/analyze', (body) => service.analyzeTurn(body))
package/lib/schema.js CHANGED
@@ -59,6 +59,10 @@ export const Config = z.preprocess((v) => v ?? {}, z.object({
59
59
  directiveTrialTurns: z.number().default(10),
60
60
  /** A candidate retires when the messy-turn rate during its trial exceeds the baseline by more than this. */
61
61
  directiveWorseBy: z.number().default(0.15),
62
+ /** Bootstrap analyses run at once (1 = one after another; same calls, less waiting). */
63
+ bootstrapConcurrency: z.number().default(1),
64
+ /** Also learn from a clean turn that follows a messy one (what the user included the second time). Automatic, capped. */
65
+ learnFromGood: z.boolean().default(true),
62
66
  }))
63
67
 
64
68
  /**
@@ -82,6 +86,8 @@ const configPatchSchema = z.object({
82
86
  enrichPrompts: z.boolean().optional(),
83
87
  directiveTrialTurns: z.number().optional(),
84
88
  directiveWorseBy: z.number().optional(),
89
+ bootstrapConcurrency: z.number().optional(),
90
+ learnFromGood: z.boolean().optional(),
85
91
  })
86
92
 
87
93
  // ── Trajectory projection ──────────────────────────────────────────────────
@@ -160,6 +166,12 @@ export const reportSchema = z.object({
160
166
  trigger: z.string().default('manual'),
161
167
  /** The user's next message when it triggered the analysis (clipped). */
162
168
  followUp: z.string().optional(),
169
+ /** Absolute workspace directory of the conversation, when the harness knew it. */
170
+ cwd: z.string().optional(),
171
+ /** trigger 'good' only: what the clean prompt supplied that the messy one before it lacked. */
172
+ strengths: z.array(z.object({ kind: z.string(), what: z.string() })).optional(),
173
+ /** trigger 'good' only: the one-sentence lesson fed to the distiller. */
174
+ lesson: z.string().optional(),
163
175
  })
164
176
 
165
177
  /**
@@ -178,6 +190,8 @@ export const patternCountersSchema = z.object({
178
190
  verified: z.number().int().default(0),
179
191
  /** Times the next turn's outcome was same/worse than the baseline. */
180
192
  unverified: z.number().int().default(0),
193
+ /** Times a clean prompt right after a messy turn showed the user supplying this themselves. */
194
+ resolved: z.number().int().default(0),
181
195
  })
182
196
 
183
197
  /** One distilled durable style rule (from rejected-improvement reasons). */
@@ -219,6 +233,8 @@ const directiveSchema = z.object({
219
233
  status: z.enum(['candidate', 'active', 'retired']).default('active'),
220
234
  trial: directiveTrialSchema.optional(),
221
235
  retiredReason: z.string().optional(),
236
+ /** Absolute workspace directory this directive is limited to; absent = every conversation. */
237
+ workspace: z.string().optional(),
222
238
  })
223
239
 
224
240
  /** The persistent user-wide mistake profile. */
@@ -257,7 +273,7 @@ export const statsArgSchema = z.object({
257
273
 
258
274
  export const directivesArgSchema = z.discriminatedUnion('action', [
259
275
  z.object({ action: z.literal('toggle'), id: z.string().min(1).max(64), enabled: z.boolean() }),
260
- z.object({ action: z.literal('add'), text: z.string().min(1).max(300) }),
276
+ z.object({ action: z.literal('add'), text: z.string().min(1).max(300), workspace: z.string().max(1000).optional() }),
261
277
  z.object({ action: z.literal('remove'), id: z.string().min(1).max(64) }),
262
278
  ])
263
279