spurline-dashboard 0.3.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 (124) hide show
  1. checksums.yaml +7 -0
  2. data/lib/CLAUDE.md +11 -0
  3. data/lib/spurline/CLAUDE.md +16 -0
  4. data/lib/spurline/adapters/CLAUDE.md +12 -0
  5. data/lib/spurline/adapters/base.rb +17 -0
  6. data/lib/spurline/adapters/claude.rb +208 -0
  7. data/lib/spurline/adapters/open_ai.rb +213 -0
  8. data/lib/spurline/adapters/registry.rb +33 -0
  9. data/lib/spurline/adapters/scheduler/base.rb +15 -0
  10. data/lib/spurline/adapters/scheduler/sync.rb +15 -0
  11. data/lib/spurline/adapters/stub_adapter.rb +54 -0
  12. data/lib/spurline/agent.rb +433 -0
  13. data/lib/spurline/audit/log.rb +156 -0
  14. data/lib/spurline/audit/secret_filter.rb +121 -0
  15. data/lib/spurline/base.rb +130 -0
  16. data/lib/spurline/cartographer/CLAUDE.md +12 -0
  17. data/lib/spurline/cartographer/analyzer.rb +71 -0
  18. data/lib/spurline/cartographer/analyzers/CLAUDE.md +12 -0
  19. data/lib/spurline/cartographer/analyzers/ci_config.rb +171 -0
  20. data/lib/spurline/cartographer/analyzers/dotfiles.rb +134 -0
  21. data/lib/spurline/cartographer/analyzers/entry_points.rb +145 -0
  22. data/lib/spurline/cartographer/analyzers/file_signatures.rb +55 -0
  23. data/lib/spurline/cartographer/analyzers/manifests.rb +217 -0
  24. data/lib/spurline/cartographer/analyzers/security_scan.rb +223 -0
  25. data/lib/spurline/cartographer/repo_profile.rb +140 -0
  26. data/lib/spurline/cartographer/runner.rb +88 -0
  27. data/lib/spurline/cartographer.rb +6 -0
  28. data/lib/spurline/channels/base.rb +41 -0
  29. data/lib/spurline/channels/event.rb +136 -0
  30. data/lib/spurline/channels/github.rb +205 -0
  31. data/lib/spurline/channels/router.rb +103 -0
  32. data/lib/spurline/cli/check.rb +88 -0
  33. data/lib/spurline/cli/checks/CLAUDE.md +11 -0
  34. data/lib/spurline/cli/checks/adapter_resolution.rb +81 -0
  35. data/lib/spurline/cli/checks/agent_loadability.rb +41 -0
  36. data/lib/spurline/cli/checks/base.rb +35 -0
  37. data/lib/spurline/cli/checks/credentials.rb +43 -0
  38. data/lib/spurline/cli/checks/permissions.rb +22 -0
  39. data/lib/spurline/cli/checks/project_structure.rb +48 -0
  40. data/lib/spurline/cli/checks/session_store.rb +97 -0
  41. data/lib/spurline/cli/console.rb +73 -0
  42. data/lib/spurline/cli/credentials.rb +181 -0
  43. data/lib/spurline/cli/generators/CLAUDE.md +11 -0
  44. data/lib/spurline/cli/generators/agent.rb +123 -0
  45. data/lib/spurline/cli/generators/migration.rb +62 -0
  46. data/lib/spurline/cli/generators/project.rb +331 -0
  47. data/lib/spurline/cli/generators/tool.rb +98 -0
  48. data/lib/spurline/cli/router.rb +121 -0
  49. data/lib/spurline/configuration.rb +23 -0
  50. data/lib/spurline/dsl/CLAUDE.md +11 -0
  51. data/lib/spurline/dsl/guardrails.rb +108 -0
  52. data/lib/spurline/dsl/hooks.rb +51 -0
  53. data/lib/spurline/dsl/memory.rb +39 -0
  54. data/lib/spurline/dsl/model.rb +23 -0
  55. data/lib/spurline/dsl/persona.rb +74 -0
  56. data/lib/spurline/dsl/suspend_until.rb +53 -0
  57. data/lib/spurline/dsl/tools.rb +176 -0
  58. data/lib/spurline/errors.rb +109 -0
  59. data/lib/spurline/lifecycle/CLAUDE.md +18 -0
  60. data/lib/spurline/lifecycle/deterministic_runner.rb +207 -0
  61. data/lib/spurline/lifecycle/runner.rb +456 -0
  62. data/lib/spurline/lifecycle/states.rb +47 -0
  63. data/lib/spurline/lifecycle/suspension_boundary.rb +82 -0
  64. data/lib/spurline/memory/CLAUDE.md +12 -0
  65. data/lib/spurline/memory/context_assembler.rb +100 -0
  66. data/lib/spurline/memory/embedder/CLAUDE.md +11 -0
  67. data/lib/spurline/memory/embedder/base.rb +17 -0
  68. data/lib/spurline/memory/embedder/open_ai.rb +70 -0
  69. data/lib/spurline/memory/episode.rb +56 -0
  70. data/lib/spurline/memory/episodic_store.rb +147 -0
  71. data/lib/spurline/memory/long_term/CLAUDE.md +11 -0
  72. data/lib/spurline/memory/long_term/base.rb +22 -0
  73. data/lib/spurline/memory/long_term/postgres.rb +106 -0
  74. data/lib/spurline/memory/manager.rb +147 -0
  75. data/lib/spurline/memory/short_term.rb +57 -0
  76. data/lib/spurline/orchestration/agent_spawner.rb +151 -0
  77. data/lib/spurline/orchestration/judge.rb +109 -0
  78. data/lib/spurline/orchestration/ledger/store/base.rb +28 -0
  79. data/lib/spurline/orchestration/ledger/store/memory.rb +50 -0
  80. data/lib/spurline/orchestration/ledger.rb +339 -0
  81. data/lib/spurline/orchestration/merge_queue.rb +133 -0
  82. data/lib/spurline/orchestration/permission_intersection.rb +151 -0
  83. data/lib/spurline/orchestration/task_envelope.rb +201 -0
  84. data/lib/spurline/persona/base.rb +42 -0
  85. data/lib/spurline/persona/registry.rb +42 -0
  86. data/lib/spurline/secrets/resolver.rb +65 -0
  87. data/lib/spurline/secrets/vault.rb +42 -0
  88. data/lib/spurline/security/content.rb +76 -0
  89. data/lib/spurline/security/context_pipeline.rb +58 -0
  90. data/lib/spurline/security/gates/base.rb +36 -0
  91. data/lib/spurline/security/gates/operator_config.rb +22 -0
  92. data/lib/spurline/security/gates/system_prompt.rb +23 -0
  93. data/lib/spurline/security/gates/tool_result.rb +23 -0
  94. data/lib/spurline/security/gates/user_input.rb +22 -0
  95. data/lib/spurline/security/injection_scanner.rb +109 -0
  96. data/lib/spurline/security/pii_filter.rb +104 -0
  97. data/lib/spurline/session/CLAUDE.md +11 -0
  98. data/lib/spurline/session/resumption.rb +36 -0
  99. data/lib/spurline/session/serializer.rb +169 -0
  100. data/lib/spurline/session/session.rb +154 -0
  101. data/lib/spurline/session/store/CLAUDE.md +12 -0
  102. data/lib/spurline/session/store/base.rb +27 -0
  103. data/lib/spurline/session/store/memory.rb +45 -0
  104. data/lib/spurline/session/store/postgres.rb +123 -0
  105. data/lib/spurline/session/store/sqlite.rb +139 -0
  106. data/lib/spurline/session/suspension.rb +93 -0
  107. data/lib/spurline/session/turn.rb +98 -0
  108. data/lib/spurline/spur.rb +213 -0
  109. data/lib/spurline/streaming/CLAUDE.md +12 -0
  110. data/lib/spurline/streaming/buffer.rb +77 -0
  111. data/lib/spurline/streaming/chunk.rb +62 -0
  112. data/lib/spurline/streaming/stream_enumerator.rb +29 -0
  113. data/lib/spurline/testing.rb +245 -0
  114. data/lib/spurline/toolkit.rb +110 -0
  115. data/lib/spurline/tools/base.rb +209 -0
  116. data/lib/spurline/tools/idempotency.rb +220 -0
  117. data/lib/spurline/tools/permissions.rb +44 -0
  118. data/lib/spurline/tools/registry.rb +43 -0
  119. data/lib/spurline/tools/runner.rb +255 -0
  120. data/lib/spurline/tools/scope.rb +309 -0
  121. data/lib/spurline/tools/toolkit_registry.rb +63 -0
  122. data/lib/spurline/version.rb +5 -0
  123. data/lib/spurline.rb +56 -0
  124. metadata +218 -0
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Spurline
4
+ module Security
5
+ # The cardinal type of the Spurline framework. Every piece of content flowing
6
+ # through the system is a Content object carrying a trust level and source.
7
+ # Raw strings never enter the context pipeline.
8
+ #
9
+ # Content objects are frozen on creation and cannot be mutated.
10
+ class Content
11
+ TRUST_LEVELS = %i[system operator user external untrusted].freeze
12
+
13
+ TAINTED_LEVELS = %i[external untrusted].freeze
14
+
15
+ attr_reader :text, :trust, :source
16
+
17
+ def initialize(text:, trust:, source:)
18
+ validate_trust!(trust)
19
+
20
+ @text = text.dup.freeze
21
+ @trust = trust
22
+ @source = source.dup.freeze
23
+ freeze
24
+ end
25
+
26
+ # Raises TaintedContentError for tainted content. Use #render instead.
27
+ def to_s
28
+ if tainted?
29
+ raise Spurline::TaintedContentError,
30
+ "Cannot convert tainted content (trust: #{trust}, source: #{source}) to string. " \
31
+ "Use Content#render to get a safely fenced string."
32
+ end
33
+
34
+ text
35
+ end
36
+
37
+ # Returns the content as a string, applying XML data fencing for tainted content.
38
+ # This is the ONLY safe way to extract a string from tainted content.
39
+ def render
40
+ return text unless tainted?
41
+
42
+ <<~XML.strip
43
+ <external_data trust="#{trust}" source="#{source}">
44
+ #{text}
45
+ </external_data>
46
+ XML
47
+ end
48
+
49
+ def tainted?
50
+ TAINTED_LEVELS.include?(trust)
51
+ end
52
+
53
+ def ==(other)
54
+ other.is_a?(Content) &&
55
+ text == other.text &&
56
+ trust == other.trust &&
57
+ source == other.source
58
+ end
59
+
60
+ def inspect
61
+ "#<Spurline::Security::Content trust=#{trust} source=#{source.inspect} " \
62
+ "text=#{text[0..50].inspect}#{text.length > 50 ? "..." : ""}>"
63
+ end
64
+
65
+ private
66
+
67
+ def validate_trust!(trust)
68
+ return if TRUST_LEVELS.include?(trust)
69
+
70
+ raise Spurline::ConfigurationError,
71
+ "Invalid trust level: #{trust.inspect}. " \
72
+ "Must be one of: #{TRUST_LEVELS.map(&:inspect).join(", ")}."
73
+ end
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Spurline
4
+ module Security
5
+ # The only path content takes to the LLM. Every LLM call assembles context
6
+ # through this pipeline. The stages run in fixed order and cannot be reordered.
7
+ #
8
+ # Pipeline stages:
9
+ # 1. Injection scanning — detect and block prompt injection attempts
10
+ # 2. PII filtering — redact/block/warn on personally identifiable information
11
+ # 3. Data fencing — render tainted content with XML fencing
12
+ #
13
+ # Input: Array of Content objects at various trust levels
14
+ # Output: Array of rendered strings, safe for inclusion in an LLM prompt
15
+ class ContextPipeline
16
+ def initialize(guardrails: {})
17
+ @scanner = InjectionScanner.new(
18
+ level: guardrails.fetch(:injection_filter, :strict)
19
+ )
20
+ @pii_filter = PIIFilter.new(
21
+ mode: guardrails.fetch(:pii_filter, :off)
22
+ )
23
+ end
24
+
25
+ # Processes an array of Content objects through the full security pipeline.
26
+ # Returns an array of safe, rendered strings ready for the LLM.
27
+ #
28
+ # Raises InjectionAttemptError if injection patterns are detected.
29
+ def process(contents)
30
+ contents.map do |content|
31
+ validate_content!(content)
32
+ scan!(content)
33
+ filtered = filter(content)
34
+ filtered.render
35
+ end
36
+ end
37
+
38
+ private
39
+
40
+ def validate_content!(content)
41
+ return if content.is_a?(Content)
42
+
43
+ raise Spurline::TaintedContentError,
44
+ "ContextPipeline received #{content.class.name} instead of " \
45
+ "Spurline::Security::Content. All content must enter through a Gate. " \
46
+ "Raw strings are never allowed in the pipeline."
47
+ end
48
+
49
+ def scan!(content)
50
+ @scanner.scan!(content)
51
+ end
52
+
53
+ def filter(content)
54
+ @pii_filter.filter(content)
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Spurline
4
+ module Security
5
+ module Gates
6
+ # Abstract base class for security gates. Each gate wraps raw input
7
+ # into a Content object with the appropriate trust level and source.
8
+ #
9
+ # All external data enters the framework through exactly one of four gates.
10
+ # Nothing bypasses a gate.
11
+ class Base
12
+ class << self
13
+ # Wraps raw text into a Content object with the gate's trust level.
14
+ # Subclasses must implement #trust_level and #source_for.
15
+ def wrap(text, **metadata)
16
+ Content.new(
17
+ text: text,
18
+ trust: trust_level,
19
+ source: source_for(**metadata)
20
+ )
21
+ end
22
+
23
+ private
24
+
25
+ def trust_level
26
+ raise NotImplementedError, "#{name} must implement .trust_level"
27
+ end
28
+
29
+ def source_for(**_metadata)
30
+ raise NotImplementedError, "#{name} must implement .source_for"
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Spurline
4
+ module Security
5
+ module Gates
6
+ # Gate for developer-authored configuration. Trust level: :operator.
7
+ class OperatorConfig < Base
8
+ class << self
9
+ private
10
+
11
+ def trust_level
12
+ :operator
13
+ end
14
+
15
+ def source_for(key: "config", **)
16
+ "config:#{key}"
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Spurline
4
+ module Security
5
+ module Gates
6
+ # Gate for framework and persona prompts. Trust level: :system.
7
+ # System prompts are trusted by definition and bypass the injection scanner.
8
+ class SystemPrompt < Base
9
+ class << self
10
+ private
11
+
12
+ def trust_level
13
+ :system
14
+ end
15
+
16
+ def source_for(persona: "default", **)
17
+ "persona:#{persona}"
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Spurline
4
+ module Security
5
+ module Gates
6
+ # Gate for tool execution results. Trust level: :external.
7
+ # Tool results are always tainted — they come from outside the trust boundary.
8
+ class ToolResult < Base
9
+ class << self
10
+ private
11
+
12
+ def trust_level
13
+ :external
14
+ end
15
+
16
+ def source_for(tool_name: "unknown", **)
17
+ "tool:#{tool_name}"
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Spurline
4
+ module Security
5
+ module Gates
6
+ # Gate for live user messages. Trust level: :user.
7
+ class UserInput < Base
8
+ class << self
9
+ private
10
+
11
+ def trust_level
12
+ :user
13
+ end
14
+
15
+ def source_for(user_id: "anonymous", **)
16
+ "user:#{user_id}"
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Spurline
4
+ module Security
5
+ # Scans Content objects for prompt injection patterns.
6
+ # Configurable strictness: :strict, :moderate, :permissive.
7
+ #
8
+ # Only scans content at trust levels that could be injected (:user, :external, :untrusted).
9
+ # System and operator content is trusted by definition and bypasses scanning.
10
+ #
11
+ # Pattern tiers are additive: :strict includes all :moderate patterns,
12
+ # :moderate includes all :permissive (BASE) patterns.
13
+ class InjectionScanner
14
+ SKIP_TRUST_LEVELS = %i[system operator].freeze
15
+
16
+ # Patterns checked at all strictness levels — the most obvious injection attempts.
17
+ BASE_PATTERNS = [
18
+ /ignore\s+(all\s+)?(previous|prior|above|earlier)\s+(instructions|prompts|context|rules)/i,
19
+ /you\s+are\s+now\s+(a|an|in)\s+/i,
20
+ /\bsystem\s*:\s*\n/i,
21
+ /\bforget\s+(all\s+|everything\s+)?(previous|prior|your)\s+(instructions|context|rules|training)/i,
22
+ /\bdisregard\s+(all\s+)?(previous|prior|above|your)\s+(instructions|prompts|rules)/i,
23
+ /\bnew\s+instructions\s*:/i,
24
+ /\bpretend\s+(you\s+are|to\s+be|that\s+you)/i,
25
+ ].freeze
26
+
27
+ # Additional patterns for :moderate and :strict — social engineering and role manipulation.
28
+ MODERATE_PATTERNS = [
29
+ /\bdo\s+not\s+follow\b/i,
30
+ /\boverride\s+(your|the)\s+(instructions|rules|guidelines|programming)\b/i,
31
+ /\bact\s+as\s+(if\s+you\s+are|though\s+you|a\b)/i,
32
+ /\bbehave\s+as\s+(if|though|a\b)/i,
33
+ /\bfrom\s+now\s+on\s*,?\s*(you|your|act|behave|respond|ignore)/i,
34
+ /\bjailbreak/i,
35
+ /\bdeveloper\s+mode\b/i,
36
+ /\bDAN\s+(mode|prompt)\b/i,
37
+ /\bdo\s+anything\s+now\b/i,
38
+ /\bunfiltered\s+(mode|response|output)\b/i,
39
+ /\bno\s+(restrictions|rules|limitations|filters|censorship)\b/i,
40
+ /\bbypass\s+(your|the|any|all)\s+(restrictions|rules|filters|safety|guidelines)/i,
41
+ ].freeze
42
+
43
+ # Additional patterns for :strict only — structural attacks and format manipulation.
44
+ STRICT_PATTERNS = [
45
+ /\brole\s*:\s*(system|assistant)\b/i,
46
+ /<\/?system>/i,
47
+ /\[INST\]/i,
48
+ /<<\s*SYS\s*>>/i,
49
+ /<\|im_start\|>/i,
50
+ /\bIMPORTANT\s*:\s*(new|override|ignore|forget|disregard|update)/i,
51
+ /\bATTENTION\s*:\s*(new|override|ignore|forget|disregard|update)/i,
52
+ /\b(BEGIN|END)\s+(SYSTEM|INSTRUCTION|PROMPT)\b/i,
53
+ /---+\s*\n\s*(system|instruction|new prompt|override)/i,
54
+ /\bbase64\s*[\s:]+[A-Za-z0-9+\/=]{20,}/i,
55
+ /\btranslate\s+(the\s+)?(following|this)\s+(from|to)\s+.*\s+(and|then)\s+(ignore|forget|override)/i,
56
+ /\brepeat\s+(the\s+)?(system\s+prompt|instructions|your\s+rules)/i,
57
+ /\b(reveal|show|display|output|print)\s+(your|the)\s+(system\s+prompt|instructions|rules)/i,
58
+ ].freeze
59
+
60
+ LEVELS = %i[strict moderate permissive].freeze
61
+
62
+ attr_reader :level
63
+
64
+ def initialize(level: :strict)
65
+ validate_level!(level)
66
+ @level = level
67
+ end
68
+
69
+ # Scans a Content object for injection patterns.
70
+ # Returns nil if clean, raises InjectionAttemptError if detected.
71
+ def scan!(content)
72
+ return if SKIP_TRUST_LEVELS.include?(content.trust)
73
+
74
+ text = content.text
75
+ patterns_for_level.each do |pattern|
76
+ next unless text.match?(pattern)
77
+
78
+ raise Spurline::InjectionAttemptError,
79
+ "Injection pattern detected in content (trust: #{content.trust}, " \
80
+ "source: #{content.source}). Pattern: #{pattern.source[0..40]}. " \
81
+ "Review the content or adjust injection_filter level."
82
+ end
83
+
84
+ nil
85
+ end
86
+
87
+ private
88
+
89
+ def patterns_for_level
90
+ case level
91
+ when :strict
92
+ BASE_PATTERNS + MODERATE_PATTERNS + STRICT_PATTERNS
93
+ when :moderate
94
+ BASE_PATTERNS + MODERATE_PATTERNS
95
+ when :permissive
96
+ BASE_PATTERNS
97
+ end
98
+ end
99
+
100
+ def validate_level!(level)
101
+ return if LEVELS.include?(level)
102
+
103
+ raise Spurline::ConfigurationError,
104
+ "Invalid injection filter level: #{level.inspect}. " \
105
+ "Must be one of: #{LEVELS.map(&:inspect).join(", ")}."
106
+ end
107
+ end
108
+ end
109
+ end
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Spurline
4
+ module Security
5
+ # Filters personally identifiable information from Content objects.
6
+ # Modes:
7
+ # :redact — replaces detected PII with [REDACTED_<type>] placeholders
8
+ # :block — raises PIIDetectedError if PII is found
9
+ # :warn — returns content unchanged but records detections for audit
10
+ # :off — no scanning, returns content unchanged
11
+ #
12
+ # Only scans content at trust levels where PII matters (:user, :external, :untrusted).
13
+ # System and operator content is trusted by definition and bypasses PII filtering.
14
+ class PIIFilter
15
+ MODES = %i[redact block warn off].freeze
16
+
17
+ SKIP_TRUST_LEVELS = %i[system operator].freeze
18
+
19
+ # Pattern definitions: [label, regex, replacement_tag]
20
+ # Ordered from most specific to least specific to prevent partial matches.
21
+ PII_PATTERNS = [
22
+ [:ssn, /\b\d{3}-\d{2}-\d{4}\b/, "[REDACTED_SSN]"],
23
+ [:credit_card, /\b(?:\d{4}[\s-]?){3}\d{4}\b/, "[REDACTED_CREDIT_CARD]"],
24
+ [:phone, /\b(?:\+?1[\s.-]?)?\(?\d{3}\)?[\s.-]?\d{3}[\s.-]?\d{4}\b/, "[REDACTED_PHONE]"],
25
+ [:email, /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/, "[REDACTED_EMAIL]"],
26
+ [:ip_address, /\b(?:\d{1,3}\.){3}\d{1,3}\b/, "[REDACTED_IP]"],
27
+ ].freeze
28
+
29
+ attr_reader :mode
30
+
31
+ def initialize(mode: :off)
32
+ validate_mode!(mode)
33
+ @mode = mode
34
+ end
35
+
36
+ # Filters a Content object for PII.
37
+ # Returns the original content if no PII is detected or mode is :off.
38
+ # Returns a new Content object with redacted text in :redact mode.
39
+ # Raises PIIDetectedError in :block mode.
40
+ # Returns the original content with detections array in :warn mode.
41
+ def filter(content)
42
+ return content if mode == :off
43
+ return content if SKIP_TRUST_LEVELS.include?(content.trust)
44
+
45
+ detections = detect(content.text)
46
+ return content if detections.empty?
47
+
48
+ case mode
49
+ when :redact
50
+ redact(content, detections)
51
+ when :block
52
+ block!(content, detections)
53
+ when :warn
54
+ # In :warn mode, content passes through unchanged.
55
+ # The caller (ContextPipeline) can check detections via the return.
56
+ # For now, we simply return the content — audit logging is deferred.
57
+ content
58
+ end
59
+ end
60
+
61
+ # Scans text for PII patterns. Returns array of {type:, match:, pattern:} hashes.
62
+ def detect(text)
63
+ detections = []
64
+ PII_PATTERNS.each do |label, pattern, _|
65
+ text.scan(pattern) do |match|
66
+ detected = match.is_a?(Array) ? match.first : $~.to_s
67
+ detections << { type: label, match: detected, pattern: pattern }
68
+ end
69
+ end
70
+ detections
71
+ end
72
+
73
+ private
74
+
75
+ def redact(content, detections)
76
+ redacted_text = content.text.dup
77
+ PII_PATTERNS.each do |_, pattern, replacement|
78
+ redacted_text.gsub!(pattern, replacement)
79
+ end
80
+
81
+ Content.new(
82
+ text: redacted_text,
83
+ trust: content.trust,
84
+ source: content.source
85
+ )
86
+ end
87
+
88
+ def block!(content, detections)
89
+ types = detections.map { |d| d[:type] }.uniq.join(", ")
90
+ raise Spurline::PIIDetectedError,
91
+ "PII detected in content (trust: #{content.trust}, source: #{content.source}). " \
92
+ "Types found: #{types}. Set pii_filter to :redact or :off to allow this content."
93
+ end
94
+
95
+ def validate_mode!(mode)
96
+ return if MODES.include?(mode)
97
+
98
+ raise Spurline::ConfigurationError,
99
+ "Invalid PII filter mode: #{mode.inspect}. " \
100
+ "Must be one of: #{MODES.map(&:inspect).join(", ")}."
101
+ end
102
+ end
103
+ end
104
+ end
@@ -0,0 +1,11 @@
1
+ <claude-mem-context>
2
+ # Recent Activity
3
+
4
+ <!-- This section is auto-generated by claude-mem. Edit content outside the tags. -->
5
+
6
+ ### Feb 21, 2026
7
+
8
+ | ID | Time | T | Title | Read |
9
+ |----|------|---|-------|------|
10
+ | #3782 | 10:23 PM | 🔵 | Spurline session and state machine architecture analyzed for suspended sessions feature | ~700 |
11
+ </claude-mem-context>
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Spurline
4
+ module Session
5
+ # Handles restoring a resumed session's turn history into short-term memory.
6
+ # When a session is resumed by ID, completed turns are replayed into the
7
+ # memory manager so the agent has context from the previous conversation.
8
+ class Resumption
9
+ attr_reader :restored_count
10
+
11
+ def initialize(session:, memory:)
12
+ @session = session
13
+ @memory = memory
14
+ @restored_count = 0
15
+ end
16
+
17
+ # Restores the session's completed turn history into the memory manager.
18
+ # Incomplete turns are skipped — they represent interrupted work.
19
+ def restore!
20
+ @session.turns.each do |turn|
21
+ next unless turn.complete?
22
+
23
+ @memory.add_turn(turn)
24
+ @restored_count += 1
25
+ end
26
+
27
+ @restored_count
28
+ end
29
+
30
+ # Whether this session has prior turns to restore.
31
+ def resumable?
32
+ @session.turns.any?(&:complete?)
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,169 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "time"
5
+
6
+ module Spurline
7
+ module Session
8
+ # JSON serializer/deserializer for persisted sessions.
9
+ # Includes a format_version field for forward-compatible migrations.
10
+ class Serializer
11
+ FORMAT_VERSION = 1
12
+ CONTENT_TYPE = "Spurline::Security::Content"
13
+ TIME_TYPE = "Time"
14
+ SYMBOL_TYPE = "Symbol"
15
+
16
+ def to_json(session)
17
+ JSON.generate(
18
+ format_version: FORMAT_VERSION,
19
+ session: serialize_session(session)
20
+ )
21
+ end
22
+
23
+ def from_json(json, store:)
24
+ payload = JSON.parse(json)
25
+ unless payload.is_a?(Hash)
26
+ raise Spurline::SessionDeserializationError, "Session payload must be a JSON object."
27
+ end
28
+
29
+ version = payload.fetch("format_version")
30
+ unless version == FORMAT_VERSION
31
+ raise Spurline::SessionDeserializationError,
32
+ "Unsupported session format version: #{version.inspect}."
33
+ end
34
+
35
+ session_data = deserialize_session(payload.fetch("session"))
36
+ Session.restore(session_data, store: store)
37
+ rescue Spurline::SessionDeserializationError
38
+ raise
39
+ rescue JSON::ParserError, KeyError, TypeError, NoMethodError, ArgumentError, Spurline::ConfigurationError => e
40
+ raise Spurline::SessionDeserializationError, "Failed to deserialize session payload: #{e.message}"
41
+ end
42
+
43
+ private
44
+
45
+ def serialize_session(session)
46
+ {
47
+ id: session.id,
48
+ agent_class: session.agent_class,
49
+ user: session.user,
50
+ state: session.state.to_s,
51
+ started_at: serialize_value(session.started_at),
52
+ finished_at: serialize_value(session.finished_at),
53
+ metadata: serialize_value(session.metadata),
54
+ turns: session.turns.map { |turn| serialize_turn(turn) },
55
+ }
56
+ end
57
+
58
+ def serialize_turn(turn)
59
+ {
60
+ input: serialize_value(turn.input),
61
+ output: serialize_value(turn.output),
62
+ tool_calls: serialize_value(turn.tool_calls),
63
+ number: turn.number,
64
+ started_at: serialize_value(turn.started_at),
65
+ finished_at: serialize_value(turn.finished_at),
66
+ metadata: serialize_value(turn.metadata),
67
+ }
68
+ end
69
+
70
+ def serialize_value(value)
71
+ case value
72
+ when Spurline::Security::Content
73
+ {
74
+ __type: CONTENT_TYPE,
75
+ text: value.text,
76
+ trust: value.trust.to_s,
77
+ source: value.source,
78
+ }
79
+ when Time
80
+ {
81
+ __type: TIME_TYPE,
82
+ iso8601: value.utc.iso8601(6),
83
+ }
84
+ when Symbol
85
+ {
86
+ __type: SYMBOL_TYPE,
87
+ value: value.to_s,
88
+ }
89
+ when Array
90
+ value.map { |item| serialize_value(item) }
91
+ when Hash
92
+ value.each_with_object({}) do |(key, item), hash|
93
+ hash[key.to_s] = serialize_value(item)
94
+ end
95
+ else
96
+ value
97
+ end
98
+ end
99
+
100
+ def deserialize_session(data)
101
+ hash = deserialize_hash(data)
102
+ {
103
+ id: hash.fetch(:id),
104
+ agent_class: hash[:agent_class],
105
+ user: hash[:user],
106
+ turns: Array(hash[:turns]).map { |turn_data| Turn.restore(deserialize_turn(turn_data)) },
107
+ state: hash.fetch(:state).to_sym,
108
+ started_at: hash.fetch(:started_at),
109
+ finished_at: hash[:finished_at],
110
+ metadata: hash[:metadata] || {},
111
+ }
112
+ end
113
+
114
+ def deserialize_turn(data)
115
+ hash = deserialize_hash(data)
116
+ {
117
+ input: hash[:input],
118
+ output: hash[:output],
119
+ tool_calls: hash[:tool_calls] || [],
120
+ number: hash.fetch(:number),
121
+ started_at: hash.fetch(:started_at),
122
+ finished_at: hash[:finished_at],
123
+ metadata: hash[:metadata] || {},
124
+ }
125
+ end
126
+
127
+ def deserialize_hash(value)
128
+ hash = deserialize_value(value)
129
+ unless hash.is_a?(Hash)
130
+ raise TypeError, "Expected Hash value, got #{hash.class}."
131
+ end
132
+
133
+ hash
134
+ end
135
+
136
+ def deserialize_value(value)
137
+ case value
138
+ when Array
139
+ value.map { |item| deserialize_value(item) }
140
+ when Hash
141
+ deserialize_hash_value(value)
142
+ else
143
+ value
144
+ end
145
+ end
146
+
147
+ def deserialize_hash_value(value)
148
+ type = value["__type"]
149
+
150
+ case type
151
+ when CONTENT_TYPE
152
+ Spurline::Security::Content.new(
153
+ text: value.fetch("text"),
154
+ trust: value.fetch("trust").to_sym,
155
+ source: value.fetch("source")
156
+ )
157
+ when TIME_TYPE
158
+ Time.iso8601(value.fetch("iso8601"))
159
+ when SYMBOL_TYPE
160
+ value.fetch("value").to_sym
161
+ else
162
+ value.each_with_object({}) do |(key, item), hash|
163
+ hash[key.to_sym] = deserialize_value(item)
164
+ end
165
+ end
166
+ end
167
+ end
168
+ end
169
+ end